Skip to main content

repame_sprite/
lib.rs

1//! Instanced 2D sprite batch: atlas quads in, pixels out.
2//!
3//! The batch draws [`SpriteInstance`] rows from an atlas through a dp-space
4//! [`FitMode`] camera. Placement, z-order and picks live in `repame-actors`;
5//! this crate owns the GPU batch, the atlas types, the camera math and the
6//! pick event types. Unit discipline lives here, once: games work in
7//! world/dp units and do not touch physical px.
8
9use glam::{Mat4, Vec2, Vec3};
10use repose_core::Color;
11
12pub mod batch;
13pub use batch::{
14    AtlasUpload, BatchDesc, BatchFrame, SpriteBatch, TextureFilter, draw_batch, draw_batch_with_id,
15    frame_uv, instance_rows, screen_camera, sprite_aabb,
16};
17
18pub mod fullscreen;
19pub use fullscreen::{
20    FullscreenDesc, FullscreenPass, FullscreenTexture, FullscreenTextureRef,
21    FullscreenTextureUpload,
22};
23
24/// How the world rect is framed when the canvas aspect differs from
25/// `world_size`. Named like Godot's `Window.stretch/aspect.
26#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
27pub enum FitMode {
28    #[default]
29    Keep,
30    /// Same scale, world rect anchored at the canvas
31    /// origin, so the roomier axis reveals world past `world_size` and
32    /// the roomier side of the canvas is not wasted on bars.
33    Expand,
34}
35
36/// 2D orthographic camera. Owned by the UI (signals), copied into the
37/// snapshot per frame. `effective_center` is `center + offset`.
38/// Canvas, GPU batch, and picks derive from these fields together.
39#[derive(Clone, Copy, Debug)]
40pub struct Camera2d {
41    /// Follow target in world units. Defaults to `(0, 0)`.
42    /// Scroll limits clamp this field; shake goes in `offset`.
43    pub center: Vec2,
44    /// Momentary displacement added to `center`. Defaults to `(0, 0)`.
45    /// Applied after scroll limits; game code decays it toward zero.
46    pub offset: Vec2,
47    /// World units per screen pixel at zoom 1. Defaults to `1.0`.
48    /// Visible width is `viewport_dp * units_per_pixel / zoom`.
49    /// Non-positive values fall back to `1.0` (guards division).
50    pub units_per_pixel: f32,
51    /// Magnification. Defaults to `1.0`; higher zooms in.
52    /// Non-positive values fall back to `1.0`.
53    pub zoom: f32,
54    /// Roll in radians about the look point. Defaults to `0.0`.
55    /// Applied on canvas, GPU, picks, and actor surfaces alike.
56    pub roll: f32,
57    /// Aspect-fit policy when the canvas and `world_size` disagree.
58    /// Defaults to [`FitMode::Keep`].
59    pub fit: FitMode,
60}
61
62impl Default for Camera2d {
63    fn default() -> Self {
64        Self {
65            center: Vec2::ZERO,
66            offset: Vec2::ZERO,
67            units_per_pixel: 1.0,
68            zoom: 1.0,
69            roll: 0.0,
70            fit: FitMode::Keep,
71        }
72    }
73}
74
75/// Scroll limits and follow smoothing live in `game_utils_repame::feel`.
76/// They run game-side when producing the snapshot; this crate only
77impl Camera2d {
78    /// Effective look point: `center + offset`.
79    /// Canvas, GPU batch, picks, and actor surfaces all use this value.
80    pub fn effective_center(&self) -> [f32; 2] {
81        [self.center.x + self.offset.x, self.center.y + self.offset.y]
82    }
83
84    /// Framing matrix used by GPU + picks. Prefer this over raw ortho.
85    /// Includes [`roll`](Camera2d::roll) around the look point.
86    pub fn fit_matrix(&self, canvas_dp: [f32; 2], world_size: [f32; 2]) -> Mat4 {
87        let fit = effective_fit(canvas_dp, world_size, self);
88        fit_view_proj_with_roll(
89            canvas_dp,
90            world_size,
91            self.effective_center(),
92            fit,
93            self.roll,
94        )
95    }
96
97    /// World point under a dp-space cursor (density divided out).
98    /// Includes roll.
99    pub fn dp_to_world_pt(&self, canvas_dp: [f32; 2], world_size: [f32; 2], dp: [f32; 2]) -> Vec2 {
100        let fit = effective_fit(canvas_dp, world_size, self);
101        let w = dp_to_world_with_roll(dp, world_size, self.effective_center(), fit, self.roll);
102        Vec2::new(w[0], w[1])
103    }
104
105    /// Raw ortho ignoring contain-fit letterbox. Kept for HUD overlays in
106    /// screen space; viewport code must use [`fit_matrix`](Camera2d::fit_matrix).
107    pub fn view_proj_screen(&self, viewport_px: [f32; 2]) -> Mat4 {
108        let vp_w = viewport_px[0].max(1.0);
109        let vp_h = viewport_px[1].max(1.0);
110        let zoom = sane_positive(self.zoom);
111        let upp = sane_positive(self.units_per_pixel);
112        let w = vp_w * upp / zoom;
113        let h = vp_h * upp / zoom;
114        let proj = glam::camera::rh::proj::directx::orthographic(
115            -w / 2.0,
116            w / 2.0,
117            h / 2.0,
118            -h / 2.0,
119            -1000.0,
120            1000.0,
121        );
122        let view = Mat4::from_translation(Vec3::new(
123            -(self.center.x + self.offset.x),
124            -(self.center.y + self.offset.y),
125            0.0,
126        ));
127        proj * view
128    }
129
130    /// Legacy y-down view-projection: world +y points screen-down.
131    /// Frames from `viewport * units_per_pixel / zoom` and ignores
132    /// `world_size`, so it disagrees with canvas, GPU, and picks
133    /// under letterbox. Viewport code must use [`fit_view_proj`].
134    #[deprecated(
135        since = "0.1.8",
136        note = "ignores world_size; use fit_view_proj for the shared canvas/GPU/pick framing contract"
137    )]
138    pub fn view_proj(&self, viewport_px: [f32; 2]) -> Mat4 {
139        self.view_proj_screen(viewport_px)
140    }
141
142    /// Legacy position under a viewport-pixel cursor.
143    /// Inverts `view_proj` with the same letterbox divergence.
144    /// Picks must use [`pick_world`] over the painted [`FrameGeom`].
145    #[deprecated(
146        since = "0.1.8",
147        note = "ignores world_size; use pick_world over the painted FrameGeom instead"
148    )]
149    #[allow(deprecated)]
150    pub fn screen_to_world(&self, viewport_px: [f32; 2], px: [f32; 2]) -> Vec2 {
151        if viewport_px[0] <= 0.0 || viewport_px[1] <= 0.0 {
152            let [cx, cy] = self.effective_center();
153            return Vec2::new(cx, cy);
154        }
155        let ndc = Vec2::new(
156            (px[0] / viewport_px[0]) * 2.0 - 1.0,
157            1.0 - (px[1] / viewport_px[1]) * 2.0,
158        );
159        let inv = self.view_proj(viewport_px).inverse();
160        let world = inv.project_point3(ndc.extend(0.0));
161        Vec2::new(world.x, world.y)
162    }
163}
164
165/// Blend mode per sprite. Additive uses `SrcAlpha + One`.
166/// Sorted into ranges (alpha, then multiply, then additive): push order
167/// across blend modes is NOT preserved, see the batch module docs.
168#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
169pub enum SpriteBlend {
170    #[default]
171    Alpha,
172    Additive,
173    Multiply,
174}
175
176/// One batched sprite. `uv` is in atlas texels normalized to 0..1.
177#[derive(Clone, Copy, Debug)]
178pub struct SpriteInstance {
179    /// World-space center, rotation radians, size in world units.
180    pub center: Vec2,
181    pub rotation: f32,
182    pub size: Vec2,
183    /// Normalized anchor: `[0.5, 0.5]` centers the quad on `center`,
184    /// `[0, 0]` pins the top-left corner. Y-down.
185    pub anchor: Vec2,
186    /// Mirror about the anchor axes. Canvas fills are flip-invariant;
187    /// the GPU batch mirrors geometry (UVs untouched).
188    pub flip_x: bool,
189    pub flip_y: bool,
190    pub uv_min: Vec2,
191    pub uv_max: Vec2,
192    /// RGBA tint, `(c * 255) as u8` per channel.
193    pub color: [f32; 4],
194    /// Atlas page index for multi-texture batches.
195    pub page: u32,
196    /// Draw key within one blend range; higher draws on top. Default 0.
197    /// Stable-sorted by `z` inside the range, but blend ranges draw
198    /// alpha-then-multiply-then-additive regardless of `z` (see
199    /// [`SpriteBlend`]).
200    pub z: f32,
201    pub blend: SpriteBlend,
202}
203
204impl Default for SpriteInstance {
205    fn default() -> Self {
206        Self {
207            center: Vec2::ZERO,
208            rotation: 0.0,
209            size: Vec2::ONE,
210            anchor: Vec2::new(0.5, 0.5),
211            flip_x: false,
212            flip_y: false,
213            uv_min: Vec2::ZERO,
214            uv_max: Vec2::ONE,
215            color: [1.0, 1.0, 1.0, 1.0],
216            page: 0,
217            z: 0.0,
218            blend: SpriteBlend::Alpha,
219        }
220    }
221}
222
223/// World-anchored text floater (damage numbers, `+25`).
224#[derive(Clone, Debug, Default)]
225pub struct WorldText {
226    pub text: String,
227    /// World-space anchor.
228    pub pos: Vec2,
229    /// RGBA, `(c * 255) as u8` per channel.
230    pub color: [f32; 4],
231    /// Font size in world units.
232    pub size: f32,
233    /// Font family name to resolve, or `None` (default) for the stack the
234    /// text engine picks itself. The name must already be known to the
235    /// engine; an unresolved name falls back to the default stack.
236    pub font_family: Option<&'static str>,
237}
238
239pub enum PickEvent {
240    Press {
241        world: Vec2,
242        screen: [f32; 2],
243        button: repose_core::input::PointerButton,
244    },
245    Click {
246        world: Vec2,
247        screen: [f32; 2],
248        button: repose_core::input::PointerButton,
249    },
250    /// Pointer released, always. [`PickEvent::Click`] is gated behind
251    /// [`click_within_slop_dp`], so a drag that ends far from where it started
252    /// emits no click at all; a game that drags something has no other way to
253    /// learn that the drag ended. Emitted before [`PickEvent::Click`], and
254    /// whether or not a click follows.
255    Release {
256        world: Vec2,
257        screen: [f32; 2],
258        button: repose_core::input::PointerButton,
259    },
260    Hover {
261        world: Vec2,
262        /// Window-physical px (y-down), same space as `Press.screen`.
263        /// Games stage this raw and unproject through the live camera
264        /// each frame (screen-anchored aim); the `world` point is baked
265        /// through the camera at event time and goes stale as the
266        /// camera moves.
267        screen: [f32; 2],
268    },
269    /// Touch/pen contact began: pointer id + window-physical px.
270    /// Mouse does not emit these; taps still emit `Click` too.
271    TouchDown { id: u64, screen: [f32; 2] },
272    /// Touch/pen contact moved (same coordinate space as `TouchDown`).
273    TouchMove { id: u64, screen: [f32; 2] },
274    /// Touch/pen contact ended (up / leave).
275    TouchUp { id: u64 },
276}
277
278/// Clamp helper: finite positive values pass through, the rest fall
279/// back to 1.0 so framing math holds a valid divisor.
280fn sane_positive(v: f32) -> f32 {
281    if v.is_finite() && v > 1e-6 { v } else { 1.0 }
282}
283
284/// Contain-fit of a `world` extent into a dp-space canvas: uniform
285/// scale plus centering offsets. `world_to_dp` / `dp_to_world` invert
286/// each other for any positive scale.
287pub fn contain_fit(canvas_dp: [f32; 2], world: [f32; 2]) -> (f32, f32, f32) {
288    if canvas_dp[0] <= 0.0 || canvas_dp[1] <= 0.0 || world[0] <= 0.0 || world[1] <= 0.0 {
289        return (1.0, 0.0, 0.0);
290    }
291    let s = (canvas_dp[0] / world[0]).min(canvas_dp[1] / world[1]);
292    if !s.is_finite() || s <= 1e-6 {
293        return (1.0, 0.0, 0.0);
294    }
295    (
296        s,
297        (canvas_dp[0] - world[0] * s) * 0.5,
298        (canvas_dp[1] - world[1] * s) * 0.5,
299    )
300}
301
302/// Effective fit for one frame: base contain-fit scaled by
303/// `zoom / units_per_pixel`, re-centered. Default camera matches
304/// [`contain_fit`]; zoom 2 doubles on-screen size on both backends.
305///
306/// The scale is [`FitMode`]-independent — both modes scale uniformly, so
307/// neither can crop the world rect. Only the offsets differ: `Keep`
308/// centers the world box (bars on the roomier axis), `Expand` anchors it
309/// at the origin so the roomier axis shows world past `world_size`.
310pub fn effective_fit(canvas_dp: [f32; 2], world: [f32; 2], cam: &Camera2d) -> (f32, f32, f32) {
311    let (base, _, _) = contain_fit(canvas_dp, world);
312    let zoom = sane_positive(cam.zoom);
313    let upp = sane_positive(cam.units_per_pixel);
314    let s = base * zoom / upp;
315    if !s.is_finite() || s <= 1e-6 {
316        return (1.0, 0.0, 0.0);
317    }
318    let (ox, oy) = match cam.fit {
319        FitMode::Keep => (
320            (canvas_dp[0] - world[0] * s) * 0.5,
321            (canvas_dp[1] - world[1] * s) * 0.5,
322        ),
323        FitMode::Expand => (0.0, 0.0),
324    };
325    (s, ox, oy)
326}
327
328/// GPU camera for the shared framing contract: matrix form of
329/// [`world_to_dp`] over `canvas_dp` (dp = physical px / density).
330pub fn fit_view_proj(
331    canvas_dp: [f32; 2],
332    world_size: [f32; 2],
333    cam_center: [f32; 2],
334    fit: (f32, f32, f32),
335) -> Mat4 {
336    fit_view_proj_with_roll(canvas_dp, world_size, cam_center, fit, 0.0)
337}
338
339/// [`fit_view_proj`] plus camera roll about the look point.
340/// `0.0` matches [`fit_view_proj`].
341pub fn fit_view_proj_with_roll(
342    canvas_dp: [f32; 2],
343    world_size: [f32; 2],
344    cam_center: [f32; 2],
345    fit: (f32, f32, f32),
346    roll: f32,
347) -> Mat4 {
348    let w = canvas_dp[0].max(1.0);
349    let h = canvas_dp[1].max(1.0);
350    let (s, ox, oy) = fit;
351    let s = if s.is_finite() && s > 1e-6 { s } else { 1.0 };
352    let lx = cam_center[0] - world_size[0] * 0.5;
353    let ly = cam_center[1] - world_size[1] * 0.5;
354    let sx = 2.0 * s / w;
355    let sy = -2.0 * s / h;
356    let tx = 2.0 * (ox - lx * s) / w - 1.0;
357    let ty = 1.0 - 2.0 * (oy - ly * s) / h;
358    let base = Mat4::from_cols(
359        glam::Vec4::new(sx, 0.0, 0.0, 0.0),
360        glam::Vec4::new(0.0, sy, 0.0, 0.0),
361        glam::Vec4::new(0.0, 0.0, 0.001, 0.0),
362        glam::Vec4::new(tx, ty, 0.0, 1.0),
363    );
364    if !roll.is_finite() || roll.abs() < 1e-7 {
365        return base;
366    }
367    let (c, r) = (roll.cos(), roll.sin());
368    let rot = Mat4::from_cols(
369        glam::Vec4::new(c, r, 0.0, 0.0),
370        glam::Vec4::new(-r, c, 0.0, 0.0),
371        glam::Vec4::new(0.0, 0.0, 1.0, 0.0),
372        glam::Vec4::new(
373            cam_center[0] - c * cam_center[0] + r * cam_center[1],
374            cam_center[1] - r * cam_center[0] - c * cam_center[1],
375            0.0,
376            1.0,
377        ),
378    );
379    base * rot
380}
381
382/// World point to dp canvas point through the fit. At the default
383/// center (`world / 2`) this is `offset + world * scale`.
384pub fn world_to_dp(
385    world: [f32; 2],
386    world_size: [f32; 2],
387    cam_center: [f32; 2],
388    fit: (f32, f32, f32),
389) -> [f32; 2] {
390    world_to_dp_with_roll(world, world_size, cam_center, fit, 0.0)
391}
392
393/// [`world_to_dp`] plus camera roll around `cam_center`.
394pub fn world_to_dp_with_roll(
395    world: [f32; 2],
396    world_size: [f32; 2],
397    cam_center: [f32; 2],
398    fit: (f32, f32, f32),
399    roll: f32,
400) -> [f32; 2] {
401    let (s, ox, oy) = fit;
402    let [wx, wy] = rotate_about(world, cam_center, roll);
403    [
404        ox + (wx - (cam_center[0] - world_size[0] * 0.5)) * s,
405        oy + (wy - (cam_center[1] - world_size[1] * 0.5)) * s,
406    ]
407}
408
409/// Inverse of [`world_to_dp`]. Degenerate scales return the look point.
410pub fn dp_to_world(
411    dp: [f32; 2],
412    world_size: [f32; 2],
413    cam_center: [f32; 2],
414    fit: (f32, f32, f32),
415) -> [f32; 2] {
416    dp_to_world_with_roll(dp, world_size, cam_center, fit, 0.0)
417}
418
419/// Inverse of [`world_to_dp_with_roll`].
420pub fn dp_to_world_with_roll(
421    dp: [f32; 2],
422    world_size: [f32; 2],
423    cam_center: [f32; 2],
424    fit: (f32, f32, f32),
425    roll: f32,
426) -> [f32; 2] {
427    let (s, ox, oy) = fit;
428    if !s.is_finite() || s.abs() < 1e-6 {
429        return cam_center;
430    }
431    let base = [
432        (dp[0] - ox) / s + (cam_center[0] - world_size[0] * 0.5),
433        (dp[1] - oy) / s + (cam_center[1] - world_size[1] * 0.5),
434    ];
435    rotate_about(base, cam_center, -roll)
436}
437
438fn rotate_about(p: [f32; 2], center: [f32; 2], roll: f32) -> [f32; 2] {
439    if !roll.is_finite() || roll.abs() < 1e-7 {
440        return p;
441    }
442    let (c, r) = (roll.cos(), roll.sin());
443    let dx = p[0] - center[0];
444    let dy = p[1] - center[1];
445    [center[0] + c * dx - r * dy, center[1] + r * dx + c * dy]
446}
447
448/// Press slop in dp: pointer-up farther than this from pointer-down
449/// cancels the `Click` (drag-off-cancel). Density-independent: compared
450/// after dividing physical-px deltas by the painted density, so a tap
451/// cancels identically on density 1 and density 3.
452pub const CLICK_SLOP_DP: f32 = 12.0;
453
454/// Dp-space slop check: physical-px points divided by `density` (falling
455/// back to 1.0) before comparing against [`CLICK_SLOP_DP`].
456pub fn click_within_slop_dp(a: [f32; 2], b: [f32; 2], density: f32) -> bool {
457    let d = if density.is_finite() && density > 1e-6 {
458        density
459    } else {
460        1.0
461    };
462    let dx = (a[0] - b[0]) / d;
463    let dy = (a[1] - b[1]) / d;
464    dx * dx + dy * dy <= CLICK_SLOP_DP * CLICK_SLOP_DP
465}
466
467pub fn rgba8(c: [f32; 4]) -> Color {
468    Color::from_rgba(
469        (c[0].clamp(0.0, 1.0) * 255.0) as u8,
470        (c[1].clamp(0.0, 1.0) * 255.0) as u8,
471        (c[2].clamp(0.0, 1.0) * 255.0) as u8,
472        (c[3].clamp(0.0, 1.0) * 255.0) as u8,
473    )
474}
475
476#[cfg(test)]
477mod tests {
478    use super::*;
479    use repose_core::locals::{Density, effective_density_scale, set_density_default};
480
481    #[test]
482    fn contain_fit_roundtrip_at_density_125() {
483        // 1.25 density: px canvas -> dp fit -> world -> back is identity.
484        set_density_default(Density { scale: 1.25 });
485        let d = effective_density_scale();
486        // 1250x750 px canvas, 800x600 world: dp fit s=1, ox=100, oy=0.
487        let fit = contain_fit([1250.0 / d, 750.0 / d], [800.0, 600.0]);
488        let cam = [400.0, 300.0];
489        // Draw: world (400,300) -> dp -> px lands on the press point.
490        let [dx, dy] = world_to_dp([400.0, 300.0], [800.0, 600.0], cam, fit);
491        // Pick: window px -> dp -> world maps back.
492        let [wx, wy] = dp_to_world([625.0 / d, 375.0 / d], [800.0, 600.0], cam, fit);
493        set_density_default(Density { scale: 1.0 });
494        assert!(
495            (fit.0 - 1.0).abs() < 1e-4 && (fit.1 - 100.0).abs() < 1e-4 && fit.2.abs() < 1e-4,
496            "dp fit, got {fit:?}"
497        );
498        assert!(
499            (dx * d - 625.0).abs() < 1e-3 && (dy * d - 375.0).abs() < 1e-3,
500            "draw forward, got ({dx},{dy})"
501        );
502        assert!(
503            (wx - 400.0).abs() < 1e-3 && (wy - 300.0).abs() < 1e-3,
504            "pick inverse, got ({wx},{wy})"
505        );
506        // Camera shift moves draw and pick together.
507        let shifted = world_to_dp([400.0, 300.0], [800.0, 600.0], [410.0, 290.0], fit);
508        let back = dp_to_world(shifted, [800.0, 600.0], [410.0, 290.0], fit);
509        assert!(
510            (back[0] - 400.0).abs() < 1e-4 && (back[1] - 300.0).abs() < 1e-4,
511            "shifted round trip, got {back:?}"
512        );
513    }
514
515    #[test]
516    fn single_layer_atlas_draws_tinted_sprites() {
517        // A one-layer atlas must be a plain D2 texture, not a one-element
518        // D2Array: GL/ANGLE assumes D2 for it and then samples the array view
519        // as black, which silently erases every sprite with no validation
520        // error. Guards the shape choice for the layout, the view and the
521        // shader together, since they only agree as a set.
522        assert_eq!(
523            crate::batch::atlas_shape(1),
524            (wgpu::TextureViewDimension::D2, false)
525        );
526        assert_eq!(
527            crate::batch::atlas_shape(4),
528            (wgpu::TextureViewDimension::D2Array, true)
529        );
530        assert!(
531            crate::batch::sprite_shader(1).contains("texture_2d<")
532                && !crate::batch::sprite_shader(1).contains("texture_2d_array"),
533            "single-layer shader samples a 2d atlas"
534        );
535        assert!(
536            crate::batch::sprite_shader(4).contains("texture_2d_array"),
537            "multi-layer shader samples an array atlas"
538        );
539    }
540
541    #[test]
542    fn contain_fit_has_no_silent_clamp() {
543        for (canvas, world) in [
544            ([8000.0, 6000.0], [80.0, 60.0]),
545            ([80.0, 60.0], [8000.0, 6000.0]),
546            ([1000.0, 750.0], [800.0, 600.0]),
547        ] {
548            let fit = contain_fit(canvas, world);
549            let cam = [world[0] * 0.5, world[1] * 0.5];
550            let dp = world_to_dp([cam[0], cam[1]], world, cam, fit);
551            let back = dp_to_world(dp, world, cam, fit);
552            assert!(
553                (back[0] - cam[0]).abs() < 1e-3 && (back[1] - cam[1]).abs() < 1e-3,
554                "round trip for {canvas:?}/{world:?}, fit {fit:?} gave {back:?}"
555            );
556        }
557        assert_eq!(contain_fit([0.0, 600.0], [800.0, 600.0]), (1.0, 0.0, 0.0));
558        assert_eq!(
559            dp_to_world(
560                [10.0, 10.0],
561                [800.0, 600.0],
562                [400.0, 300.0],
563                (0.0, 0.0, 0.0)
564            ),
565            [400.0, 300.0]
566        );
567    }
568
569    /// `Keep` is the pre-`FitMode` framing: centered world box, bars on
570    /// the roomier axis, exactly `world` visible.
571    #[test]
572    fn fit_mode_keep_is_contain_fit() {
573        for (canvas, world) in [
574            ([1600.0, 900.0], [800.0, 800.0]),
575            ([900.0, 1600.0], [800.0, 800.0]),
576            ([1000.0, 1000.0], [800.0, 600.0]),
577        ] {
578            let cam = Camera2d {
579                center: Vec2::new(world[0] * 0.5, world[1] * 0.5),
580                fit: FitMode::Keep,
581                ..Default::default()
582            };
583            let fit = effective_fit(canvas, world, &cam);
584            assert_eq!(fit, contain_fit(canvas, world), "{canvas:?}/{world:?}");
585            let (s, ox, oy) = fit;
586            // World box lands centered and no larger than the canvas.
587            assert!((ox - (canvas[0] - world[0] * s) * 0.5).abs() < 1e-4);
588            assert!((oy - (canvas[1] - world[1] * s) * 0.5).abs() < 1e-4);
589            assert!(world[0] * s <= canvas[0] + 1e-4 && world[1] * s <= canvas[1] + 1e-4);
590        }
591    }
592
593    /// `Expand` keeps the same uniform scale but anchors the world box at
594    /// the canvas origin, so the roomier axis shows world past `world` and
595    /// nothing is ever cropped.
596    #[test]
597    fn fit_mode_expand_reveals_more_and_never_crops() {
598        let canvas = [1600.0, 900.0];
599        let world = [800.0, 800.0];
600        let cam = Camera2d {
601            center: Vec2::new(world[0] * 0.5, world[1] * 0.5),
602            fit: FitMode::Expand,
603            ..Default::default()
604        };
605        let (s, ox, oy) = effective_fit(canvas, world, &cam);
606        let keep = effective_fit(
607            canvas,
608            world,
609            &Camera2d {
610                fit: FitMode::Keep,
611                ..cam
612            },
613        );
614        assert_eq!(s, keep.0, "expand must not change the uniform scale");
615        assert_eq!((ox, oy), (0.0, 0.0), "expand anchors at the origin");
616        // Never crops: the whole world box still lands inside the canvas.
617        assert!(world[0] * s <= canvas[0] + 1e-4 && world[1] * s <= canvas[1] + 1e-4);
618        // Reveals more on the roomier axis, while the constraining axis
619        // still shows exactly `world` (its scale is unchanged).
620        let visible = [canvas[0] / s, canvas[1] / s];
621        let exact = [
622            (visible[0] - world[0]).abs() < 1e-3,
623            (visible[1] - world[1]).abs() < 1e-3,
624        ];
625        assert!(exact[0] ^ exact[1], "one axis exact: {visible:?}");
626        if exact[0] {
627            assert!(visible[1] > world[1], "y reveals: {visible:?}");
628        } else {
629            assert!(visible[0] > world[0], "x reveals: {visible:?}");
630        }
631        // Picks invert the expanded framing like any other.
632        let dp = world_to_dp([100.0, 700.0], world, cam.effective_center(), (s, ox, oy));
633        let back = dp_to_world(dp, world, cam.effective_center(), (s, ox, oy));
634        assert!((back[0] - 100.0).abs() < 1e-3 && (back[1] - 700.0).abs() < 1e-3);
635    }
636
637    #[test]
638    fn canvas_rotated_rect_matches_instance_rows() {
639        // Canvas draws the mirror-adjusted quad plus a px-space rotation;
640        // GPU draws `instance_rows` corners. Both land on the same
641        // px corners (compared as sets) across combos.
642        let cam = Camera2d {
643            center: Vec2::new(400.0, 300.0),
644            offset: Vec2::ZERO,
645            units_per_pixel: 1.0,
646            zoom: 1.0,
647            roll: 0.3,
648            fit: FitMode::Keep,
649        };
650        let world_size = [800.0, 600.0];
651        let canvas_dp = [800.0, 600.0];
652        let fit = effective_fit(canvas_dp, world_size, &cam);
653        let center = cam.effective_center();
654        let project = |wx: f32, wy: f32| -> [f32; 2] {
655            world_to_dp_with_roll([wx, wy], world_size, center, fit, cam.roll)
656        };
657        let k = fit.0;
658        for (rotation, flip_x, flip_y, anchor) in [
659            (0.7f32, false, false, [0.5, 0.5]),
660            (0.7, true, false, [0.5, 0.5]),
661            (0.7, false, true, [0.5, 0.5]),
662            (0.7, true, true, [0.5, 0.5]),
663            (-1.2, true, false, [0.0, 0.0]),
664            (2.1, false, true, [1.0, 0.25]),
665            (0.0, true, false, [0.5, 0.5]),
666        ] {
667            let size = [64.0f32, 40.0];
668            let ctr = [400.0f32, 300.0];
669            // GPU reference: instance_rows corners through the projection.
670            let (row0, row1) = batch::instance_rows(ctr, size, rotation, anchor, flip_x, flip_y);
671            let mut expect = [[0.0f32; 2]; 4];
672            for (i, corner) in [[-0.5, -0.5], [0.5, -0.5], [0.5, 0.5], [-0.5, 0.5]]
673                .iter()
674                .enumerate()
675            {
676                let wx = row0[0] * corner[0] + row0[1] * corner[1] + row0[3];
677                let wy = row1[0] * corner[0] + row1[1] * corner[1] + row1[3];
678                expect[i] = project(wx, wy);
679            }
680            // Canvas: mirror-adjusted rect about the pivot, rotated by
681            // roll + rotation. Mirroring is captured by the rect as a
682            // set, so the angle does not negate.
683            let ax = if flip_x { 1.0 - anchor[0] } else { anchor[0] };
684            let ay = if flip_y { 1.0 - anchor[1] } else { anchor[1] };
685            let [px, py] = project(ctr[0], ctr[1]);
686            let angle = cam.roll + rotation;
687            let (c, s) = (angle.cos(), angle.sin());
688            let (ox, oy) = (px - ax * size[0] * k, py - ay * size[1] * k);
689            let w = size[0] * k;
690            let h = size[1] * k;
691            for (i, corner) in [[0.0, 0.0], [w, 0.0], [w, h], [0.0, h]].iter().enumerate() {
692                let rx = px + (ox + corner[0] - px) * c - (oy + corner[1] - py) * s;
693                let ry = py + (ox + corner[0] - px) * s + (oy + corner[1] - py) * c;
694                let mut best = f32::INFINITY;
695                for e in expect {
696                    best = best.min((rx - e[0]).hypot(ry - e[1]));
697                }
698                assert!(
699                    best < 1e-2,
700                    "rot={rotation} flip=({flip_x},{flip_y}) anchor={anchor:?}: corner {i} ({rx},{ry}) matches none of {expect:?}"
701                );
702            }
703        }
704    }
705
706    #[test]
707    #[allow(deprecated)]
708    fn camera_guards_div_by_zero() {
709        let bad = Camera2d {
710            center: Vec2::ZERO,
711            offset: Vec2::ZERO,
712            units_per_pixel: 0.0,
713            zoom: 0.0,
714            roll: 0.0,
715            fit: FitMode::Keep,
716        };
717        let m = bad.view_proj([0.0, 0.0]);
718        assert!(m.is_finite(), "view_proj stays finite, got {m:?}");
719        assert_eq!(bad.screen_to_world([0.0, 0.0], [5.0, 5.0]), Vec2::ZERO);
720    }
721
722    #[test]
723    fn fit_matrix_canvas_gpu_picks_agree() {
724        let cam = Camera2d {
725            center: Vec2::new(410.0, 290.0),
726            offset: Vec2::ZERO,
727            units_per_pixel: 1.0,
728            zoom: 2.0,
729            roll: 0.0,
730            fit: FitMode::Keep,
731        };
732        let world_size = [800.0, 600.0];
733        let density = 1.25;
734        let vp_phys = [1250.0, 750.0];
735        let canvas_dp = [vp_phys[0] / density, vp_phys[1] / density];
736        let cam_center = cam.effective_center();
737        let fit = effective_fit(canvas_dp, world_size, &cam);
738        assert!((fit.0 - 2.0).abs() < 1e-4, "effective fit, got {fit:?}");
739        let vp = cam.fit_matrix(canvas_dp, world_size);
740        assert!(vp.is_finite());
741        for world in [[400.0, 300.0], [410.0, 290.0], [0.0, 0.0], [800.0, 600.0]] {
742            let [dx, dy] = world_to_dp(world, world_size, cam_center, fit);
743            let px = [dx * density, dy * density];
744            let ndc = vp.project_point3(glam::Vec3::new(world[0], world[1], 0.0));
745            let gpu_px = [
746                (ndc.x + 1.0) * 0.5 * vp_phys[0],
747                (1.0 - ndc.y) * 0.5 * vp_phys[1],
748            ];
749            assert!(
750                (px[0] - gpu_px[0]).abs() < 1e-2 && (px[1] - gpu_px[1]).abs() < 1e-2,
751                "canvas/GPU disagree for {world:?}: canvas {px:?} vs gpu {gpu_px:?}"
752            );
753            let back =
754                cam.dp_to_world_pt(canvas_dp, world_size, [px[0] / density, px[1] / density]);
755            assert!(
756                (back.x - world[0]).abs() < 1e-3 && (back.y - world[1]).abs() < 1e-3,
757                "pick round trip for {world:?} gave {back:?}"
758            );
759        }
760    }
761
762    #[test]
763    fn letterbox_pick_matches_draw() {
764        // canvas 1600x900 dp, world 800x800 -> horizontal letterbox
765        let cam = Camera2d {
766            center: Vec2::new(400.0, 400.0),
767            ..Default::default()
768        };
769        let fit = effective_fit([1600.0, 900.0], [800.0, 800.0], &cam);
770        let dp = world_to_dp([400.0, 400.0], [800.0, 800.0], cam.effective_center(), fit);
771        let back = dp_to_world(dp, [800.0, 800.0], cam.effective_center(), fit);
772        assert!((back[0] - 400.0).abs() < 1e-3 && (back[1] - 400.0).abs() < 1e-3);
773        // Method and plain function share the same matrix.
774        let m = cam.fit_matrix([1600.0, 900.0], [800.0, 800.0]);
775        let plain = fit_view_proj([1600.0, 900.0], [800.0, 800.0], cam.effective_center(), fit);
776        let dm = m - plain;
777        let max = dm
778            .to_cols_array()
779            .iter()
780            .fold(0.0f32, |a, b| a.max(b.abs()));
781        assert!(max < 1e-5);
782    }
783
784    #[test]
785    fn offset_shifts_look_and_draw_together() {
786        // Shake rides the offset channel: the same world point draws and
787        // picks with the offset applied.
788        let world_size = [800.0, 600.0];
789        let canvas_dp = [1000.0, 600.0];
790        let mut cam = Camera2d {
791            center: Vec2::new(400.0, 300.0),
792            offset: Vec2::new(10.0, -10.0),
793            units_per_pixel: 1.0,
794            zoom: 1.0,
795            roll: 0.0,
796            fit: FitMode::Keep,
797        };
798        assert_eq!(cam.effective_center(), [410.0, 290.0]);
799        let fit = effective_fit(canvas_dp, world_size, &cam);
800        let dp = world_to_dp([400.0, 300.0], world_size, cam.effective_center(), fit);
801        let back = dp_to_world(dp, world_size, cam.effective_center(), fit);
802        assert!((back[0] - 400.0).abs() < 1e-4 && (back[1] - 300.0).abs() < 1e-4);
803        cam.offset = Vec2::ZERO;
804        let dp2 = world_to_dp([400.0, 300.0], world_size, cam.effective_center(), fit);
805        // Offset 10 world units at scale 1 moves the drawing by 10 dp.
806        assert!((dp[0] - dp2[0] + 10.0).abs() < 1e-4);
807        assert!((dp[1] - dp2[1] - 10.0).abs() < 1e-4);
808    }
809
810    #[test]
811    fn click_slop_cancels_drags() {
812        assert!(click_within_slop_dp([100.0, 100.0], [105.0, 105.0], 1.0));
813        assert!(click_within_slop_dp([100.0, 100.0], [105.0, 105.0], 1.0));
814        assert!(!click_within_slop_dp(
815            [100.0, 100.0],
816            [100.0 + CLICK_SLOP_DP + 1.0, 100.0],
817            1.0
818        ));
819    }
820
821    #[test]
822    fn click_slop_is_density_independent() {
823        // 12 dp at density 3 is 36 px: same tap cancels on both densities.
824        assert!(click_within_slop_dp([0.0, 0.0], [36.0, 0.0], 3.0));
825        assert!(!click_within_slop_dp([0.0, 0.0], [37.0, 0.0], 3.0));
826        assert!(click_within_slop_dp([0.0, 0.0], [12.0, 0.0], 1.0));
827        assert!(!click_within_slop_dp([0.0, 0.0], [13.0, 0.0], 1.0));
828    }
829
830    #[test]
831    fn roll_roundtrips_and_moves_pixels() {
832        let cam = Camera2d {
833            center: Vec2::new(400.0, 300.0),
834            offset: Vec2::ZERO,
835            units_per_pixel: 1.0,
836            zoom: 1.0,
837            roll: std::f32::consts::FRAC_PI_2,
838            fit: FitMode::Keep,
839        };
840        let world = [800.0, 600.0];
841        let dp = [1000.0, 600.0];
842        let fit = effective_fit(dp, world, &cam);
843        for p in [[400.0, 300.0], [500.0, 300.0], [0.0, 0.0]] {
844            let fwd = world_to_dp_with_roll(p, world, cam.effective_center(), fit, cam.roll);
845            let back = dp_to_world_with_roll(fwd, world, cam.effective_center(), fit, cam.roll);
846            assert!(
847                (back[0] - p[0]).abs() < 1e-3 && (back[1] - p[1]).abs() < 1e-3,
848                "roll roundtrip for {p:?} gave {back:?}"
849            );
850        }
851        let plain = world_to_dp([500.0, 300.0], world, cam.effective_center(), fit);
852        let rolled =
853            world_to_dp_with_roll([500.0, 300.0], world, cam.effective_center(), fit, cam.roll);
854        assert!(
855            (plain[0] - rolled[0]).abs() + (plain[1] - rolled[1]).abs() > 1.0,
856            "roll must move pixels, plain {plain:?} rolled {rolled:?}"
857        );
858        let m0 = fit_view_proj_with_roll(dp, world, cam.effective_center(), fit, 0.0);
859        let legacy = fit_view_proj(dp, world, cam.effective_center(), fit);
860        assert_eq!(m0, legacy);
861    }
862
863    #[test]
864    fn px_round_trips_through_fit() {
865        let cam = Camera2d {
866            center: Vec2::new(213.0, 120.0),
867            offset: Vec2::ZERO,
868            units_per_pixel: 1.0 / 3.0,
869            zoom: 1.0,
870            roll: 0.0,
871            fit: FitMode::Keep,
872        };
873        let world = Vec2::new(100.0, 60.0);
874        let viewport_dp = [1024.0, 600.0];
875        let world_size = [1024.0, 600.0];
876        let (s, ox, oy) = effective_fit(viewport_dp, world_size, &cam);
877        let [dx, dy] = world_to_dp(
878            [world.x, world.y],
879            world_size,
880            cam.effective_center(),
881            (s, ox, oy),
882        );
883        let px = Vec2::new(dx * 1.25, dy * 1.25);
884        let back = unproject_px(px, viewport_dp, world_size, 1.25, &cam);
885        assert!(
886            (back - world).length() < 0.01,
887            "round trip, got {back:?} for {world:?}"
888        );
889    }
890}
891
892/// One decoded + magnified hardware-cursor frame.
893#[derive(Clone, Debug, PartialEq, Eq)]
894pub struct CursorFrame {
895    pub rgba: Vec<u8>,
896    pub size: [u16; 2],
897    pub hotspot: [u16; 2],
898    pub key: (i32, [u32; 3], u64, u32),
899}
900
901pub fn cursor_frame(
902    strip: &[u8],
903    strip_w: u32,
904    strip_h: u32,
905    frames: u32,
906    frame: i32,
907    cell: (u32, u32),
908    origin: (f32, f32),
909    tint: [f32; 4],
910    mag: u32,
911    strip_hash: u64,
912) -> Option<CursorFrame> {
913    let frames = frames.max(1) as i32;
914    let frame = frame.clamp(0, frames - 1);
915    let (cw, ch) = (cell.0.max(1), cell.1.max(1));
916    if strip_w < cw * (frame as u32 + 1) || strip_h < ch {
917        return None;
918    }
919    let mag = mag.clamp(1, 8);
920    let tint_u8 = [
921        (tint[0].clamp(0.0, 1.0) * 255.0) as u32,
922        (tint[1].clamp(0.0, 1.0) * 255.0) as u32,
923        (tint[2].clamp(0.0, 1.0) * 255.0) as u32,
924    ];
925    let key = (frame, tint_u8, strip_hash, mag);
926    let fx = (frame as u32 * cw) as usize;
927    let (dw, dh) = (cw * mag, ch * mag);
928    if dw > 2048 || dh > 2048 {
929        return None;
930    }
931    let mut px = Vec::with_capacity((dw * dh * 4) as usize);
932    for row in 0..ch {
933        let start = ((row * strip_w) as usize + fx) * 4;
934        let end = start + (cw as usize) * 4;
935        let cell = strip.get(start..end)?;
936        let mut mag_row = Vec::with_capacity((cw * mag * 4) as usize);
937        for pix in cell.chunks_exact(4) {
938            let t = [
939                (pix[0] as u32 * tint_u8[0] / 255) as u8,
940                (pix[1] as u32 * tint_u8[1] / 255) as u8,
941                (pix[2] as u32 * tint_u8[2] / 255) as u8,
942                pix[3],
943            ];
944            for _ in 0..mag {
945                mag_row.extend_from_slice(&t);
946            }
947        }
948        for _ in 0..mag {
949            px.extend_from_slice(&mag_row);
950        }
951    }
952    let (w16, h16) = (dw.min(2048) as u16, dh.min(2048) as u16);
953    Some(CursorFrame {
954        rgba: px,
955        size: [w16, h16],
956        hotspot: [
957            (origin.0 * mag as f32)
958                .round()
959                .clamp(0.0, w16.saturating_sub(1) as f32) as u16,
960            (origin.1 * mag as f32)
961                .round()
962                .clamp(0.0, h16.saturating_sub(1) as f32) as u16,
963        ],
964        key,
965    })
966}
967
968/// Unproject one window-physical px point through this frame's camera.
969pub fn unproject_px(
970    px: Vec2,
971    viewport_dp: [f32; 2],
972    world_size: [f32; 2],
973    density: f32,
974    cam: &Camera2d,
975) -> Vec2 {
976    let d = if density.is_finite() && density > 1e-6 {
977        density
978    } else {
979        1.0
980    };
981    let dp = [px.x / d, px.y / d];
982    cam.dp_to_world_pt(viewport_dp, world_size, dp)
983}