Skip to main content

repame_sprite/
lib.rs

1//! 2D sprite viewport: snapshot in, pixels out.
2//!
3//! The UI builds a [`FrameInput`] per frame (plain data, cheap to rebuild
4//! during composition) and mounts [`Viewport2d`] as a Repose view, which
5//! draws the snapshot through a dp-space contain-fit and reports pointer
6//! picks back in world coords. Unit discipline lives here, once:
7//! games work purely in world/dp units and never touch physical px.
8//! Camera state lives in Repose signals, never in the renderer.
9
10use std::cell::Cell;
11use std::rc::Rc;
12use std::sync::{Arc, Mutex};
13
14use glam::{Mat4, Vec2, Vec3};
15use repose_canvas::{Canvas, DrawScope, Embedded};
16use repose_core::locals::effective_density_scale;
17use repose_core::{Color, Modifier, Rect, View};
18use repose_render_wgpu::{Callback, CallbackResources, ScreenDescriptor, WgpuCallback};
19use repose_ui::Box as UiBox;
20use repose_ui::ViewExt;
21
22pub mod batch;
23use batch::draw_batch;
24pub use batch::{AtlasUpload, BatchDesc, SpriteBatch, TextureFilter, instance_rows, screen_camera};
25
26/// 2D orthographic camera. Owned by the UI (Repose signal), copied into the
27/// snapshot per frame.
28#[derive(Clone, Copy, Debug)]
29pub struct Camera2d {
30    /// World-space center the camera looks at.
31    pub center: Vec2,
32    /// World units per screen pixel at zoom 1. Combined with viewport size
33    /// to build the ortho projection.
34    pub units_per_pixel: f32,
35    pub zoom: f32,
36}
37
38impl Default for Camera2d {
39    fn default() -> Self {
40        Self {
41            center: Vec2::ZERO,
42            units_per_pixel: 1.0,
43            zoom: 1.0,
44        }
45    }
46}
47
48impl Camera2d {
49    pub fn view_proj(&self, viewport_px: [f32; 2]) -> Mat4 {
50        let w = viewport_px[0] * self.units_per_pixel / self.zoom;
51        let h = viewport_px[1] * self.units_per_pixel / self.zoom;
52        // Right-handed, 0..1 depth: matches wgpu NDC.
53        let proj = glam::camera::rh::proj::directx::orthographic(
54            -w / 2.0,
55            w / 2.0,
56            -h / 2.0,
57            h / 2.0,
58            -1000.0,
59            1000.0,
60        );
61        let view = Mat4::from_translation(Vec3::new(-self.center.x, -self.center.y, 0.0));
62        proj * view
63    }
64
65    /// World-space position under a viewport-pixel cursor position.
66    pub fn screen_to_world(&self, viewport_px: [f32; 2], px: [f32; 2]) -> Vec2 {
67        let ndc = Vec2::new(
68            (px[0] / viewport_px[0]) * 2.0 - 1.0,
69            1.0 - (px[1] / viewport_px[1]) * 2.0,
70        );
71        let inv = self.view_proj(viewport_px).inverse();
72        let world = inv.project_point3(ndc.extend(0.0));
73        Vec2::new(world.x, world.y)
74    }
75}
76
77/// One batched sprite. `uv` is in atlas texels normalized to 0..1.
78#[derive(Clone, Copy, Debug)]
79pub struct SpriteInstance {
80    /// World-space center, rotation radians, size in world units.
81    pub center: Vec2,
82    pub rotation: f32,
83    pub size: Vec2,
84    /// Normalized anchor (origin): `[0.5, 0.5]` centers the quad on
85    /// `center` (legacy default), `[0, 0]` pins the top-left corner -
86    /// bevy `Anchor` semantics, y-down.
87    pub anchor: Vec2,
88    /// Mirror around the anchor axes (left-walking hordes, etc.).
89    /// Solid canvas fills are flip-invariant; the GPU batch mirrors
90    /// geometry (UVs untouched).
91    pub flip_x: bool,
92    pub flip_y: bool,
93    pub uv_min: Vec2,
94    pub uv_max: Vec2,
95    /// RGBA tint, same byte semantics as the legacy canvas path
96    /// (`(c * 255) as u8` per channel).
97    pub color: [f32; 4],
98    /// Atlas page index for multi-texture batches.
99    pub page: u32,
100}
101
102impl Default for SpriteInstance {
103    fn default() -> Self {
104        Self {
105            center: Vec2::ZERO,
106            rotation: 0.0,
107            size: Vec2::ONE,
108            anchor: Vec2::new(0.5, 0.5),
109            flip_x: false,
110            flip_y: false,
111            uv_min: Vec2::ZERO,
112            uv_max: Vec2::ONE,
113            color: [1.0, 1.0, 1.0, 1.0],
114            page: 0,
115        }
116    }
117}
118
119/// World-anchored text floater (damage numbers, `+25`).
120#[derive(Clone, Debug, Default)]
121pub struct WorldText {
122    pub text: String,
123    /// World-space anchor, same convention as the legacy canvas text path.
124    pub pos: Vec2,
125    /// RGBA, same byte semantics as [`SpriteInstance::color`].
126    pub color: [f32; 4],
127    /// Font size in world units.
128    pub size: f32,
129}
130
131/// Everything the viewport draws this frame. Plain data, snapshot per frame.
132#[derive(Clone, Default, Debug)]
133pub struct FrameInput {
134    pub cam: Camera2d,
135    /// World-space extent the contain-fit maps into the viewport.
136    /// `Viewport2d` uses `cam.center` (rest/shake included) for the look
137    /// point; `viewport_px` / `units_per_pixel` / `zoom` are reserved for
138    /// the future wgpu backend and ignored by the canvas renderer.
139    pub world_size: [f32; 2],
140    pub viewport_px: [f32; 2],
141    pub sprites: Vec<SpriteInstance>,
142    /// World-anchored text, drawn after the sprite pass.
143    pub texts: Vec<WorldText>,
144    /// Full-viewport fill under everything (letterbox included).
145    pub background: Option<[f32; 4]>,
146    /// Optional fullscreen tint/color-grading hook (e.g. FOW dimming,
147    /// damage flash). Applied after the sprite pass.
148    pub overlay_color: Option<[f32; 4]>,
149}
150
151/// UI-facing pointer events from the viewport.
152#[derive(Clone, Debug)]
153pub enum PickEvent {
154    Click { world: Vec2, screen: [f32; 2] },
155    Hover { world: Vec2 },
156}
157
158/// Contain-fit of a `world` extent into a dp-space canvas: uniform scale
159/// plus centering offsets, all dp. Pure, so games and tests can pin it.
160pub fn contain_fit(canvas_dp: [f32; 2], world: [f32; 2]) -> (f32, f32, f32) {
161    if canvas_dp[0] <= 0.0 || canvas_dp[1] <= 0.0 || world[0] <= 0.0 || world[1] <= 0.0 {
162        return (1.0, 0.0, 0.0);
163    }
164    let s = (canvas_dp[0] / world[0]).min(canvas_dp[1] / world[1]);
165    let s = s.clamp(0.1, 8.0);
166    (
167        s,
168        (canvas_dp[0] - world[0] * s) * 0.5,
169        (canvas_dp[1] - world[1] * s) * 0.5,
170    )
171}
172
173/// World point -> dp canvas point through the fit. `cam_center` shifts the
174/// look point (trauma shake included): at the default center
175/// (`world / 2`) this is exactly `offset + world * scale`.
176pub fn world_to_dp(
177    world: [f32; 2],
178    world_size: [f32; 2],
179    cam_center: [f32; 2],
180    fit: (f32, f32, f32),
181) -> [f32; 2] {
182    let (s, ox, oy) = fit;
183    [
184        ox + (world[0] - (cam_center[0] - world_size[0] * 0.5)) * s,
185        oy + (world[1] - (cam_center[1] - world_size[1] * 0.5)) * s,
186    ]
187}
188
189/// Inverse of [`world_to_dp`]: dp canvas point -> world point.
190pub fn dp_to_world(
191    dp: [f32; 2],
192    world_size: [f32; 2],
193    cam_center: [f32; 2],
194    fit: (f32, f32, f32),
195) -> [f32; 2] {
196    let (s, ox, oy) = fit;
197    [
198        (dp[0] - ox) / s + (cam_center[0] - world_size[0] * 0.5),
199        (dp[1] - oy) / s + (cam_center[1] - world_size[1] * 0.5),
200    ]
201}
202
203/// One painted frame's geometry, shared between [`Viewport2d`] /
204/// [`Viewport2dGpu`] (writers) and world-anchored siblings like
205/// [`ActorFrame`] (readers). Everything needed to map either direction,
206/// so board sprites, picks, and actor surfaces stay glued - including
207/// under camera shake.
208#[derive(Clone, Copy, Debug)]
209pub struct FrameGeom {
210    /// Dp contain-fit of the world extent: `(scale, off_x, off_y)`.
211    pub fit: (f32, f32, f32),
212    /// Look-point shift in world units: `cam.center - world / 2`
213    /// (trauma shake included; zero when the camera is default).
214    pub look: [f32; 2],
215    /// Physical px per dp at paint time.
216    pub density: f32,
217    /// Painted viewport size in physical px (drives the GPU camera).
218    pub viewport_px: [f32; 2],
219}
220
221impl Default for FrameGeom {
222    fn default() -> Self {
223        Self {
224            fit: (1.0, 0.0, 0.0),
225            look: [0.0, 0.0],
226            density: 1.0,
227            viewport_px: [1.0, 1.0],
228        }
229    }
230}
231
232/// World-anchored surface rect in dp: `([off_x, off_y], [w, h])` for a
233/// `size` box centered on `center`. Pure; `ActorFrame` is its view form.
234pub fn surface_dp(center: [f32; 2], size: [f32; 2], geom: FrameGeom) -> ([f32; 2], [f32; 2]) {
235    let (s, ox, oy) = geom.fit;
236    (
237        [
238            ox + (center[0] - size[0] * 0.5 - geom.look[0]) * s,
239            oy + (center[1] - size[1] * 0.5 - geom.look[1]) * s,
240        ],
241        [size[0] * s, size[1] * s],
242    )
243}
244
245fn rgba8(c: [f32; 4]) -> Color {
246    Color::from_rgba(
247        (c[0].clamp(0.0, 1.0) * 255.0) as u8,
248        (c[1].clamp(0.0, 1.0) * 255.0) as u8,
249        (c[2].clamp(0.0, 1.0) * 255.0) as u8,
250        (c[3].clamp(0.0, 1.0) * 255.0) as u8,
251    )
252}
253
254/// 2D viewport view. Owns nothing render-side; gesture handling and camera
255/// state live in the [`FrameInput`] snapshot (same split as resims
256/// `Viewport3d`).
257///
258/// Layout contract: fills its parent. Draws `background`, then sprites,
259/// then world texts, then the fullscreen tint - all through the dp
260/// contain-fit of [`FrameInput::world_size`]. Pointer presses are reported
261/// via `on_event` in world coords. Each paint publishes a [`FrameGeom`]
262/// snapshot to `geom_out` for dp-space siblings ([`ActorFrame`]); the
263/// viewport, picks, and actor surfaces therefore share one transform by
264/// construction.
265#[allow(non_snake_case)] // Repose view convention (cf. resims `Viewport3d`).
266pub fn Viewport2d(
267    input: FrameInput,
268    geom_out: Rc<Cell<FrameGeom>>,
269    on_event: impl Fn(PickEvent) + 'static,
270) -> View {
271    let input = Rc::new(input);
272    let world_size = input.world_size;
273    let cam_center = [input.cam.center.x, input.cam.center.y];
274    let pick_geom = geom_out.clone();
275    let draw_input = input.clone();
276    let draw_geom = geom_out.clone();
277
278    let modifier = Modifier::new().fill_max_size().on_pointer_down(
279        move |ev: repose_core::input::PointerEvent| {
280            // Region-local px -> window px (robust to a non-zero viewport
281            // origin) -> dp -> world through the painted frame geometry.
282            let w = ev.position_in_window();
283            let g = pick_geom.get();
284            let world = dp_to_world(
285                [w.x / g.density, w.y / g.density],
286                world_size,
287                [
288                    g.look[0] + world_size[0] * 0.5,
289                    g.look[1] + world_size[1] * 0.5,
290                ],
291                g.fit,
292            );
293            on_event(PickEvent::Click {
294                world: Vec2::new(world[0], world[1]),
295                screen: [w.x, w.y],
296            });
297        },
298    );
299    Canvas(modifier, move |scope: &mut DrawScope| {
300        // DrawScope size is physical px (layout/paint run in px); the fit
301        // is dp (1 world unit == 1 dp). Convert down, publish geometry,
302        // and scale back up once for drawing.
303        let d = effective_density_scale();
304        let fit = contain_fit(
305            [scope.size.width / d, scope.size.height / d],
306            draw_input.world_size,
307        );
308        draw_geom.set(FrameGeom {
309            fit,
310            look: [
311                cam_center[0] - draw_input.world_size[0] * 0.5,
312                cam_center[1] - draw_input.world_size[1] * 0.5,
313            ],
314            density: d,
315            viewport_px: [scope.size.width, scope.size.height],
316        });
317        let s = fit.0 * d;
318        let project = |wx: f32, wy: f32| -> [f32; 2] {
319            let [dx, dy] = world_to_dp(
320                [wx, wy],
321                draw_input.world_size,
322                cam_center,
323                (fit.0, fit.1, fit.2),
324            );
325            [dx * d, dy * d]
326        };
327        if let Some(bg) = draw_input.background {
328            scope.draw_rect(
329                Rect {
330                    x: 0.0,
331                    y: 0.0,
332                    w: scope.size.width,
333                    h: scope.size.height,
334                },
335                rgba8(bg),
336                0.0,
337            );
338        }
339        for spr in draw_input.sprites.iter() {
340            // Anchor-aware top-left (default anchor keeps legacy centering);
341            // solid fills are flip-invariant, so flip lives in the batch.
342            let [tx, ty] = project(
343                spr.center.x - spr.anchor.x * spr.size.x,
344                spr.center.y - spr.anchor.y * spr.size.y,
345            );
346            scope.draw_rect(
347                Rect {
348                    x: tx,
349                    y: ty,
350                    w: spr.size.x * s,
351                    h: spr.size.y * s,
352                },
353                rgba8(spr.color),
354                0.0,
355            );
356        }
357        for t in draw_input.texts.iter() {
358            let [tx, ty] = project(t.pos.x, t.pos.y);
359            scope.draw_text(
360                t.text.clone(),
361                repose_core::Vec2 { x: tx, y: ty },
362                rgba8(t.color),
363                t.size * s,
364            );
365        }
366        if let Some(tint) = draw_input.overlay_color {
367            scope.draw_rect(
368                Rect {
369                    x: 0.0,
370                    y: 0.0,
371                    w: scope.size.width,
372                    h: scope.size.height,
373                },
374                rgba8(tint),
375                0.0,
376            );
377        }
378    })
379}
380
381/// GPU twin of [`Viewport2d`]: same snapshot in, same picks out, but
382/// sprites draw as textured atlas quads through [`SpriteBatch`].
383///
384/// Layout contract: fills its parent. The payload is rebuilt from the
385/// snapshot every frame (like the canvas draw closure): `prepare`
386/// rebuilds the batch, and camera from the last painted viewport (cold
387/// start falls back to [`FrameInput::viewport_px`]), and `paint`
388/// records the fresh viewport into `geom_out` and issues the shared
389/// draw calls. Atlas uploads ride along per frame; games drain their
390/// atlas queue once per frame, so each upload applies exactly once.
391/// Geometry crosses the render thread behind a mutex (`WgpuCallback`
392/// payloads must be `Send + Sync`), picks read it back on the UI side.
393/// Background, world texts, and tint stay canvas-view features for now:
394/// GPU consumers compose them as sibling views.
395#[allow(non_snake_case)]
396pub fn Viewport2dGpu(
397    input: FrameInput,
398    geom_out: Arc<Mutex<FrameGeom>>,
399    uploads: Vec<AtlasUpload>,
400    desc: BatchDesc,
401    on_event: impl Fn(PickEvent) + 'static,
402) -> View {
403    let input = Arc::new(input);
404    let world_size = input.world_size;
405    let pick_geom = geom_out.clone();
406    let payload = GpuViewport {
407        input: input.clone(),
408        geom: geom_out,
409        uploads,
410        desc,
411    };
412    let modifier = Modifier::new().fill_max_size().on_pointer_down(
413        move |ev: repose_core::input::PointerEvent| {
414            let w = ev.position_in_window();
415            let Ok(g) = pick_geom.lock() else {
416                return;
417            };
418            let world = dp_to_world(
419                [w.x / g.density, w.y / g.density],
420                world_size,
421                [
422                    g.look[0] + world_size[0] * 0.5,
423                    g.look[1] + world_size[1] * 0.5,
424                ],
425                g.fit,
426            );
427            on_event(PickEvent::Click {
428                world: Vec2::new(world[0], world[1]),
429                screen: [w.x, w.y],
430            });
431        },
432    );
433    Embedded(modifier, Callback::new(payload))
434}
435
436struct GpuViewport {
437    input: Arc<FrameInput>,
438    geom: Arc<Mutex<FrameGeom>>,
439    uploads: Vec<AtlasUpload>,
440    desc: BatchDesc,
441}
442
443impl WgpuCallback for GpuViewport {
444    fn prepare(
445        &self,
446        device: &wgpu::Device,
447        queue: &wgpu::Queue,
448        encoder: &mut wgpu::CommandEncoder,
449        screen: &ScreenDescriptor,
450        resources: &mut CallbackResources,
451    ) -> Vec<wgpu::CommandBuffer> {
452        let vp = self
453            .geom
454            .lock()
455            .map(|g| g.viewport_px)
456            .unwrap_or([1.0, 1.0]);
457        let vp = if vp[0] > 1.0 && vp[1] > 1.0 {
458            vp
459        } else {
460            self.input.viewport_px
461        };
462        let mut batch = SpriteBatch::new(self.desc);
463        batch.set_camera(screen_camera(vp));
464        for s in &self.input.sprites {
465            batch.push_sprite(s);
466        }
467        batch.extend_uploads(self.uploads.clone());
468        batch.prepare(device, queue, encoder, screen, resources)
469    }
470
471    fn paint(
472        &self,
473        info: repose_core::PaintCallbackInfo,
474        rpass: &mut wgpu::RenderPass<'static>,
475        resources: &CallbackResources,
476    ) {
477        // Fresh viewport geometry for picks + next frame's camera.
478        let d = info.pixels_per_point.max(0.0001);
479        let vp = [info.viewport.w, info.viewport.h];
480        let fit = contain_fit([vp[0] / d, vp[1] / d], self.input.world_size);
481        let cam = [self.input.cam.center.x, self.input.cam.center.y];
482        if let Ok(mut g) = self.geom.lock() {
483            *g = FrameGeom {
484                fit,
485                look: [
486                    cam[0] - self.input.world_size[0] * 0.5,
487                    cam[1] - self.input.world_size[1] * 0.5,
488                ],
489                density: d,
490                viewport_px: vp,
491            };
492        }
493        draw_batch(rpass, resources);
494    }
495}
496
497/// A world-anchored surface: `child` (painting in its own local coords)/// is boxed to a `size` rect centered on a world `center`, positioned
498/// through the viewport's [`FrameGeom`]. Siblings of [`Viewport2d`] -
499/// e.g. live actor surfaces - track sim positions without game-side unit
500/// math, glued to sprites and picks even under camera shake. `mirror_x`
501/// flips around the surface center (left-walking hordes reusing
502/// right-facing art).
503#[allow(non_snake_case)]
504pub fn ActorFrame(
505    center: [f32; 2],
506    size: [f32; 2],
507    geom: Rc<Cell<FrameGeom>>,
508    mirror_x: bool,
509    child: View,
510) -> View {
511    let ([ox, oy], [w, h]) = surface_dp(center, size, geom.get());
512    // `.absolute()` is load-bearing: without it taffy ignores the offsets
513    // and every surface stacks at the same fixed spot.
514    let mut modifier = Modifier::new()
515        .size(w, h)
516        .absolute()
517        .offset(Some(ox), Some(oy), None, None);
518    if mirror_x {
519        modifier = modifier.scale2(-1.0, 1.0);
520    }
521    UiBox(modifier).child(child)
522}
523
524#[cfg(test)]
525mod tests {
526    use super::*;
527    use repose_core::locals::{Density, set_density_default};
528
529    #[test]
530    fn contain_fit_roundtrip_at_density_125() {
531        // Production eDP-1 runs at 1.25: px canvas -> dp fit -> world ->
532        // back must be identity or clicks miss their visuals.
533        set_density_default(Density { scale: 1.25 });
534        let d = effective_density_scale();
535        // 1250x750 px canvas, 800x600 world: dp fit s=1, ox=100, oy=0.
536        let fit = contain_fit([1250.0 / d, 750.0 / d], [800.0, 600.0]);
537        let cam = [400.0, 300.0];
538        // Draw: world (400,300) -> dp -> px must hit the press point.
539        let [dx, dy] = world_to_dp([400.0, 300.0], [800.0, 600.0], cam, fit);
540        // Pick: window px -> dp -> world must map back exactly.
541        let [wx, wy] = dp_to_world([625.0 / d, 375.0 / d], [800.0, 600.0], cam, fit);
542        set_density_default(Density { scale: 1.0 });
543        assert!(
544            (fit.0 - 1.0).abs() < 1e-4 && (fit.1 - 100.0).abs() < 1e-4 && fit.2.abs() < 1e-4,
545            "dp fit, got {fit:?}"
546        );
547        assert!(
548            (dx * d - 625.0).abs() < 1e-3 && (dy * d - 375.0).abs() < 1e-3,
549            "draw forward, got ({dx},{dy})"
550        );
551        assert!(
552            (wx - 400.0).abs() < 1e-3 && (wy - 300.0).abs() < 1e-3,
553            "pick inverse, got ({wx},{wy})"
554        );
555        // Camera shift (trauma shake) moves draw and pick together.
556        let shifted = world_to_dp([400.0, 300.0], [800.0, 600.0], [410.0, 290.0], fit);
557        let back = dp_to_world(shifted, [800.0, 600.0], [410.0, 290.0], fit);
558        assert!(
559            (back[0] - 400.0).abs() < 1e-4 && (back[1] - 300.0).abs() < 1e-4,
560            "shifted round trip, got {back:?}"
561        );
562    }
563
564    #[test]
565    fn surface_dp_matches_board_point_under_shake() {
566        // Actor surfaces and board sprites share FrameGeom: the surface
567        // center must land exactly on the board-space point, including
568        // with a shifted look point (trauma shake).
569        let geom = FrameGeom {
570            fit: (1.0, 100.0, 0.0),
571            look: [0.0, 0.0],
572            density: 1.25,
573            viewport_px: [1250.0, 750.0],
574        };
575        let ([ox, oy], [w, h]) = surface_dp([400.0, 300.0], [64.0, 80.0], geom);
576        assert!(
577            (ox - 468.0).abs() < 1e-4 && (oy - 260.0).abs() < 1e-4,
578            "offset, got ({ox},{oy})"
579        );
580        assert!(
581            (w - 64.0).abs() < 1e-4 && (h - 80.0).abs() < 1e-4,
582            "size, got ({w},{h})"
583        );
584        // Surface center == board point for the same world pos.
585        let board = world_to_dp([400.0, 300.0], [800.0, 600.0], [400.0, 300.0], geom.fit);
586        assert!(
587            (ox + w * 0.5 - board[0]).abs() < 1e-4 && (oy + h * 0.5 - board[1]).abs() < 1e-4,
588            "surface glued to board point"
589        );
590        let shaken = FrameGeom {
591            look: [10.0, -10.0],
592            ..geom
593        };
594        let ([sx, sy], _) = surface_dp([400.0, 300.0], [64.0, 80.0], shaken);
595        let sboard = world_to_dp([400.0, 300.0], [800.0, 600.0], [410.0, 290.0], shaken.fit);
596        assert!(
597            (sx + w * 0.5 - sboard[0]).abs() < 1e-4 && (sy + h * 0.5 - sboard[1]).abs() < 1e-4,
598            "surface glued under shake"
599        );
600    }
601}