Skip to main content

roxlap_render/
lib.rs

1//! roxlap-render — unified CPU/GPU renderer facade.
2//!
3//! One [`SceneRenderer`] hides the choice between the CPU opticast
4//! path (`roxlap-core` / `roxlap-scene`, presented via `softbuffer`)
5//! and the GPU compute-shader path (`roxlap-gpu`, presented via its
6//! own wgpu surface). Construction picks the GPU backend when asked
7//! and able, and **falls back to CPU automatically** when WGPU init
8//! fails — so a host never has to branch on GPU availability or carry
9//! the `Scene`→GPU upload/refresh/transform glue itself.
10//!
11//! Hosts stay thin: build a `Scene`, advance it from input, then call
12//! [`SceneRenderer::render`] each frame. The facade owns the window
13//! surface, the framebuffer/z-buffer (CPU) or the resident scene +
14//! dirty-chunk tracking (GPU), and presentation.
15//!
16//! The per-frame flow is `render` → *(optional overlays)* → finish.
17//! Between [`SceneRenderer::render`] and the finishing
18//! [`SceneRenderer::present`] / [`SceneRenderer::paint_egui`] call, a
19//! host may overlay depth-tested world-space lines with
20//! [`SceneRenderer::draw_lines`] (editor gizmos, debug geometry — see
21//! [`Line3`]); they land in the framebuffer, occluded by the rendered
22//! scene, with egui still painting panels on top.
23//!
24//! This is the RF.0 skeleton: backend selection + fallback + a
25//! clear-to-sky frame. RF.1/RF.2 fill in the real CPU/GPU scene
26//! render; RF.3 adds sprites; RF.4 adds framebuffer capture.
27
28#![forbid(unsafe_code)]
29
30mod cpu;
31/// WebGL2 framebuffer presenter for the CPU backend on wasm (the
32/// browser has no `softbuffer`).
33#[cfg(target_arch = "wasm32")]
34mod cpu_blit;
35#[cfg(feature = "hud")]
36mod cpu_egui;
37mod gpu;
38
39#[cfg(not(target_arch = "wasm32"))]
40use std::sync::Arc;
41
42use roxlap_core::opticast::OpticastSettings;
43use roxlap_core::sky::Sky;
44use roxlap_core::Camera;
45use roxlap_scene::Scene;
46
47pub use roxlap_formats::kfa::KfaSprite;
48pub use roxlap_formats::kv6::Kv6;
49pub use roxlap_formats::sprite::Sprite;
50pub use roxlap_gpu::{GpuInitError, GpuRendererSettings, PowerPreference};
51// Re-exported so hosts can name the [`SceneRenderer::new`] bounds
52// without adding a direct `raw-window-handle` dependency of their own.
53pub use raw_window_handle::{HasDisplayHandle, HasWindowHandle};
54// Re-exported so hosts feed [`SceneRenderer::paint_egui`] from the exact
55// egui version the renderer was built against (`hud` feature).
56#[cfg(feature = "hud")]
57pub use egui;
58
59use crate::cpu::CpuBackend;
60use crate::gpu::GpuBackend;
61
62/// Type-erased display handle stored by the CPU backend's softbuffer
63/// surface. `raw-window-handle` implements `HasDisplayHandle` for
64/// `Arc<H>` (`H: ?Sized`), and the bare trait object implements its
65/// own object-safe trait — so `Arc<W>` coerces to `Arc<DynDisplay>`
66/// for any provider `W`.
67#[cfg(not(target_arch = "wasm32"))]
68pub(crate) type DynDisplay = dyn HasDisplayHandle + Send + Sync + 'static;
69/// Type-erased window handle counterpart to [`DynDisplay`].
70#[cfg(not(target_arch = "wasm32"))]
71pub(crate) type DynWindow = dyn HasWindowHandle + Send + Sync + 'static;
72
73/// One placed sprite instance: which [`SpriteSet::models`] entry and
74/// where in the world.
75pub struct SpriteInstanceDesc {
76    pub model: usize,
77    pub pos: [f32; 3],
78}
79
80/// Stable handle to a registered sprite model, returned (one per
81/// [`SpriteSet::models`] entry, in order) by
82/// [`SceneRenderer::set_sprites`]. Pass it to
83/// [`refresh_sprite_model`](SceneRenderer::refresh_sprite_model) to
84/// re-register that model's geometry after a content edit — so callers
85/// never track the positional `usize` index themselves. Opaque on
86/// purpose: there is no arithmetic to do on it.
87///
88/// Also returned by [`SceneRenderer::add_sprite_model`] for an
89/// incrementally registered model, and accepted by
90/// [`remove_sprite_model`](SceneRenderer::remove_sprite_model). A handle
91/// to a removed model is **stale**: it resolves to nothing, so passing
92/// it anywhere is a safe no-op. The `gen` (generation) field guards a
93/// future compacting registry; it stays `0` today because model slots
94/// are tombstoned in place and never reused (GPU chain ids are
95/// append-only).
96#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
97pub struct SpriteModelId {
98    pub(crate) slot: u32,
99    pub(crate) gen: u32,
100}
101
102/// Stable handle to a **dynamically added** sprite instance — the result
103/// of [`SceneRenderer::add_sprite_instance`], passed to
104/// [`remove_sprite_instance`](SceneRenderer::remove_sprite_instance).
105///
106/// Backends remove instances by swap (O(1)), which moves another instance
107/// into the freed slot; this handle survives that because the facade keeps
108/// the id↔slot mapping up to date. The generation guards against a stale
109/// handle aliasing a recycled slot.
110#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
111pub struct SpriteInstanceId {
112    slot: u32,
113    gen: u32,
114}
115
116/// Facade-side slotmap that turns the backends' swap-remove indexing into
117/// stable [`SpriteInstanceId`] handles. Both backends keep their dynamic
118/// instances as a tail sublist indexed `0..n`; `order[dyn_index]` is the
119/// owning slot, and a removal fixes up the one slot whose instance was
120/// swapped into the hole.
121#[derive(Default)]
122struct DynInstanceMap {
123    /// Per slot: `(generation, Some(dyn_index) while live)`.
124    slots: Vec<(u32, Option<u32>)>,
125    /// Per live `dyn_index`: the owning slot. Parallel to the backends'
126    /// dynamic sublist (so `order.len()` == the dynamic instance count).
127    order: Vec<u32>,
128    free: Vec<u32>,
129}
130
131impl DynInstanceMap {
132    /// Register a freshly appended instance (always at `dyn_index ==
133    /// order.len()`); returns its stable handle.
134    fn alloc(&mut self, dyn_index: u32) -> SpriteInstanceId {
135        debug_assert_eq!(self.order.len() as u32, dyn_index);
136        let slot = self.free.pop().unwrap_or_else(|| {
137            self.slots.push((0, None));
138            (self.slots.len() - 1) as u32
139        });
140        let gen = self.slots[slot as usize].0;
141        self.slots[slot as usize].1 = Some(dyn_index);
142        self.order.push(slot);
143        SpriteInstanceId { slot, gen }
144    }
145
146    /// Resolve a handle to its current backend `dyn_index`, or `None` if
147    /// it's stale / already removed.
148    fn dyn_index(&self, id: SpriteInstanceId) -> Option<u32> {
149        let (gen, idx) = *self.slots.get(id.slot as usize)?;
150        (gen == id.gen).then_some(idx).flatten()
151    }
152
153    /// Apply a removal: the backend swap-removed `removed` and reported
154    /// `moved` (the old-last `dyn_index` that slid into `removed`, or
155    /// `None` if `removed` was itself the last).
156    fn remove(&mut self, id: SpriteInstanceId, removed: u32, moved: Option<u32>) {
157        self.slots[id.slot as usize].1 = None;
158        self.slots[id.slot as usize].0 += 1; // bump generation
159        self.free.push(id.slot);
160        if let Some(last) = moved {
161            let moved_slot = self.order[last as usize];
162            self.slots[moved_slot as usize].1 = Some(removed);
163            self.order[removed as usize] = moved_slot;
164        }
165        self.order.pop();
166    }
167}
168
169/// Facade-side slotmap for registered sprite **models**, mirroring
170/// [`DynInstanceMap`] but **without** the swap-remove fixup: a model
171/// slot maps 1:1 to the backends' positional model index (the GPU LOD
172/// chain id), which is append-only and never reused. A removed model
173/// tombstones its slot *in place* (the backend frees the voxel data but
174/// keeps the id), so a stale [`SpriteModelId`] resolves to `None` → a
175/// safe no-op rather than aliasing another model.
176#[derive(Default)]
177struct DynModelMap {
178    /// Per slot (== backend model index): `(generation, live)`. Slots are
179    /// never reused, so `generation` stays `0`; `live` flips to `false`
180    /// on removal.
181    slots: Vec<(u32, bool)>,
182}
183
184impl DynModelMap {
185    /// Reset to `n` live models with ids `0..n` — used by
186    /// [`SceneRenderer::set_sprites`], which rebuilds the whole model set
187    /// positionally (model index = chain id on both backends).
188    fn reset(&mut self, n: usize) {
189        self.slots.clear();
190        self.slots.resize(n, (0, true));
191    }
192
193    /// Register a freshly appended model at positional index
194    /// `model_index` (always the new `slots.len()`); returns its handle.
195    fn alloc(&mut self, model_index: u32) -> SpriteModelId {
196        debug_assert_eq!(self.slots.len() as u32, model_index);
197        self.slots.push((0, true));
198        SpriteModelId {
199            slot: model_index,
200            gen: 0,
201        }
202    }
203
204    /// Resolve a handle to its backend model index, or `None` if it's
205    /// stale / already removed.
206    fn model_index(&self, id: SpriteModelId) -> Option<usize> {
207        let (gen, live) = *self.slots.get(id.slot as usize)?;
208        (gen == id.gen && live).then_some(id.slot as usize)
209    }
210
211    /// Tombstone a model slot in place. Returns `false` if the handle is
212    /// stale / already removed.
213    fn remove(&mut self, id: SpriteModelId) -> bool {
214        let Some(slot) = self.slots.get_mut(id.slot as usize) else {
215            return false;
216        };
217        if slot.0 != id.gen || !slot.1 {
218            return false;
219        }
220        slot.1 = false;
221        true
222    }
223}
224
225/// Orientation + position for a dynamic sprite instance — the per-frame
226/// pose passed to [`SceneRenderer::add_sprite_instance_posed`] and
227/// [`set_sprite_instance_transform`](SceneRenderer::set_sprite_instance_transform).
228///
229/// `right`/`up`/`forward` are the instance's local axes expressed in
230/// world space (the columns of the model→world rotation), mapping
231/// directly onto the underlying [`Sprite`]'s `s`/`h`/`f` (kv6 local
232/// +x/+y/+z). They **must** be non-singular (`det ≠ 0`) but need not be
233/// orthonormal — a uniform/non-uniform scale or shear is fine. A
234/// near-singular basis falls through the renderer's degenerate-basis
235/// guards and the instance silently skips that frame rather than
236/// panicking. [`Default`] is the identity basis (axis-aligned).
237#[derive(Clone, Copy, Debug)]
238pub struct DynSpriteTransform {
239    /// Instance world position (the kv6 pivot maps here).
240    pub pos: [f32; 3],
241    /// Local +x in world space ↦ [`Sprite::s`].
242    pub right: [f32; 3],
243    /// Local +y in world space ↦ [`Sprite::h`].
244    pub up: [f32; 3],
245    /// Local +z in world space ↦ [`Sprite::f`].
246    pub forward: [f32; 3],
247}
248
249impl Default for DynSpriteTransform {
250    fn default() -> Self {
251        Self {
252            pos: [0.0, 0.0, 0.0],
253            right: [1.0, 0.0, 0.0],
254            up: [0.0, 1.0, 0.0],
255            forward: [0.0, 0.0, 1.0],
256        }
257    }
258}
259
260impl DynSpriteTransform {
261    /// Stamp this pose onto a [`Sprite`] in place: `pos → p`,
262    /// `right/up/forward → s/h/f` (a direct copy — the basis is the
263    /// model→world columns). Both backends keep the rest of the template
264    /// (`kv6`, `flags`) and only overwrite the pose.
265    pub(crate) fn apply_to(self, s: &mut Sprite) {
266        s.p = self.pos;
267        s.s = self.right;
268        s.h = self.up;
269        s.f = self.forward;
270    }
271}
272
273/// Backend-agnostic sprite description. The facade builds the CPU
274/// per-instance draw list and the GPU instanced registry from the
275/// same data, so both backends show identical sprites. The host owns
276/// content (which models, where, recolouring) — building a recoloured
277/// variant is just a second [`Sprite`] model with edited `kv6.voxels`.
278pub struct SpriteSet {
279    /// Distinct voxel models (KV6 + base orientation). Instances index
280    /// into this; their position overrides the model's.
281    pub models: Vec<Sprite>,
282    pub instances: Vec<SpriteInstanceDesc>,
283    /// Model the [`SceneRenderer::carve_active_sprite`] hotkey edits
284    /// (GPU only, mirroring the demo's `G`-carve). `None` disables it.
285    pub carve_model: Option<usize>,
286}
287
288/// Per-frame inputs both backends consume. The host builds the
289/// [`OpticastSettings`] (it owns scan distance etc.); the facade does
290/// everything else (pool config, sky fill, render, present).
291pub struct FrameParams<'a> {
292    /// CPU opticast settings (scan distance, mip ladder, framebuffer
293    /// geometry). Ignored by the GPU backend.
294    pub settings: &'a OpticastSettings,
295    /// Packed engine sky colour: the CPU sky-miss fill + skycast, and
296    /// the clear colour if no scene renders.
297    pub sky_color: u32,
298    /// Optional sky panorama for the CPU rasterizer's sky sampling.
299    pub sky: Option<&'a Sky>,
300    /// CPU fog: packed colour + max scan distance (voxels). `0` scan
301    /// distance disables CPU fog.
302    pub fog_color: u32,
303    pub fog_max_scan_dist: i32,
304    /// CPU: treat z=255 as air (avoids the S1.X bedrock path for
305    /// out-of-bounds cameras).
306    pub treat_z_max_as_air: bool,
307    /// GPU scene-grid LOD scan distance (world units); see GPU.11.1.
308    /// Ignored by the CPU backend.
309    pub gpu_mip_scan_dist: f32,
310    /// GPU outer-DDA step budget (chunks). Ignored by the CPU backend.
311    pub gpu_max_outer_steps: u32,
312    /// GPU vertical field of view (radians). Ignored by the CPU
313    /// backend (it derives projection from [`OpticastSettings`]).
314    pub gpu_fov_y_rad: f32,
315    /// Whether to draw the renderer's sprites this frame. Both backends
316    /// draw KV6 sprites flat-lit (the clean-room DDA sprite raycaster on
317    /// CPU; uploaded model colours on GPU), so no host-supplied lighting
318    /// is needed — this is just the on/off opt-in. `false` skips sprite
319    /// drawing.
320    pub draw_sprites: bool,
321    /// Per-face directional shading for the voxel grids — voxlap's
322    /// `setsideshades(top, bot, left, right, up, down)`, the grid-scan
323    /// analogue of [`draw_sprites`](Self::draw_sprites). Each
324    /// entry darkens the faces pointing that way; the host typically
325    /// passes its engine's `side_shades()`. The default `[0; 6]` keeps
326    /// `sideshademode` off (no per-side shading), so existing hosts and
327    /// the oracle goldens are unaffected. Applied each frame by **both**
328    /// backends: the CPU rasteriser via `gcsub`, and the GPU scene-DDA
329    /// pass by darkening a hit voxel's brightness by the hit face's
330    /// shade (the face taken from the DDA's last-stepped axis).
331    pub side_shades: [i8; 6],
332}
333
334/// Result of [`SceneRenderer::pick`] — a resolved screen→world voxel
335/// hit. `world` is the surface point (`cam.pos + t · normalize(ray)`);
336/// `grid` + `voxel` are the owning grid and its **grid-local** voxel
337/// (transform-correct for rotated / translated grids).
338#[derive(Clone, Copy, PartialEq, Debug)]
339pub struct PickHit {
340    pub world: [f32; 3],
341    pub grid: roxlap_scene::GridId,
342    pub voxel: glam::IVec3,
343}
344
345/// A world-space view ray: the canonical unproject output of
346/// [`SceneRenderer::view_ray`]. `dir` is unit-length. Feed it straight
347/// to [`roxlap_scene::Scene::raycast`] for depth-free, backend-agnostic
348/// voxel picking (`scene.raycast(ray.origin, ray.dir, max_dist)`), or
349/// intersect it with a plane for tile selection.
350#[derive(Clone, Copy, PartialEq, Debug)]
351pub struct Ray {
352    pub origin: glam::DVec3,
353    pub dir: glam::DVec3,
354}
355
356/// A world-space line segment to draw over a rendered frame via
357/// [`SceneRenderer::draw_lines`] — editor gizmos (bounding boxes, floor
358/// grids, axes, hover wireframes), debug paths, etc.
359#[derive(Clone, Copy, PartialEq, Debug)]
360pub struct Line3 {
361    /// World-space endpoints (voxel units), in the same frame the
362    /// rendered scene + `camera` use.
363    pub a: [f64; 3],
364    pub b: [f64; 3],
365    /// `0xAARRGGBB` — the high byte is an alpha blend factor (`0xFF`
366    /// opaque, `0x00` invisible), the low 24 bits the RGB colour.
367    pub color: u32,
368    /// Screen-space thickness in pixels (`<= 1.0` draws a 1px line).
369    pub width_px: f32,
370    /// `true`: the segment is occluded by nearer rendered geometry
371    /// (depth-tested against the frame's z-buffer). `false`: always on
372    /// top (e.g. a hover highlight that should show through the model).
373    pub depth_test: bool,
374}
375
376/// A handle to an uploaded image-sprite texture, returned by
377/// [`SceneRenderer::upload_image`]. Positional (like [`SpriteModelId`]):
378/// it indexes the backend's texture store. Pass it in an [`ImageSprite`]
379/// for [`SceneRenderer::draw_images`], or to
380/// [`drop_image`](SceneRenderer::drop_image) to release it. Opaque on
381/// purpose — there's no arithmetic to do on it.
382#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
383pub struct ImageId(pub(crate) usize);
384
385/// How an [`ImageSprite`]'s quad is oriented in the world.
386#[derive(Clone, Copy, PartialEq, Debug)]
387pub enum ImageFacing {
388    /// Fixed in world space: the quad lies in the plane spanned by `u`
389    /// (the image's +column / width direction) and `v` (its +row /
390    /// height direction). Both are world-space directions; their length
391    /// is ignored (the quad is sized by [`ImageSprite::size`]), so pass
392    /// the plane's axes directly. Row 0 of the image is the `origin`
393    /// edge and rows grow along `v`.
394    World { u: [f32; 3], v: [f32; 3] },
395    /// Always faces the camera (billboard); `up` is the world direction
396    /// the image's top edge points toward (e.g. world `-Z` for the
397    /// scene-demo's z-down world, or any "up" the host prefers).
398    Billboard { up: [f32; 3] },
399}
400
401/// One placed 2D image sprite for the current frame: a flat textured
402/// quad in world space, composited over the rendered scene with the
403/// frame's depth buffer (so the voxel model can occlude it). Built per
404/// frame and passed to [`SceneRenderer::draw_images`], mirroring
405/// [`Line3`] / [`SceneRenderer::draw_lines`]. The texture is uploaded
406/// once via [`SceneRenderer::upload_image`] and referenced by [`image`].
407///
408/// [`image`]: ImageSprite::image
409#[derive(Clone, Copy, PartialEq, Debug)]
410pub struct ImageSprite {
411    /// The uploaded texture to draw (from [`SceneRenderer::upload_image`]).
412    pub image: ImageId,
413    /// World position of the quad's **top-left** corner — the image's
414    /// `(column 0, row 0)` texel. The quad extends `size[0]` along the
415    /// facing's `u` and `size[1]` along its `v`.
416    pub origin: [f32; 3],
417    /// World orientation of the quad — fixed in world or camera-facing.
418    pub facing: ImageFacing,
419    /// World size of the quad along `u` and `v`. For pixel-art traced at
420    /// 1 texel = 1 voxel, pass `[width as f32, height as f32]`.
421    pub size: [f32; 2],
422    /// Multiplied into every sampled texel (tint + opacity), `0xAARRGGBB`.
423    /// `0xFFFFFFFF` draws the texture unchanged; the high byte scales
424    /// the texel alpha (e.g. `0x80FFFFFF` = 50 % opacity).
425    pub tint: u32,
426    /// Alpha cutoff in `0.0..=1.0`. Texels whose **own** alpha is below
427    /// this are discarded outright (not blended) — crisp pixel-art edges
428    /// instead of a semi-transparent haze, and the same threshold decides
429    /// what [`SceneRenderer::pick_image`] treats as solid. `0.0` keeps the
430    /// plain straight-alpha over-blend (every non-zero texel draws).
431    pub alpha_cutoff: f32,
432    /// `true`: occluded by nearer rendered geometry (depth-tested against
433    /// the frame's depth buffer, with a bias so a quad resting on a
434    /// coincident voxel face doesn't z-fight). `false`: always on top.
435    pub depth_test: bool,
436    /// `true`: draw regardless of which way the quad faces (no backface
437    /// cull) — what reference images usually want. `false`: cull when the
438    /// quad faces away from the camera. Ignored for
439    /// [`ImageFacing::Billboard`] (it always faces the camera).
440    pub double_sided: bool,
441}
442
443/// Backend-agnostic resolved quad: four world corners (`TL, TR, BL, BR`,
444/// with UVs `(0,0) (1,0) (0,1) (1,1)`) + the texture to map. The facade
445/// resolves [`ImageSprite::facing`] into corners and culls back-facing
446/// quads once, so both backends draw from the same geometry.
447#[derive(Clone, Copy, Debug)]
448pub(crate) struct QuadDraw {
449    pub corners: [[f32; 3]; 4],
450    pub image: ImageId,
451    pub tint: u32,
452    pub depth_test: bool,
453    pub alpha_cutoff: f32,
454}
455
456/// Result of [`SceneRenderer::pick_image`] — a resolved screen→sprite hit.
457/// `uv` is the normalised position within the quad (`(0,0)` = top-left
458/// corner); `texel` is the matching source-image pixel; `world` is the
459/// hit point; `t` is its euclidean distance from the camera.
460#[derive(Clone, Copy, PartialEq, Debug)]
461pub struct ImagePickHit {
462    pub image: ImageId,
463    pub uv: [f32; 2],
464    pub texel: (u32, u32),
465    pub world: [f32; 3],
466    pub t: f32,
467}
468
469/// Which renderer a [`SceneRenderer`] resolved to at construction.
470#[derive(Clone, Copy, PartialEq, Eq, Debug)]
471pub enum Backend {
472    /// `roxlap-core` opticast, presented via `softbuffer`.
473    Cpu,
474    /// `roxlap-gpu` compute marcher, presented via wgpu.
475    Gpu,
476}
477
478/// Construction-time options for [`SceneRenderer::new`].
479pub struct RenderOptions {
480    /// Try the GPU backend first. When `false`, or when GPU init
481    /// fails, the renderer uses the CPU backend.
482    pub want_gpu: bool,
483    /// Settings forwarded to [`roxlap_gpu::GpuRenderer`] when the GPU
484    /// backend is selected.
485    pub gpu: GpuRendererSettings,
486    /// Packed `0x00RRGGBB` (alpha ignored) the empty/clear frame fills
487    /// with until a scene render lands. Also the CPU sky-miss colour
488    /// default if a frame supplies none.
489    pub clear_sky: u32,
490    /// CPU [`ScratchPool`](roxlap_core::rasterizer::ScratchPool) `lastx`
491    /// sizing — the largest combined grid `vsid` the CPU rasterizer
492    /// will see. Pre-sizing keeps later frames allocation-free.
493    pub cpu_max_grid_vsid: u32,
494    /// CPU strip-parallel render thread count (capped to the rayon
495    /// pool). One [`ScratchPool`](roxlap_core::rasterizer::ScratchPool)
496    /// slot per thread.
497    pub cpu_render_threads: usize,
498}
499
500impl Default for RenderOptions {
501    fn default() -> Self {
502        Self {
503            want_gpu: false,
504            gpu: GpuRendererSettings::default(),
505            clear_sky: 0x0099_b3d9,
506            // 32 chunks × CHUNK_SIZE_XY — the scene-demo's widest
507            // combined ground grid.
508            cpu_max_grid_vsid: 32 * roxlap_scene::CHUNK_SIZE_XY,
509            cpu_render_threads: 4,
510        }
511    }
512}
513
514/// Depth-test slack (same spirit as the backends' `DEPTH_BIAS`) so a
515/// [`SceneRenderer::pick_image`] hit on a sprite resting on a coincident
516/// voxel face isn't rejected as "occluded".
517const PICK_DEPTH_BIAS: f32 = 0.5;
518
519// --- image-sprite geometry helpers (shared by both backends) ---
520
521fn v_sub(a: [f32; 3], b: [f32; 3]) -> [f32; 3] {
522    [a[0] - b[0], a[1] - b[1], a[2] - b[2]]
523}
524fn v_add(a: [f32; 3], b: [f32; 3]) -> [f32; 3] {
525    [a[0] + b[0], a[1] + b[1], a[2] + b[2]]
526}
527fn v_scale(a: [f32; 3], s: f32) -> [f32; 3] {
528    [a[0] * s, a[1] * s, a[2] * s]
529}
530fn v_dot(a: [f32; 3], b: [f32; 3]) -> f32 {
531    a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
532}
533fn v_cross(a: [f32; 3], b: [f32; 3]) -> [f32; 3] {
534    [
535        a[1] * b[2] - a[2] * b[1],
536        a[2] * b[0] - a[0] * b[2],
537        a[0] * b[1] - a[1] * b[0],
538    ]
539}
540fn v_norm(a: [f32; 3]) -> [f32; 3] {
541    let len = v_dot(a, a).sqrt();
542    if len < 1e-12 {
543        a
544    } else {
545        v_scale(a, 1.0 / len)
546    }
547}
548
549/// Intersect a ray (`origin` + `dir`, `dir` un-normalised) with a quad
550/// `[TL, TR, BL, BR]` and return `(uv, t)` for a front/back hit inside
551/// the quad — `uv` in `0..=1` (`(0,0)` = `TL`), `t` the ray parameter
552/// (`hit = origin + dir·t`). `None` for a parallel ray, a hit behind the
553/// origin, a degenerate quad, or a hit outside the `u`/`v` span. Solves
554/// affine coords exactly for a (possibly skew) parallelogram. Standalone
555/// so the geometry is unit-testable without a renderer.
556fn ray_quad_uv(
557    origin: [f32; 3],
558    dir: [f32; 3],
559    corners: &[[f32; 3]; 4],
560) -> Option<([f32; 2], f32)> {
561    let [tl, tr, bl, _br] = *corners;
562    let ue = v_sub(tr, tl); // +u edge (width)
563    let ve = v_sub(bl, tl); // +v edge (height)
564    let n = v_cross(ue, ve);
565    let denom = v_dot(dir, n);
566    if denom.abs() < 1e-12 {
567        return None; // ray parallel to the quad's plane
568    }
569    let t = v_dot(v_sub(tl, origin), n) / denom;
570    if t <= 1e-6 {
571        return None; // behind / at the origin
572    }
573    let p = v_add(origin, v_scale(dir, t));
574    let rel = v_sub(p, tl);
575    let guu = v_dot(ue, ue);
576    let guv = v_dot(ue, ve);
577    let gvv = v_dot(ve, ve);
578    let det = guu * gvv - guv * guv;
579    if det.abs() < 1e-12 {
580        return None; // degenerate quad
581    }
582    let wu = v_dot(rel, ue);
583    let wv = v_dot(rel, ve);
584    let a = (gvv * wu - guv * wv) / det;
585    let b = (guu * wv - guv * wu) / det;
586    if !(0.0..=1.0).contains(&a) || !(0.0..=1.0).contains(&b) {
587        return None; // outside the quad
588    }
589    Some(([a, b], t))
590}
591
592/// Resolve an [`ImageSprite`] into its four world corners (`TL, TR, BL,
593/// BR`), or `None` when a `double_sided == false` world quad faces away
594/// from the camera (back-face cull) or its plane is degenerate. The
595/// camera basis is used only for [`ImageFacing::Billboard`] and the cull
596/// test.
597fn resolve_quad(sprite: &ImageSprite, camera: &Camera) -> Option<QuadDraw> {
598    let cam_pos = [
599        camera.pos[0] as f32,
600        camera.pos[1] as f32,
601        camera.pos[2] as f32,
602    ];
603    let cam_fwd = v_norm([
604        camera.forward[0] as f32,
605        camera.forward[1] as f32,
606        camera.forward[2] as f32,
607    ]);
608
609    let (u_hat, v_hat) = match sprite.facing {
610        ImageFacing::World { u, v } => (v_norm(u), v_norm(v)),
611        ImageFacing::Billboard { up } => {
612            // Horizontal axis ⟂ both the view direction and `up`; fall
613            // back to the camera right when `up` is parallel to the view.
614            let mut u_hat = v_norm(v_cross(up, cam_fwd));
615            if v_dot(u_hat, u_hat) < 1e-12 {
616                u_hat = v_norm([
617                    camera.right[0] as f32,
618                    camera.right[1] as f32,
619                    camera.right[2] as f32,
620                ]);
621            }
622            // Vertical axis ⟂ both, pointing *down* (rows grow downward)
623            // so the top edge ends up toward `up`.
624            let mut v_hat = v_norm(v_cross(cam_fwd, u_hat));
625            if v_dot(v_hat, up) > 0.0 {
626                v_hat = v_scale(v_hat, -1.0);
627            }
628            (u_hat, v_hat)
629        }
630    };
631
632    let du = v_scale(u_hat, sprite.size[0]);
633    let dv = v_scale(v_hat, sprite.size[1]);
634    let tl = sprite.origin;
635    let tr = v_add(tl, du);
636    let bl = v_add(tl, dv);
637    let br = v_add(tr, dv);
638
639    // Back-face cull for fixed world quads (billboards always face us).
640    if !sprite.double_sided {
641        if let ImageFacing::World { .. } = sprite.facing {
642            let normal = v_cross(du, dv);
643            // Front-facing when the quad normal points toward the camera.
644            if v_dot(normal, v_sub(cam_pos, tl)) <= 0.0 {
645                return None;
646            }
647        }
648    }
649
650    Some(QuadDraw {
651        corners: [tl, tr, bl, br],
652        image: sprite.image,
653        tint: sprite.tint,
654        depth_test: sprite.depth_test,
655        alpha_cutoff: sprite.alpha_cutoff,
656    })
657}
658
659/// Renderer-internal backend; never exposes wgpu or softbuffer types.
660/// The GPU variant owns the whole wgpu device/queue/pipelines, so
661/// it's boxed to keep the enum small.
662enum BackendImpl {
663    // Both variants boxed so the enum stays small regardless of which
664    // backend's state is larger (clippy::large_enum_variant).
665    Cpu(Box<CpuBackend>),
666    Gpu(Box<GpuBackend>),
667}
668
669/// Unified renderer over the CPU and GPU paths. See the crate docs.
670pub struct SceneRenderer {
671    inner: BackendImpl,
672    /// Handles for dynamically added sprite instances (see
673    /// [`Self::add_sprite_instance`]). Reset by [`Self::set_sprites`].
674    dyn_map: DynInstanceMap,
675    /// Handles for registered sprite models (see [`Self::add_sprite_model`]
676    /// and the models returned by [`Self::set_sprites`]). Reset by
677    /// [`Self::set_sprites`].
678    model_map: DynModelMap,
679}
680
681impl SceneRenderer {
682    /// Build a renderer for `window` — any [`raw-window-handle`]
683    /// provider (winit, SDL, GLFW, …) in an `Arc`. `size` is the
684    /// window's initial physical framebuffer size in pixels; thereafter
685    /// the host reports changes via [`Self::resize`]. Passing the size
686    /// explicitly keeps the facade decoupled from any one windowing
687    /// library's size API.
688    ///
689    /// Selects the GPU backend when `opts.want_gpu` and WGPU
690    /// initialises; otherwise the CPU backend. **Never fails** — a
691    /// missing/incompatible GPU silently yields the CPU path (the
692    /// message is logged to stderr).
693    ///
694    /// [`raw-window-handle`]: raw_window_handle
695    #[cfg(not(target_arch = "wasm32"))]
696    #[must_use]
697    pub fn new<W>(window: Arc<W>, size: (u32, u32), opts: &RenderOptions) -> Self
698    where
699        W: HasWindowHandle + HasDisplayHandle + Send + Sync + 'static,
700    {
701        if opts.want_gpu {
702            match GpuBackend::new(window.clone(), size, opts) {
703                Ok(g) => {
704                    return Self {
705                        inner: BackendImpl::Gpu(Box::new(g)),
706                        dyn_map: DynInstanceMap::default(),
707                        model_map: DynModelMap::default(),
708                    };
709                }
710                Err(e) => {
711                    eprintln!(
712                        "roxlap-render: GPU init failed ({e}); falling back to the CPU renderer",
713                    );
714                }
715            }
716        }
717        Self {
718            inner: BackendImpl::Cpu(Box::new(CpuBackend::new(window, size, opts))),
719            dyn_map: DynInstanceMap::default(),
720            model_map: DynModelMap::default(),
721        }
722    }
723
724    /// wasm/WebGPU build-time entry: build a renderer over an HTML
725    /// `canvas`. `size` is the canvas's initial framebuffer size in
726    /// pixels; the host reports later changes via [`Self::resize`].
727    ///
728    /// Async because the browser drives wgpu's adapter/device requests
729    /// through its event loop — `await` it inside a
730    /// `wasm_bindgen_futures::spawn_local` task. Selects the GPU
731    /// (WebGPU) backend when `opts.want_gpu` and WebGPU is available;
732    /// otherwise (no WebGPU, or init failed) it falls back to the CPU
733    /// opticast path presented through a WebGL2 blit on the same canvas.
734    /// **Never fails** — the message is logged to the browser console.
735    #[cfg(target_arch = "wasm32")]
736    pub async fn new_from_canvas_async(
737        canvas: web_sys::HtmlCanvasElement,
738        size: (u32, u32),
739        opts: &RenderOptions,
740    ) -> Self {
741        if opts.want_gpu {
742            // `SurfaceTarget::Canvas` moves the canvas into wgpu, so the
743            // GPU attempt gets a clone — the CPU fallback keeps the
744            // original if WebGPU init fails.
745            match GpuBackend::new_async(canvas.clone(), size, opts).await {
746                Ok(g) => {
747                    return Self {
748                        inner: BackendImpl::Gpu(Box::new(g)),
749                        dyn_map: DynInstanceMap::default(),
750                        model_map: DynModelMap::default(),
751                    };
752                }
753                Err(e) => {
754                    web_sys::console::warn_1(
755                        &format!("roxlap-render: WebGPU init failed ({e}); using the CPU renderer")
756                            .into(),
757                    );
758                }
759            }
760        }
761        Self {
762            inner: BackendImpl::Cpu(Box::new(CpuBackend::new_from_canvas(canvas, size, opts))),
763            dyn_map: DynInstanceMap::default(),
764            model_map: DynModelMap::default(),
765        }
766    }
767
768    /// Which backend was selected.
769    #[must_use]
770    pub fn backend(&self) -> Backend {
771        match self.inner {
772            BackendImpl::Cpu(_) => Backend::Cpu,
773            BackendImpl::Gpu(_) => Backend::Gpu,
774        }
775    }
776
777    /// The GPU adapter description when on the GPU backend, else
778    /// `None`.
779    #[must_use]
780    pub fn adapter_info(&self) -> Option<&str> {
781        match &self.inner {
782            BackendImpl::Gpu(g) => Some(g.adapter_info()),
783            BackendImpl::Cpu(_) => None,
784        }
785    }
786
787    /// Upload an equirectangular sky panorama (RGBA8, `w×h`) for the
788    /// GPU marcher's sky sampling. No-op on the CPU backend, which
789    /// samples the [`Sky`] passed in each [`FrameParams`] instead.
790    pub fn set_sky_panorama(&mut self, rgba: &[u8], w: u32, h: u32) {
791        if let BackendImpl::Gpu(g) = &mut self.inner {
792            g.set_sky_panorama(rgba, w, h);
793        }
794    }
795
796    /// Follow a window resize. CPU resizes its framebuffer lazily, so
797    /// this only matters to the GPU swapchain — but it's safe to call
798    /// for both.
799    pub fn resize(&mut self, width: u32, height: u32) {
800        match &mut self.inner {
801            BackendImpl::Cpu(c) => c.resize(width, height),
802            BackendImpl::Gpu(g) => g.resize(width, height),
803        }
804    }
805
806    /// Composite `scene` from `camera` with `frame` params into the
807    /// backend's frame buffer — **without presenting**. The CPU backend
808    /// fills sky + runs the opticast compositor into an owned buffer;
809    /// the GPU backend uploads/refreshes the scene, runs the compute
810    /// marcher + sprite pass, and acquires (but does not present) the
811    /// swapchain frame.
812    ///
813    /// Finish the frame with exactly one of [`present`](Self::present)
814    /// (no overlay) or [`paint_egui`](Self::paint_egui) (UI overlay).
815    /// Calling `render` again without finishing drops the pending frame.
816    pub fn render(&mut self, scene: &mut Scene, camera: &Camera, frame: &FrameParams) {
817        match &mut self.inner {
818            BackendImpl::Cpu(c) => c.render(scene, camera, frame),
819            BackendImpl::Gpu(g) => g.render(scene, camera, frame),
820        }
821    }
822
823    /// Draw world-space [`Line3`] segments over the frame
824    /// [`render`](Self::render) composited, using that frame's camera +
825    /// projection + depth buffer. Call **after** [`render`](Self::render)
826    /// and **before** [`present`](Self::present) /
827    /// [`paint_egui`](Self::paint_egui) — the lines land in the
828    /// framebuffer, so a subsequent `paint_egui` still draws its panels
829    /// on top.
830    ///
831    /// `camera` must be the one the last frame rendered with (the
832    /// projection is taken from that frame). Depth-tested segments
833    /// (`Line3::depth_test`) are occluded by nearer rendered geometry;
834    /// always-on-top segments ignore depth. See [`Line3`] for colour /
835    /// width / blend semantics.
836    pub fn draw_lines(&mut self, camera: &Camera, lines: &[Line3]) {
837        match &mut self.inner {
838            BackendImpl::Cpu(c) => c.draw_lines(camera, lines),
839            BackendImpl::Gpu(g) => g.draw_lines(camera, lines),
840        }
841    }
842
843    /// Upload (or replace) an RGBA8 image and return a stable [`ImageId`]
844    /// to reference it in [`draw_images`](Self::draw_images). `rgba` is
845    /// row-major, `width * height * 4` bytes, **straight** (un-premultiplied)
846    /// alpha. The texture is retained until [`drop_image`](Self::drop_image),
847    /// so the per-frame draw call stays cheap. Sampling is
848    /// nearest-neighbour (pixel-art friendly — no blurring).
849    ///
850    /// Returns `ImageId(0)` for malformed input (wrong byte count or a
851    /// zero dimension); such an id draws nothing.
852    pub fn upload_image(&mut self, rgba: &[u8], width: u32, height: u32) -> ImageId {
853        match &mut self.inner {
854            BackendImpl::Cpu(c) => c.upload_image(rgba, width, height),
855            BackendImpl::Gpu(g) => g.upload_image(rgba, width, height),
856        }
857    }
858
859    /// Release a texture uploaded with [`upload_image`](Self::upload_image).
860    /// The id must not be reused afterwards (a later `upload_image` may
861    /// hand the slot back out under a fresh id).
862    pub fn drop_image(&mut self, id: ImageId) {
863        match &mut self.inner {
864            BackendImpl::Cpu(c) => c.drop_image(id),
865            BackendImpl::Gpu(g) => g.drop_image(id),
866        }
867    }
868
869    /// Draw 2D [`ImageSprite`]s over the frame [`render`](Self::render)
870    /// composited — flat textured quads placed in world space, using that
871    /// frame's camera + projection + depth buffer. Same contract as
872    /// [`draw_lines`](Self::draw_lines): call **after** [`render`](Self::render)
873    /// and **before** [`present`](Self::present) / [`paint_egui`](Self::paint_egui).
874    ///
875    /// UVs are perspective-correct (no affine warp on an obliquely-viewed
876    /// quad). Depth-tested sprites are occluded by nearer rendered
877    /// geometry (with a bias to avoid z-fighting on a coincident face);
878    /// the texture's straight alpha + the [`ImageSprite::tint`] composite
879    /// over the scene. `camera` must be the one the last frame rendered.
880    pub fn draw_images(&mut self, camera: &Camera, images: &[ImageSprite]) {
881        if images.is_empty() {
882            return;
883        }
884        let quads: Vec<QuadDraw> = images
885            .iter()
886            .filter_map(|s| resolve_quad(s, camera))
887            .collect();
888        if quads.is_empty() {
889            return;
890        }
891        match &mut self.inner {
892            BackendImpl::Cpu(c) => c.draw_images(camera, &quads),
893            BackendImpl::Gpu(g) => g.draw_images(camera, &quads),
894        }
895    }
896
897    /// Project a world point to window pixel coordinates `(x, y)` under
898    /// the projection the **last frame** rendered with — the backend-correct
899    /// `world → screen` inverse of [`view_ray`](Self::view_ray). `None`
900    /// before the first frame or for a point at/behind the camera near
901    /// plane.
902    ///
903    /// Both backends honour their own projection (CPU `setcamera`
904    /// `hx/hy/hz`, GPU vertical-FOV pinhole), so hosts never reconstruct
905    /// it themselves. The returned `(x, y)` may fall outside `[0, w) ×
906    /// [0, h)` for points off-screen but in front of the camera.
907    #[must_use]
908    pub fn project_point(&self, camera: &Camera, world: [f32; 3]) -> Option<(f32, f32)> {
909        match &self.inner {
910            BackendImpl::Cpu(c) => c.project_point(camera, world),
911            BackendImpl::Gpu(g) => g.project_point(camera, world),
912        }
913    }
914
915    /// Screen→sprite pick: the nearest [`ImageSprite`] hit under window
916    /// pixel `(x, y)`, resolving which texel was clicked. `sprites` is the
917    /// same list passed to [`draw_images`](Self::draw_images) (image
918    /// sprites are immediate-mode, so the caller owns the set). `None` for
919    /// a miss.
920    ///
921    /// The ray is intersected with each quad's plane and mapped to its
922    /// `uv` / source texel. A texel whose alpha is below the sprite's
923    /// [`ImageSprite::alpha_cutoff`] (and any fully-transparent texel) is
924    /// **see-through** — the pick passes through it to a sprite behind.
925    /// For [`depth_test`](ImageSprite::depth_test) sprites the hit is
926    /// rejected when nearer scene geometry occludes that pixel (shares the
927    /// depth convention + bias of [`pick`](Self::pick); on the GPU backend
928    /// the occlusion test costs a click-time depth readback).
929    #[must_use]
930    pub fn pick_image(
931        &self,
932        camera: &Camera,
933        x: f64,
934        y: f64,
935        sprites: &[ImageSprite],
936    ) -> Option<ImagePickHit> {
937        if sprites.is_empty() {
938            return None;
939        }
940        let dir = self.pixel_ray(camera, x, y)?;
941        let dir = [dir[0] as f32, dir[1] as f32, dir[2] as f32];
942        let dir_len = v_dot(dir, dir).sqrt();
943        if dir_len < 1e-9 {
944            return None;
945        }
946        let origin = [
947            camera.pos[0] as f32,
948            camera.pos[1] as f32,
949            camera.pos[2] as f32,
950        ];
951        // Scene surface distance under this pixel (sky / no-hit → None);
952        // used to occlude depth-tested sprites. Same metric as `pick`.
953        let scene_t = self.pick_depth(x as u32, y as u32);
954
955        let mut best: Option<ImagePickHit> = None;
956        for sprite in sprites {
957            // Reuse the render-path resolve (back-face cull included), so
958            // a single-sided quad that isn't drawn also can't be picked.
959            let Some(q) = resolve_quad(sprite, camera) else {
960                continue;
961            };
962            let Some(([a, b], t)) = ray_quad_uv(origin, dir, &q.corners) else {
963                continue; // miss / parallel / behind
964            };
965            let d_eucl = t * dir_len;
966            if best.is_some_and(|cur| d_eucl >= cur.t) {
967                continue; // a nearer sprite already won
968            }
969            let p = v_add(origin, v_scale(dir, t));
970
971            let Some((iw, ih)) = self.image_dims(sprite.image) else {
972                continue; // dropped / unknown image
973            };
974            let tx = ((a * iw as f32) as i32).clamp(0, iw as i32 - 1) as u32;
975            let ty = ((b * ih as f32) as i32).clamp(0, ih as i32 - 1) as u32;
976
977            // See-through test: a texel is solid when its alpha clears the
978            // cutoff (and a fully-transparent texel is never solid).
979            let cutoff_u8 = (sprite.alpha_cutoff.clamp(0.0, 1.0) * 255.0) as u32;
980            let solid_thresh = cutoff_u8.max(1);
981            if u32::from(self.image_alpha_at(sprite.image, tx, ty)) < solid_thresh {
982                continue;
983            }
984
985            // Occlusion: a depth-tested sprite behind nearer geometry loses.
986            if sprite.depth_test {
987                if let Some(st) = scene_t {
988                    if d_eucl > st + PICK_DEPTH_BIAS {
989                        continue;
990                    }
991                }
992            }
993
994            best = Some(ImagePickHit {
995                image: sprite.image,
996                uv: [a, b],
997                texel: (tx, ty),
998                world: p,
999                t: d_eucl,
1000            });
1001        }
1002        best
1003    }
1004
1005    /// Source dimensions of an uploaded image, or `None` if the id was
1006    /// dropped / never uploaded. Internal helper for [`Self::pick_image`].
1007    fn image_dims(&self, id: ImageId) -> Option<(u32, u32)> {
1008        match &self.inner {
1009            BackendImpl::Cpu(c) => c.image_dims(id),
1010            BackendImpl::Gpu(g) => g.image_dims(id),
1011        }
1012    }
1013
1014    /// Alpha byte of texel `(tx, ty)` in an uploaded image (`0` for an
1015    /// unknown id / out-of-range texel). Internal helper for
1016    /// [`Self::pick_image`].
1017    fn image_alpha_at(&self, id: ImageId, tx: u32, ty: u32) -> u8 {
1018        match &self.inner {
1019            BackendImpl::Cpu(c) => c.image_alpha_at(id, tx, ty),
1020            BackendImpl::Gpu(g) => g.image_alpha_at(id, tx, ty),
1021        }
1022    }
1023
1024    /// Mirror the rendered 3D scene horizontally before display. The flip is
1025    /// applied *before* any egui overlay, so the UI stays upright while the
1026    /// viewport un-mirrors — a fix for the engine's left-handed render.
1027    /// Supported on both backends (CPU reverses the framebuffer rows; GPU
1028    /// mirrors the scene blit + line/image overlays). Picking/projection are
1029    /// unchanged, so a host that flips must mirror its cursor X (`width - x`)
1030    /// for ray casts.
1031    pub fn set_flip_x(&mut self, flip: bool) {
1032        match &mut self.inner {
1033            BackendImpl::Cpu(c) => c.set_flip_x(flip),
1034            BackendImpl::Gpu(g) => g.set_flip_x(flip),
1035        }
1036    }
1037
1038    /// Present the frame [`render`](Self::render) composited, with no UI
1039    /// overlay. Pairs with `render`; use [`paint_egui`](Self::paint_egui)
1040    /// instead to overlay an egui UI before presenting.
1041    pub fn present(&mut self) {
1042        match &mut self.inner {
1043            BackendImpl::Cpu(c) => c.present(),
1044            BackendImpl::Gpu(g) => g.present(),
1045        }
1046    }
1047
1048    /// Overlay an egui UI on the frame [`render`](Self::render)
1049    /// composited, then present it (`hud` feature). The host runs egui
1050    /// itself (e.g. `egui` + `egui-winit`) and passes the tessellated
1051    /// `jobs` ([`egui::Context::tessellate`]) and the per-frame
1052    /// `textures` delta from [`egui::FullOutput`]; `pixels_per_point` is
1053    /// the UI scale (`ctx.pixels_per_point()`).
1054    ///
1055    /// The GPU backend paints via `egui-wgpu`; the CPU backend
1056    /// software-rasterises the tessellation into its framebuffer. Use
1057    /// this **instead of** [`present`](Self::present) — both finish the
1058    /// frame.
1059    #[cfg(feature = "hud")]
1060    pub fn paint_egui(
1061        &mut self,
1062        jobs: &[egui::ClippedPrimitive],
1063        textures: &egui::TexturesDelta,
1064        pixels_per_point: f32,
1065    ) {
1066        match &mut self.inner {
1067            BackendImpl::Cpu(c) => c.paint_egui(jobs, textures, pixels_per_point),
1068            BackendImpl::Gpu(g) => g.paint_egui(jobs, textures, pixels_per_point),
1069        }
1070    }
1071
1072    /// Register sprite models + instances. The CPU backend builds a
1073    /// per-instance draw list; the GPU backend builds an instanced
1074    /// model registry. Call once at setup (or again to replace).
1075    pub fn set_sprites(&mut self, set: &SpriteSet) -> Vec<SpriteModelId> {
1076        match &mut self.inner {
1077            BackendImpl::Cpu(c) => c.set_sprites(set),
1078            BackendImpl::Gpu(g) => g.set_sprites(set),
1079        }
1080        // A fresh sprite set replaces the instance world, so any
1081        // previously added dynamic instances + models are gone — drop their
1082        // handles and re-seat the model slotmap with `set.models.len()`
1083        // live ids `0..n` (model index = chain id on both backends).
1084        self.dyn_map = DynInstanceMap::default();
1085        self.model_map.reset(set.models.len());
1086        (0..set.models.len() as u32)
1087            .map(|slot| SpriteModelId { slot, gen: 0 })
1088            .collect()
1089    }
1090
1091    /// Re-register one sprite model's geometry after you've edited its
1092    /// content (a carve or recolour of its `kv6`). `model` is the
1093    /// [`SpriteModelId`] handed back by [`set_sprites`](Self::set_sprites);
1094    /// `kv6` is the model's **new** geometry — the caller owns the source
1095    /// of truth (e.g. a dense carve grid the surface-only `kv6` can't
1096    /// represent) and supplies the refreshed mesh here.
1097    ///
1098    /// This is a **backend-agnostic content refresh**, not a GPU upload:
1099    /// the renderer brings its stored model up to date however its active
1100    /// backend needs to. The instance set is left untouched (an edit never
1101    /// moves or adds an instance), so on the GPU backend only that one
1102    /// model's voxel data is re-uploaded — through a slack-backed
1103    /// suballocator, one model's bytes rather than the whole registry —
1104    /// while the CPU backend swaps the cached `kv6` into each instance of
1105    /// the model. Use [`set_sprites`](Self::set_sprites) to add/remove
1106    /// models or change the instance set.
1107    pub fn refresh_sprite_model(&mut self, model: SpriteModelId, kv6: &Kv6) {
1108        let Some(idx) = self.model_map.model_index(model) else {
1109            return; // stale / removed handle → no-op
1110        };
1111        match &mut self.inner {
1112            BackendImpl::Cpu(c) => c.update_sprite_model(idx, kv6),
1113            BackendImpl::Gpu(g) => g.update_sprite_model(idx, kv6),
1114        }
1115    }
1116
1117    /// Add one sprite instance of an already-registered `model` at world
1118    /// `pos`, **incrementally** — the cheap streaming-spawn path that both
1119    /// backends now share (GPU: append to the instance buffer, growing by
1120    /// powers of two; CPU: push one pre-posed [`Sprite`]). Returns a
1121    /// stable [`SpriteInstanceId`] for later removal.
1122    ///
1123    /// `model` must be a [`SpriteModelId`] from the current
1124    /// [`set_sprites`](Self::set_sprites) (a model registered there, even
1125    /// with zero initial instances). Dynamic instances live *after* the
1126    /// static set + any KFA limbs, so register those first.
1127    pub fn add_sprite_instance(&mut self, model: SpriteModelId, pos: [f32; 3]) -> SpriteInstanceId {
1128        self.add_sprite_instance_posed(
1129            model,
1130            DynSpriteTransform {
1131                pos,
1132                ..DynSpriteTransform::default()
1133            },
1134        )
1135    }
1136
1137    /// Add one sprite instance of an already-registered `model`,
1138    /// pre-posed with the orientation in `xf` — the streaming-spawn path
1139    /// for objects that appear mid-flight already rotated (so there's no
1140    /// one-frame axis-aligned flash before the first
1141    /// [`set_sprite_instance_transform`](Self::set_sprite_instance_transform)).
1142    /// Otherwise identical to
1143    /// [`add_sprite_instance`](Self::add_sprite_instance) (which is just
1144    /// this with the identity basis). Returns a stable
1145    /// [`SpriteInstanceId`].
1146    ///
1147    /// A stale/removed `model` handle spawns nothing and returns a handle
1148    /// that is itself already stale (it resolves to no instance). `xf`'s
1149    /// basis must be non-singular; a degenerate one makes the instance
1150    /// silently skip drawing (see [`DynSpriteTransform`]).
1151    pub fn add_sprite_instance_posed(
1152        &mut self,
1153        model: SpriteModelId,
1154        xf: DynSpriteTransform,
1155    ) -> SpriteInstanceId {
1156        let Some(idx) = self.model_map.model_index(model) else {
1157            // Stale model → spawn nothing; hand back a sentinel id that
1158            // resolves to no live instance (a safe no-op everywhere).
1159            return SpriteInstanceId {
1160                slot: u32::MAX,
1161                gen: u32::MAX,
1162            };
1163        };
1164        let dyn_index = match &mut self.inner {
1165            BackendImpl::Cpu(c) => c.add_dyn_instance_posed(idx, xf),
1166            BackendImpl::Gpu(g) => g.add_dyn_instance_posed(idx, xf),
1167        };
1168        self.dyn_map.alloc(dyn_index as u32)
1169    }
1170
1171    /// Remove a dynamic sprite instance added by
1172    /// [`add_sprite_instance`](Self::add_sprite_instance). O(1) on both
1173    /// backends (swap-remove); other dynamic handles stay valid. Returns
1174    /// `false` if the handle is stale / already removed.
1175    pub fn remove_sprite_instance(&mut self, id: SpriteInstanceId) -> bool {
1176        let Some(dyn_index) = self.dyn_map.dyn_index(id) else {
1177            return false;
1178        };
1179        let moved = match &mut self.inner {
1180            BackendImpl::Cpu(c) => c.remove_dyn_instance(dyn_index as usize),
1181            BackendImpl::Gpu(g) => g.remove_dyn_instance(dyn_index as usize),
1182        };
1183        self.dyn_map.remove(id, dyn_index, moved.map(|m| m as u32));
1184        true
1185    }
1186
1187    /// Number of live dynamic sprite instances (those added via
1188    /// [`add_sprite_instance`](Self::add_sprite_instance)).
1189    #[must_use]
1190    pub fn dynamic_sprite_count(&self) -> usize {
1191        self.dyn_map.order.len()
1192    }
1193
1194    /// Register one new sprite **model** incrementally from `kv6`,
1195    /// **without** rebuilding the existing model set — the streaming-in
1196    /// counterpart to [`add_sprite_instance`](Self::add_sprite_instance)
1197    /// for unique generated geometry (procedural asteroids, debris).
1198    /// Returns a stable [`SpriteModelId`] usable immediately with
1199    /// [`add_sprite_instance`](Self::add_sprite_instance) /
1200    /// [`add_sprite_instance_posed`](Self::add_sprite_instance_posed).
1201    ///
1202    /// Works before any [`set_sprites`](Self::set_sprites) (it establishes
1203    /// residency on the GPU backend's first model). The GPU backend
1204    /// appends one LOD chain to the resident registry (amortised O(model
1205    /// voxels)); the CPU backend pushes an axis-aligned template.
1206    pub fn add_sprite_model(&mut self, kv6: &Kv6) -> SpriteModelId {
1207        let model_index = match &mut self.inner {
1208            BackendImpl::Cpu(c) => c.add_model(kv6),
1209            BackendImpl::Gpu(g) => g.add_model(kv6),
1210        };
1211        self.model_map.alloc(model_index as u32)
1212    }
1213
1214    /// Remove a registered sprite model, freeing its voxel data. Returns
1215    /// `false` if `id` is stale / already removed.
1216    ///
1217    /// The model's slot is tombstoned **in place**: its id is never
1218    /// reused, so every other [`SpriteModelId`] stays valid (no remap).
1219    /// Existing instances of the removed model are **not** dropped here —
1220    /// they linger but draw as nothing on the GPU backend (the CPU
1221    /// backend keeps each instance's own kv6 clone, so they keep drawing
1222    /// until removed via
1223    /// [`remove_sprite_instance`](Self::remove_sprite_instance)); remove
1224    /// them when convenient. Call
1225    /// [`compact_sprite_models`](Self::compact_sprite_models) afterwards
1226    /// to reclaim the GPU buffer holes.
1227    pub fn remove_sprite_model(&mut self, id: SpriteModelId) -> bool {
1228        let Some(idx) = self.model_map.model_index(id) else {
1229            return false;
1230        };
1231        match &mut self.inner {
1232            BackendImpl::Cpu(c) => c.remove_model(idx),
1233            BackendImpl::Gpu(g) => g.remove_model(idx),
1234        }
1235        self.model_map.remove(id)
1236    }
1237
1238    /// Reclaim the GPU buffer space left by
1239    /// [`remove_sprite_model`](Self::remove_sprite_model) by repacking the
1240    /// resident registry to its live models only. Model ids are preserved
1241    /// (no remap). O(live voxel volume) — call it when many models have
1242    /// been removed, not every frame. No-op on the CPU backend (which
1243    /// keeps cheap empty placeholders) and when nothing was removed.
1244    pub fn compact_sprite_models(&mut self) {
1245        match &mut self.inner {
1246            BackendImpl::Cpu(c) => c.compact_models(),
1247            BackendImpl::Gpu(g) => g.compact_models(),
1248        }
1249    }
1250
1251    /// Update one dynamic instance's full pose (position + orientation)
1252    /// for this frame. `id` is from
1253    /// [`add_sprite_instance`](Self::add_sprite_instance) /
1254    /// [`add_sprite_instance_posed`](Self::add_sprite_instance_posed). A
1255    /// stale / removed handle is a no-op.
1256    ///
1257    /// For many instances per frame prefer
1258    /// [`set_sprite_instance_transforms`](Self::set_sprite_instance_transforms):
1259    /// the GPU backend flushes all pending pose changes to the device
1260    /// once per [`render`](Self::render), so a per-instance call here is
1261    /// still O(1) device work, but the batch variant avoids re-walking
1262    /// the slotmap.
1263    pub fn set_sprite_instance_transform(&mut self, id: SpriteInstanceId, xf: DynSpriteTransform) {
1264        let Some(dyn_index) = self.dyn_map.dyn_index(id) else {
1265            return;
1266        };
1267        match &mut self.inner {
1268            BackendImpl::Cpu(c) => c.set_dyn_instance_transform(dyn_index as usize, xf),
1269            BackendImpl::Gpu(g) => g.set_dyn_instance_transform(dyn_index as usize, xf),
1270        }
1271    }
1272
1273    /// Batch form of
1274    /// [`set_sprite_instance_transform`](Self::set_sprite_instance_transform)
1275    /// — apply many `(instance, pose)` updates in one call. Stale handles
1276    /// in `updates` are skipped. On the GPU backend this marks the
1277    /// instance buffer dirty once and uploads the new poses a single time
1278    /// at the next [`render`](Self::render), so spinning a whole cluster
1279    /// of instances per frame is one device upload, not one per instance.
1280    pub fn set_sprite_instance_transforms(
1281        &mut self,
1282        updates: &[(SpriteInstanceId, DynSpriteTransform)],
1283    ) {
1284        for &(id, xf) in updates {
1285            let Some(dyn_index) = self.dyn_map.dyn_index(id) else {
1286                continue;
1287            };
1288            match &mut self.inner {
1289                BackendImpl::Cpu(c) => c.set_dyn_instance_transform(dyn_index as usize, xf),
1290                BackendImpl::Gpu(g) => g.set_dyn_instance_transform(dyn_index as usize, xf),
1291            }
1292        }
1293    }
1294
1295    /// Register animated KFA sprites (one or more bone hierarchies).
1296    /// The GPU backend uploads each limb's kv6 as an instanced model
1297    /// **once** (appended to the sprite registry) and seeds the limb
1298    /// instances at their current pose; the CPU backend caches the
1299    /// posed limbs for drawing. Call once at setup, after
1300    /// [`set_sprites`](Self::set_sprites), then drive motion per frame
1301    /// with [`update_kfa_poses`](Self::update_kfa_poses).
1302    ///
1303    /// Limbs are posed from the sprites' current
1304    /// [`kfaval`](roxlap_formats::kfa::KfaSprite::kfaval) (advance
1305    /// [`animsprite`](roxlap_formats::kfa::KfaSprite::animsprite) first
1306    /// if using a baked curve), so `kfas` is taken `&mut`.
1307    pub fn set_kfa_sprites(&mut self, kfas: &mut [KfaSprite]) {
1308        match &mut self.inner {
1309            BackendImpl::Cpu(c) => c.set_kfa_sprites(kfas),
1310            BackendImpl::Gpu(g) => g.set_kfa_sprites(kfas),
1311        }
1312    }
1313
1314    /// Re-pose the registered KFA sprites from their current
1315    /// `kfaval[]`. Call each frame after advancing the animation
1316    /// (`kfa.animsprite(dt_ms)` or poking `kfaval[]`). The GPU backend
1317    /// takes the cheap transform-only update (no model-volume
1318    /// re-upload); the CPU backend re-solves limb transforms for the
1319    /// next [`render`](Self::render). Must follow a
1320    /// [`set_kfa_sprites`](Self::set_kfa_sprites) with the same sprites.
1321    pub fn update_kfa_poses(&mut self, kfas: &mut [KfaSprite]) {
1322        match &mut self.inner {
1323            BackendImpl::Cpu(c) => c.update_kfa_poses(kfas),
1324            BackendImpl::Gpu(g) => g.update_kfa_poses(kfas),
1325        }
1326    }
1327
1328    /// Carve the next z-layer off the [`SpriteSet::carve_model`] and
1329    /// re-upload (the demo's `G` hotkey + GPU.12 copy-on-modify). GPU
1330    /// only; a no-op on the CPU backend. Returns the voxels removed.
1331    pub fn carve_active_sprite(&mut self) -> u32 {
1332        match &mut self.inner {
1333            BackendImpl::Cpu(_) => 0,
1334            BackendImpl::Gpu(g) => g.carve_active_sprite(),
1335        }
1336    }
1337
1338    /// Request that the next [`render`](Self::render) capture its
1339    /// framebuffer for [`take_capture`](Self::take_capture). CPU only
1340    /// (the GPU swapchain isn't read back) — a no-op on GPU.
1341    pub fn request_capture(&mut self) {
1342        if let BackendImpl::Cpu(c) = &mut self.inner {
1343            c.request_capture();
1344        }
1345    }
1346
1347    /// Take the most recently captured frame as packed `0x00RRGGBB`
1348    /// pixels + dimensions, or `None` if no capture is ready / GPU.
1349    pub fn take_capture(&mut self) -> Option<(Vec<u32>, u32, u32)> {
1350        match &mut self.inner {
1351            BackendImpl::Cpu(c) => c.take_capture(),
1352            BackendImpl::Gpu(_) => None,
1353        }
1354    }
1355
1356    /// Screen→world picking input: the world-space hit distance `t` at
1357    /// window pixel `(x, y)` from the **last rendered frame**, or `None`
1358    /// for out-of-bounds pixels and sky / no-hit. The host reconstructs
1359    /// the world hit point as `cam.pos + t * normalize(ray_dir)`, where
1360    /// `ray_dir` is the same per-pixel ray the frame was rendered with
1361    /// (see the backend's projection).
1362    ///
1363    /// `t` is the distance to the nearest **scene-grid** surface
1364    /// (terrain + grids); sprites do not occlude it (the sprite pass
1365    /// reads depth read-only), so a cursor sprite under the pointer is
1366    /// transparent to the pick.
1367    ///
1368    /// Cost: the CPU backend reads its in-memory z-buffer (free); the
1369    /// GPU backend stages the depth buffer and blocks on a device poll
1370    /// (cheap at click time — do not call every frame). The GPU path
1371    /// only has depth when the last frame drew sprites (`write_depth`).
1372    #[must_use]
1373    pub fn pick_depth(&self, x: u32, y: u32) -> Option<f32> {
1374        match &self.inner {
1375            BackendImpl::Cpu(c) => c.pick_depth(x, y),
1376            BackendImpl::Gpu(g) => g.pick_depth(x, y),
1377        }
1378    }
1379
1380    /// World-space view-ray direction (un-normalised) for window pixel
1381    /// `(x, y)`, under the projection the **last frame** rendered with.
1382    /// The backends differ (CPU `setcamera` vs GPU vertical-FOV
1383    /// pinhole), so this hides which one is active. `None` before the
1384    /// first frame. Intersect it with a plane for tile picking, or feed
1385    /// it to [`Self::pick`] for a voxel.
1386    #[must_use]
1387    pub fn pixel_ray(&self, camera: &Camera, x: f64, y: f64) -> Option<[f64; 3]> {
1388        match &self.inner {
1389            BackendImpl::Cpu(c) => c.pixel_ray(camera, x, y),
1390            BackendImpl::Gpu(g) => g.pixel_ray(camera, x, y),
1391        }
1392    }
1393
1394    /// Canonical screen→world unproject: the full view [`Ray`]
1395    /// (`camera.pos` origin + unit direction) for window pixel
1396    /// `(x, y)`, under whichever projection the last frame used. The
1397    /// one entry point both backends honour — hosts never reconstruct
1398    /// the projection. `None` before the first frame or for a
1399    /// degenerate ray.
1400    ///
1401    /// Compose with [`roxlap_scene::Scene::raycast`] for depth-free
1402    /// picking that's identical on CPU and GPU:
1403    /// `renderer.view_ray(cam, x, y).and_then(|r| scene.raycast(r.origin, r.dir, max))`.
1404    #[must_use]
1405    pub fn view_ray(&self, camera: &Camera, x: f64, y: f64) -> Option<Ray> {
1406        let d = self.pixel_ray(camera, x, y)?;
1407        let len = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt();
1408        if len < 1e-12 {
1409            return None;
1410        }
1411        Some(Ray {
1412            origin: glam::DVec3::from_array([camera.pos[0], camera.pos[1], camera.pos[2]]),
1413            dir: glam::DVec3::new(d[0] / len, d[1] / len, d[2] / len),
1414        })
1415    }
1416
1417    /// One-call screen→world voxel pick: unproject pixel `(x, y)` with
1418    /// the active backend's projection, read the last frame's depth
1419    /// there, reconstruct the world hit, and resolve it to the owning
1420    /// grid + grid-local voxel via [`Scene::resolve_voxel`]. `None` on
1421    /// sky / no-hit, or when no grid claims the surface.
1422    ///
1423    /// `scene` and `camera` must be the ones the last frame rendered;
1424    /// the projection (size + FOV / `hx,hy,hz`) is taken from that
1425    /// frame. Cheap on CPU (in-memory z-buffer); on GPU it stages the
1426    /// depth buffer (a click-time device poll — not per frame).
1427    #[must_use]
1428    pub fn pick(&self, scene: &Scene, camera: &Camera, x: u32, y: u32) -> Option<PickHit> {
1429        let dir = self.pixel_ray(camera, f64::from(x), f64::from(y))?;
1430        let t = f64::from(self.pick_depth(x, y)?);
1431        let len = (dir[0] * dir[0] + dir[1] * dir[1] + dir[2] * dir[2]).sqrt();
1432        if len < 1e-9 {
1433            return None;
1434        }
1435        let s = t / len; // world = cam.pos + t · (dir / |dir|)
1436        let world = glam::DVec3::new(
1437            camera.pos[0] + dir[0] * s,
1438            camera.pos[1] + dir[1] * s,
1439            camera.pos[2] + dir[2] * s,
1440        );
1441        let (grid, voxel) = scene.resolve_voxel(world, glam::DVec3::from_array(dir))?;
1442        #[allow(clippy::cast_possible_truncation)]
1443        let world_f32 = [world.x as f32, world.y as f32, world.z as f32];
1444        Some(PickHit {
1445            world: world_f32,
1446            grid,
1447            voxel,
1448        })
1449    }
1450}
1451
1452#[cfg(test)]
1453mod tests {
1454    use super::*;
1455
1456    /// The handle map must survive the backends' swap-remove indexing:
1457    /// drive a model `DynInstanceMap` against a `Vec` "backend" that
1458    /// swap-removes, and check every live handle keeps resolving to its
1459    /// own payload through a sequence of adds + removes.
1460    #[test]
1461    fn dyn_instance_map_survives_swap_removes() {
1462        let mut map = DynInstanceMap::default();
1463        // The "backend": payload per dynamic index; swap_remove mirrors
1464        // both backends' remove_dyn_instance.
1465        let mut backend: Vec<u32> = Vec::new();
1466        // Our bookkeeping: handle -> the payload we expect it to address.
1467        let mut expect: Vec<(SpriteInstanceId, u32)> = Vec::new();
1468
1469        let add = |map: &mut DynInstanceMap,
1470                   backend: &mut Vec<u32>,
1471                   expect: &mut Vec<(SpriteInstanceId, u32)>,
1472                   payload: u32| {
1473            let dyn_index = backend.len() as u32;
1474            backend.push(payload);
1475            let id = map.alloc(dyn_index);
1476            expect.push((id, payload));
1477        };
1478
1479        for p in 0..6 {
1480            add(&mut map, &mut backend, &mut expect, p);
1481        }
1482
1483        // Remove a middle handle (payload 2) and a later one (payload 4),
1484        // plus the current last — covering swap and no-swap paths.
1485        for victim_payload in [2u32, 4, 5] {
1486            let pos = expect
1487                .iter()
1488                .position(|&(_, p)| p == victim_payload)
1489                .unwrap();
1490            let (id, _) = expect.remove(pos);
1491            let dyn_index = map.dyn_index(id).expect("live handle resolves");
1492            // Backend swap-remove + report moved index (old last), exactly
1493            // like remove_dyn_instance on both backends.
1494            let last = backend.len() - 1;
1495            backend.swap_remove(dyn_index as usize);
1496            let moved = (dyn_index as usize != last).then_some(last as u32);
1497            map.remove(id, dyn_index, moved);
1498            // The removed handle is now stale.
1499            assert!(map.dyn_index(id).is_none(), "removed handle is stale");
1500        }
1501
1502        // Every surviving handle still resolves to its own payload.
1503        for &(id, payload) in &expect {
1504            let idx = map.dyn_index(id).expect("survivor resolves");
1505            assert_eq!(
1506                backend[idx as usize], payload,
1507                "handle addresses its payload"
1508            );
1509        }
1510        assert_eq!(map.order.len(), backend.len());
1511        assert_eq!(backend.len(), expect.len());
1512    }
1513
1514    /// The model slotmap mints stable ids, resolves only live handles,
1515    /// and never reuses a slot — so a removed model's id stays dead and
1516    /// every other id survives the remove.
1517    #[test]
1518    fn dyn_model_map_lifecycle() {
1519        let mut map = DynModelMap::default();
1520        // `set_sprites(3 models)` seeds ids 0..3, all live.
1521        map.reset(3);
1522        let ids: Vec<SpriteModelId> = (0..3).map(|s| SpriteModelId { slot: s, gen: 0 }).collect();
1523        for (i, &id) in ids.iter().enumerate() {
1524            assert_eq!(map.model_index(id), Some(i));
1525        }
1526
1527        // Incrementally add a fourth model.
1528        let extra = map.alloc(3);
1529        assert_eq!(extra, SpriteModelId { slot: 3, gen: 0 });
1530        assert_eq!(map.model_index(extra), Some(3));
1531
1532        // Remove model 1: its handle goes stale, the rest stay valid.
1533        assert!(map.remove(ids[1]));
1534        assert_eq!(map.model_index(ids[1]), None);
1535        assert_eq!(map.model_index(ids[0]), Some(0));
1536        assert_eq!(map.model_index(ids[2]), Some(2));
1537        assert_eq!(map.model_index(extra), Some(3));
1538
1539        // Double remove / stale removal is a no-op returning false.
1540        assert!(!map.remove(ids[1]));
1541
1542        // A bogus / out-of-range handle resolves to nothing, no panic.
1543        let bogus = SpriteModelId { slot: 999, gen: 0 };
1544        assert_eq!(map.model_index(bogus), None);
1545        assert!(!map.remove(bogus));
1546
1547        // A handle with a mismatched generation never resolves (guards a
1548        // future compacting registry).
1549        let wrong_gen = SpriteModelId { slot: 0, gen: 7 };
1550        assert_eq!(map.model_index(wrong_gen), None);
1551    }
1552
1553    #[test]
1554    fn dyn_sprite_transform_default_is_identity_and_applies() {
1555        let xf = DynSpriteTransform::default();
1556        assert_eq!(xf.pos, [0.0, 0.0, 0.0]);
1557        assert_eq!(xf.right, [1.0, 0.0, 0.0]);
1558        assert_eq!(xf.up, [0.0, 1.0, 0.0]);
1559        assert_eq!(xf.forward, [0.0, 0.0, 1.0]);
1560
1561        let mut s = Sprite::axis_aligned(
1562            roxlap_formats::kv6::Kv6::solid_cube(2, 0x80_FF_FF_FF),
1563            [9.0, 9.0, 9.0],
1564        );
1565        let posed = DynSpriteTransform {
1566            pos: [1.0, 2.0, 3.0],
1567            right: [0.0, 0.0, 1.0],
1568            up: [0.0, 1.0, 0.0],
1569            forward: [1.0, 0.0, 0.0],
1570        };
1571        posed.apply_to(&mut s);
1572        assert_eq!(s.p, [1.0, 2.0, 3.0]);
1573        assert_eq!(s.s, [0.0, 0.0, 1.0]);
1574        assert_eq!(s.h, [0.0, 1.0, 0.0]);
1575        assert_eq!(s.f, [1.0, 0.0, 0.0]);
1576    }
1577
1578    #[test]
1579    fn options_default_is_cpu_intent() {
1580        let o = RenderOptions::default();
1581        assert!(!o.want_gpu);
1582        assert_eq!(o.clear_sky & 0xFF00_0000, 0, "clear_sky is 0x00RRGGBB");
1583    }
1584
1585    /// A camera at the origin looking down +Y (voxlap z-down world): right
1586    /// = +X, down = +Z, forward = +Y. Handedness `right × down == forward`.
1587    fn cam_looking_y() -> Camera {
1588        Camera {
1589            pos: [0.0, 0.0, 0.0],
1590            right: [1.0, 0.0, 0.0],
1591            down: [0.0, 0.0, 1.0],
1592            forward: [0.0, 1.0, 0.0],
1593        }
1594    }
1595
1596    #[test]
1597    fn world_quad_corner_layout() {
1598        // Top-left at (-5, 10, -5); u = +X (width), v = +Z (down). A
1599        // 10×10 quad facing the camera (its +Y normal points back at us).
1600        let sprite = ImageSprite {
1601            image: ImageId(0),
1602            origin: [-5.0, 10.0, -5.0],
1603            facing: ImageFacing::World {
1604                u: [1.0, 0.0, 0.0],
1605                v: [0.0, 0.0, 1.0],
1606            },
1607            size: [10.0, 10.0],
1608            tint: 0xFFFF_FFFF,
1609            alpha_cutoff: 0.0,
1610            depth_test: true,
1611            double_sided: true,
1612        };
1613        let q = resolve_quad(&sprite, &cam_looking_y()).expect("front-facing");
1614        assert_eq!(q.corners[0], [-5.0, 10.0, -5.0], "TL = origin");
1615        assert_eq!(q.corners[1], [5.0, 10.0, -5.0], "TR = origin + u·size");
1616        assert_eq!(q.corners[2], [-5.0, 10.0, 5.0], "BL = origin + v·size");
1617        assert_eq!(q.corners[3], [5.0, 10.0, 5.0], "BR = origin + u + v");
1618    }
1619
1620    #[test]
1621    fn world_quad_backface_culls_when_single_sided() {
1622        // Same plane but spanned so its normal (u × v) points *away* from
1623        // the camera: swap u/v so the winding flips.
1624        let sprite = ImageSprite {
1625            image: ImageId(0),
1626            origin: [-5.0, 10.0, -5.0],
1627            facing: ImageFacing::World {
1628                u: [0.0, 0.0, 1.0], // v-ish
1629                v: [1.0, 0.0, 0.0], // u-ish → normal flips to -Y... toward camera?
1630            },
1631            size: [10.0, 10.0],
1632            tint: 0xFFFF_FFFF,
1633            alpha_cutoff: 0.0,
1634            depth_test: true,
1635            double_sided: false,
1636        };
1637        // With double_sided=false one of the two windings must cull; the
1638        // opposite winding must draw. Exactly one of the two resolves.
1639        let a = resolve_quad(&sprite, &cam_looking_y()).is_some();
1640        let mut flipped = sprite;
1641        flipped.facing = ImageFacing::World {
1642            u: [1.0, 0.0, 0.0],
1643            v: [0.0, 0.0, 1.0],
1644        };
1645        let b = resolve_quad(&flipped, &cam_looking_y()).is_some();
1646        assert!(a ^ b, "exactly one winding is front-facing");
1647    }
1648
1649    #[test]
1650    fn double_sided_never_culls() {
1651        let mut sprite = ImageSprite {
1652            image: ImageId(0),
1653            origin: [-5.0, 10.0, -5.0],
1654            facing: ImageFacing::World {
1655                u: [0.0, 0.0, 1.0],
1656                v: [1.0, 0.0, 0.0],
1657            },
1658            size: [10.0, 10.0],
1659            tint: 0xFFFF_FFFF,
1660            alpha_cutoff: 0.0,
1661            depth_test: true,
1662            double_sided: true,
1663        };
1664        assert!(resolve_quad(&sprite, &cam_looking_y()).is_some());
1665        sprite.facing = ImageFacing::World {
1666            u: [1.0, 0.0, 0.0],
1667            v: [0.0, 0.0, 1.0],
1668        };
1669        assert!(resolve_quad(&sprite, &cam_looking_y()).is_some());
1670    }
1671
1672    #[test]
1673    fn ray_quad_uv_center_and_corners() {
1674        // 10×10 quad on the y=10 plane: TL(-5,10,-5) u=+X v=+Z. Camera at
1675        // origin looking +Y. A ray straight at the quad centre → uv (.5,.5).
1676        let corners = [
1677            [-5.0, 10.0, -5.0], // TL
1678            [5.0, 10.0, -5.0],  // TR
1679            [-5.0, 10.0, 5.0],  // BL
1680            [5.0, 10.0, 5.0],   // BR
1681        ];
1682        let (uv, t) = ray_quad_uv([0.0, 0.0, 0.0], [0.0, 1.0, 0.0], &corners).expect("center hit");
1683        assert!(
1684            (uv[0] - 0.5).abs() < 1e-5 && (uv[1] - 0.5).abs() < 1e-5,
1685            "centre → (.5,.5)"
1686        );
1687        assert!((t - 10.0).abs() < 1e-4, "t = plane distance");
1688        // Ray toward the TL corner texel region (−x, +y, −z) → uv near (0,0).
1689        let (uv_tl, _) = ray_quad_uv([0.0, 0.0, 0.0], [-4.0, 10.0, -4.0], &corners).unwrap();
1690        assert!(uv_tl[0] < 0.2 && uv_tl[1] < 0.2, "toward TL → small uv");
1691    }
1692
1693    #[test]
1694    fn ray_quad_uv_misses_outside_and_behind() {
1695        let corners = [
1696            [-5.0, 10.0, -5.0],
1697            [5.0, 10.0, -5.0],
1698            [-5.0, 10.0, 5.0],
1699            [5.0, 10.0, 5.0],
1700        ];
1701        // Ray pointing away (−Y) never reaches the +Y plane in front.
1702        assert!(ray_quad_uv([0.0, 0.0, 0.0], [0.0, -1.0, 0.0], &corners).is_none());
1703        // Ray parallel to the quad plane (in +X) → no intersection.
1704        assert!(ray_quad_uv([0.0, 0.0, 0.0], [1.0, 0.0, 0.0], &corners).is_none());
1705        // Ray hitting the plane far outside the quad → outside uv.
1706        assert!(ray_quad_uv([100.0, 0.0, 0.0], [0.0, 1.0, 0.0], &corners).is_none());
1707    }
1708
1709    #[test]
1710    fn billboard_axes_orthogonal_and_top_toward_up() {
1711        // World up = -Z (z-down world). The billboard's v (top→bottom)
1712        // must point away from `up`, and u/v must be ⟂ the view direction.
1713        let up = [0.0, 0.0, -1.0];
1714        let sprite = ImageSprite {
1715            image: ImageId(0),
1716            origin: [0.0, 50.0, 0.0],
1717            facing: ImageFacing::Billboard { up },
1718            size: [4.0, 4.0],
1719            tint: 0xFFFF_FFFF,
1720            alpha_cutoff: 0.0,
1721            depth_test: false,
1722            double_sided: false, // billboards must NEVER cull
1723        };
1724        let q = resolve_quad(&sprite, &cam_looking_y()).expect("billboard always faces camera");
1725        let u = v_sub(q.corners[1], q.corners[0]); // TR - TL = u·size
1726        let v = v_sub(q.corners[2], q.corners[0]); // BL - TL = v·size
1727        let fwd = [0.0, 1.0, 0.0];
1728        assert!(v_dot(u, fwd).abs() < 1e-5, "u ⟂ view");
1729        assert!(v_dot(v, fwd).abs() < 1e-5, "v ⟂ view");
1730        assert!(v_dot(u, v).abs() < 1e-5, "u ⟂ v");
1731        assert!(
1732            v_dot(v, up) < 0.0,
1733            "rows grow away from `up` (top edge toward up)"
1734        );
1735    }
1736}