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/// Dynamic lighting types (stage DL) — GPU-only sun + point lights.
39mod light;
40
41#[cfg(not(target_arch = "wasm32"))]
42use std::sync::Arc;
43
44use roxlap_core::kfa_draw::{compose_attachment, solve_kfa_limbs};
45use roxlap_core::opticast::OpticastSettings;
46use roxlap_core::sky::Sky;
47use roxlap_core::Camera;
48use roxlap_formats::voxel_clip::frame_at;
49use roxlap_scene::Scene;
50
51pub use light::{DirectionalLight, LightRig, PointLight};
52pub use roxlap_formats::character::{Attachment, Character, MeshRef};
53pub use roxlap_formats::kfa::KfaSprite;
54pub use roxlap_formats::kv6::Kv6;
55pub use roxlap_formats::material::{BlendMode, Material};
56pub use roxlap_formats::sprite::Sprite;
57pub use roxlap_formats::voxel_clip::{
58    DecodeError, DecodedClip, LoopMode, StreamingClip, VoxelClip, VoxelFrame,
59};
60pub use roxlap_gpu::{GpuInitError, GpuRendererSettings, PowerPreference};
61// Re-exported so hosts can name the [`SceneRenderer::new`] bounds
62// without adding a direct `raw-window-handle` dependency of their own.
63pub use raw_window_handle::{HasDisplayHandle, HasWindowHandle};
64// Re-exported so hosts feed [`SceneRenderer::paint_egui`] from the exact
65// egui version the renderer was built against (`hud` feature).
66#[cfg(feature = "hud")]
67pub use egui;
68
69use crate::cpu::CpuBackend;
70use crate::gpu::GpuBackend;
71
72/// Type-erased display handle stored by the CPU backend's softbuffer
73/// surface. `raw-window-handle` implements `HasDisplayHandle` for
74/// `Arc<H>` (`H: ?Sized`), and the bare trait object implements its
75/// own object-safe trait — so `Arc<W>` coerces to `Arc<DynDisplay>`
76/// for any provider `W`.
77#[cfg(not(target_arch = "wasm32"))]
78pub(crate) type DynDisplay = dyn HasDisplayHandle + Send + Sync + 'static;
79/// Type-erased window handle counterpart to [`DynDisplay`].
80#[cfg(not(target_arch = "wasm32"))]
81pub(crate) type DynWindow = dyn HasWindowHandle + Send + Sync + 'static;
82
83/// One placed sprite instance: which [`SpriteSet::models`] entry and
84/// where in the world.
85pub struct SpriteInstanceDesc {
86    pub model: usize,
87    pub pos: [f32; 3],
88}
89
90/// Stable handle to a registered sprite model, returned (one per
91/// [`SpriteSet::models`] entry, in order) by
92/// [`SceneRenderer::set_sprites`]. Pass it to
93/// [`refresh_sprite_model`](SceneRenderer::refresh_sprite_model) to
94/// re-register that model's geometry after a content edit — so callers
95/// never track the positional `usize` index themselves. Opaque on
96/// purpose: there is no arithmetic to do on it.
97///
98/// Also returned by [`SceneRenderer::add_sprite_model`] for an
99/// incrementally registered model, and accepted by
100/// [`remove_sprite_model`](SceneRenderer::remove_sprite_model). A handle
101/// to a removed model is **stale**: it resolves to nothing, so passing
102/// it anywhere is a safe no-op. The `gen` (generation) field guards a
103/// future compacting registry; it stays `0` today because model slots
104/// are tombstoned in place and never reused (GPU chain ids are
105/// append-only).
106#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
107pub struct SpriteModelId {
108    pub(crate) slot: u32,
109    pub(crate) gen: u32,
110}
111
112/// Stable handle to a **dynamically added** sprite instance — the result
113/// of [`SceneRenderer::add_sprite_instance`], passed to
114/// [`remove_sprite_instance`](SceneRenderer::remove_sprite_instance).
115///
116/// Backends remove instances by swap (O(1)), which moves another instance
117/// into the freed slot; this handle survives that because the facade keeps
118/// the id↔slot mapping up to date. The generation guards against a stale
119/// handle aliasing a recycled slot.
120#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
121pub struct SpriteInstanceId {
122    slot: u32,
123    gen: u32,
124}
125
126/// Facade-side slotmap that turns the backends' swap-remove indexing into
127/// stable [`SpriteInstanceId`] handles. Both backends keep their dynamic
128/// instances as a tail sublist indexed `0..n`; `order[dyn_index]` is the
129/// owning slot, and a removal fixes up the one slot whose instance was
130/// swapped into the hole.
131#[derive(Default)]
132struct DynInstanceMap {
133    /// Per slot: `(generation, Some(dyn_index) while live)`.
134    slots: Vec<(u32, Option<u32>)>,
135    /// Per live `dyn_index`: the owning slot. Parallel to the backends'
136    /// dynamic sublist (so `order.len()` == the dynamic instance count).
137    order: Vec<u32>,
138    free: Vec<u32>,
139}
140
141impl DynInstanceMap {
142    /// Register a freshly appended instance (always at `dyn_index ==
143    /// order.len()`); returns its stable handle.
144    fn alloc(&mut self, dyn_index: u32) -> SpriteInstanceId {
145        debug_assert_eq!(self.order.len() as u32, dyn_index);
146        let slot = self.free.pop().unwrap_or_else(|| {
147            self.slots.push((0, None));
148            (self.slots.len() - 1) as u32
149        });
150        let gen = self.slots[slot as usize].0;
151        self.slots[slot as usize].1 = Some(dyn_index);
152        self.order.push(slot);
153        SpriteInstanceId { slot, gen }
154    }
155
156    /// Resolve a handle to its current backend `dyn_index`, or `None` if
157    /// it's stale / already removed.
158    fn dyn_index(&self, id: SpriteInstanceId) -> Option<u32> {
159        let (gen, idx) = *self.slots.get(id.slot as usize)?;
160        (gen == id.gen).then_some(idx).flatten()
161    }
162
163    /// Apply a removal: the backend swap-removed `removed` and reported
164    /// `moved` (the old-last `dyn_index` that slid into `removed`, or
165    /// `None` if `removed` was itself the last).
166    fn remove(&mut self, id: SpriteInstanceId, removed: u32, moved: Option<u32>) {
167        self.slots[id.slot as usize].1 = None;
168        self.slots[id.slot as usize].0 += 1; // bump generation
169        self.free.push(id.slot);
170        if let Some(last) = moved {
171            let moved_slot = self.order[last as usize];
172            self.slots[moved_slot as usize].1 = Some(removed);
173            self.order[removed as usize] = moved_slot;
174        }
175        self.order.pop();
176    }
177}
178
179/// Facade-side slotmap for registered sprite **models**, mirroring
180/// [`DynInstanceMap`] but **without** the swap-remove fixup: a model
181/// slot maps 1:1 to the backends' positional model index (the GPU LOD
182/// chain id), which is append-only and never reused. A removed model
183/// tombstones its slot *in place* (the backend frees the voxel data but
184/// keeps the id), so a stale [`SpriteModelId`] resolves to `None` → a
185/// safe no-op rather than aliasing another model.
186#[derive(Default)]
187struct DynModelMap {
188    /// Per slot (== backend model index): `(generation, live)`. Slots are
189    /// never reused, so `generation` stays `0`; `live` flips to `false`
190    /// on removal.
191    slots: Vec<(u32, bool)>,
192}
193
194impl DynModelMap {
195    /// Reset to `n` live models with ids `0..n` — used by
196    /// [`SceneRenderer::set_sprites`], which rebuilds the whole model set
197    /// positionally (model index = chain id on both backends).
198    fn reset(&mut self, n: usize) {
199        self.slots.clear();
200        self.slots.resize(n, (0, true));
201    }
202
203    /// Register a freshly appended model at positional index
204    /// `model_index` (always the new `slots.len()`); returns its handle.
205    fn alloc(&mut self, model_index: u32) -> SpriteModelId {
206        debug_assert_eq!(self.slots.len() as u32, model_index);
207        self.slots.push((0, true));
208        SpriteModelId {
209            slot: model_index,
210            gen: 0,
211        }
212    }
213
214    /// Resolve a handle to its backend model index, or `None` if it's
215    /// stale / already removed.
216    fn model_index(&self, id: SpriteModelId) -> Option<usize> {
217        let (gen, live) = *self.slots.get(id.slot as usize)?;
218        (gen == id.gen && live).then_some(id.slot as usize)
219    }
220
221    /// Tombstone a model slot in place. Returns `false` if the handle is
222    /// stale / already removed.
223    fn remove(&mut self, id: SpriteModelId) -> bool {
224        let Some(slot) = self.slots.get_mut(id.slot as usize) else {
225            return false;
226        };
227        if slot.0 != id.gen || !slot.1 {
228            return false;
229        }
230        slot.1 = false;
231        true
232    }
233}
234
235/// Stable handle to a registered animated voxel clip (VCL.4) — the
236/// result of [`SceneRenderer::add_voxel_clip`], passed to
237/// [`add_clip_instance_posed`](SceneRenderer::add_clip_instance_posed)
238/// and [`remove_voxel_clip`](SceneRenderer::remove_voxel_clip). Like
239/// [`SpriteModelId`], a removed clip's handle is stale → a safe no-op.
240/// Reset by [`set_sprites`](SceneRenderer::set_sprites) (which drops the
241/// dynamic + clip layers).
242#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
243pub struct VoxelClipId {
244    slot: u32,
245    gen: u32,
246}
247
248/// Facade-side slotmap for registered voxel clips — mirrors
249/// [`DynModelMap`]: a clip slot maps 1:1 to the backends' positional clip
250/// index (append-only, tombstoned in place on removal, never reused).
251///
252/// `reset` clears the slots **and bumps `epoch`**, which is baked into each
253/// minted id's `gen`. A handle from before a `set_sprites` therefore carries
254/// the old epoch and resolves to `None` rather than silently aliasing the
255/// new clip that re-took its slot.
256#[derive(Default)]
257struct DynClipMap {
258    /// Per slot: `(epoch_at_alloc, live)`.
259    slots: Vec<(u32, bool)>,
260    epoch: u32,
261}
262
263impl DynClipMap {
264    fn alloc(&mut self, clip_index: u32) -> VoxelClipId {
265        debug_assert_eq!(self.slots.len() as u32, clip_index);
266        self.slots.push((self.epoch, true));
267        VoxelClipId {
268            slot: clip_index,
269            gen: self.epoch,
270        }
271    }
272
273    fn clip_index(&self, id: VoxelClipId) -> Option<usize> {
274        let (gen, live) = *self.slots.get(id.slot as usize)?;
275        (gen == id.gen && live).then_some(id.slot as usize)
276    }
277
278    fn remove(&mut self, id: VoxelClipId) -> bool {
279        let Some(slot) = self.slots.get_mut(id.slot as usize) else {
280            return false;
281        };
282        if slot.0 != id.gen || !slot.1 {
283            return false;
284        }
285        slot.1 = false;
286        true
287    }
288
289    fn reset(&mut self) {
290        self.slots.clear();
291        self.epoch = self.epoch.wrapping_add(1);
292    }
293}
294
295/// Stable handle to a registered animated character (VCL.6) — the result
296/// of [`SceneRenderer::add_character`], advanced each frame with
297/// [`advance_character`](SceneRenderer::advance_character) and dropped with
298/// [`remove_character`](SceneRenderer::remove_character). Reset by
299/// [`set_sprites`](SceneRenderer::set_sprites).
300#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
301pub struct CharacterId {
302    slot: u32,
303    gen: u32,
304}
305
306/// Facade-side slotmap for registered characters (mirrors [`DynClipMap`],
307/// including the epoch bump on `reset` so a pre-`set_sprites` handle
308/// resolves to `None` instead of aliasing a new character).
309#[derive(Default)]
310struct CharMap {
311    /// Per slot: `(epoch_at_alloc, live)`.
312    slots: Vec<(u32, bool)>,
313    epoch: u32,
314}
315
316impl CharMap {
317    fn alloc(&mut self, index: u32) -> CharacterId {
318        debug_assert_eq!(self.slots.len() as u32, index);
319        self.slots.push((self.epoch, true));
320        CharacterId {
321            slot: index,
322            gen: self.epoch,
323        }
324    }
325    fn index(&self, id: CharacterId) -> Option<usize> {
326        let (gen, live) = *self.slots.get(id.slot as usize)?;
327        (gen == id.gen && live).then_some(id.slot as usize)
328    }
329    fn remove(&mut self, id: CharacterId) -> bool {
330        let Some(slot) = self.slots.get_mut(id.slot as usize) else {
331            return false;
332        };
333        if slot.0 != id.gen || !slot.1 {
334            return false;
335        }
336        slot.1 = false;
337        true
338    }
339    fn reset(&mut self) {
340        self.slots.clear();
341        self.epoch = self.epoch.wrapping_add(1);
342    }
343}
344
345/// Stable handle to a registered **streaming** voxel clip (follow-up #3) —
346/// the result of [`SceneRenderer::add_streaming_clip`], advanced with
347/// [`set_streaming_clip_frame`](SceneRenderer::set_streaming_clip_frame) and
348/// dropped with
349/// [`remove_streaming_clip`](SceneRenderer::remove_streaming_clip). Reset by
350/// [`set_sprites`](SceneRenderer::set_sprites).
351#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
352pub struct StreamingClipId {
353    slot: u32,
354    gen: u32,
355}
356
357/// Handle to an instance of a streaming clip
358/// ([`add_streaming_clip_instance`](SceneRenderer::add_streaming_clip_instance)).
359///
360/// Deliberately **distinct** from [`SpriteInstanceId`]: a streaming clip's
361/// frame is per-*clip* (all its instances share one re-uploaded model,
362/// advanced by
363/// [`set_streaming_clip_frame`](SceneRenderer::set_streaming_clip_frame)), so
364/// a streaming instance is *not* accepted by the per-instance
365/// [`set_clip_instance_frame`](SceneRenderer::set_clip_instance_frame) —
366/// trying to scrub two instances of one streaming clip independently is a
367/// compile error, not a silent coupling. (Use a flipbook clip for
368/// per-instance frames.) Move it with
369/// [`set_streaming_instance_transform`](SceneRenderer::set_streaming_instance_transform)
370/// and drop it with
371/// [`remove_streaming_instance`](SceneRenderer::remove_streaming_instance).
372#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
373pub struct StreamingInstanceId(SpriteInstanceId);
374
375/// Facade-side slotmap for streaming clips (mirrors [`CharMap`], epoch bump
376/// on `reset` included).
377#[derive(Default)]
378struct StreamingClipMap {
379    /// Per slot: `(epoch_at_alloc, live)`.
380    slots: Vec<(u32, bool)>,
381    epoch: u32,
382}
383
384impl StreamingClipMap {
385    fn alloc(&mut self, index: u32) -> StreamingClipId {
386        debug_assert_eq!(self.slots.len() as u32, index);
387        self.slots.push((self.epoch, true));
388        StreamingClipId {
389            slot: index,
390            gen: self.epoch,
391        }
392    }
393    fn index(&self, id: StreamingClipId) -> Option<usize> {
394        let (gen, live) = *self.slots.get(id.slot as usize)?;
395        (gen == id.gen && live).then_some(id.slot as usize)
396    }
397    fn remove(&mut self, id: StreamingClipId) -> bool {
398        let Some(slot) = self.slots.get_mut(id.slot as usize) else {
399            return false;
400        };
401        if slot.0 != id.gen || !slot.1 {
402            return false;
403        }
404        slot.1 = false;
405        true
406    }
407    fn reset(&mut self) {
408        self.slots.clear();
409        self.epoch = self.epoch.wrapping_add(1);
410    }
411}
412
413/// One registered streaming clip: the seekable cursor + the single sprite
414/// model it re-uploads each frame, plus the dims/pivot used to rebuild it.
415struct StreamingClipState {
416    cursor: StreamingClip,
417    model: SpriteModelId,
418    dims: [u32; 3],
419    pivot: [f32; 3],
420    /// Colour→material map (TV.3), empty for an all-opaque streaming clip.
421    /// Re-applied on every per-frame re-upload so the streamed model keeps
422    /// its per-voxel materials as it advances.
423    material_map: Vec<(u32, u8)>,
424}
425
426/// Per-clip-attachment playback clock (VCL.6): the timing it needs to
427/// resolve a frame, plus its own accumulating clock.
428struct ClipClock {
429    durations: Vec<u32>,
430    loop_mode: LoopMode,
431    /// Playback rate, Q8 (256 = 1×).
432    speed_q8: i32,
433    /// Accumulated playback time (ms), seeded from the attachment's
434    /// `start_phase_ms`.
435    clock_ms: f64,
436}
437
438impl ClipClock {
439    /// Advance the clock by `dt` seconds at its Q8 `speed` and return the
440    /// frame to show. Shared by character attachments and standalone clip
441    /// players. A negative clock (rewind past 0) reads as frame 0 but is
442    /// kept signed so resuming forward is continuous.
443    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
444    fn tick(&mut self, dt: f64) -> u32 {
445        self.clock_ms += dt * 1000.0 * f64::from(self.speed_q8) / 256.0;
446        frame_at(
447            &self.durations,
448            self.loop_mode,
449            self.clock_ms.max(0.0) as u32,
450        ) as u32
451    }
452}
453
454/// Facade-side metadata captured for a registered flipbook clip, so editor
455/// queries + the auto-player don't shadow the `DecodedClip`.
456struct ClipMeta {
457    dims: [u32; 3],
458    pivot: [f32; 3],
459    voxel_world_size: f32,
460    durations: Vec<u32>,
461    loop_mode: LoopMode,
462    /// Colour→material map the clip was registered with (TV.3), empty for an
463    /// all-opaque clip. Retained so an in-place
464    /// [`update_clip_frame`](SceneRenderer::update_clip_frame) re-classifies
465    /// the edited frame's voxels instead of dropping its per-voxel materials.
466    material_map: Vec<(u32, u8)>,
467}
468
469/// Public metadata for a registered clip — the inspector view returned by
470/// [`SceneRenderer::clip_metadata`].
471#[derive(Clone, Debug, PartialEq)]
472pub struct ClipMetadata {
473    /// Fixed bounding box (voxels).
474    pub dims: [u32; 3],
475    /// Model pivot (the kv6 pivot frames share).
476    pub pivot: [f32; 3],
477    /// Render scale (1 voxel = this many world units).
478    pub voxel_world_size: f32,
479    /// Playback wrap behaviour.
480    pub loop_mode: LoopMode,
481    /// Number of frames.
482    pub frame_count: usize,
483    /// Per-frame durations (ms), one per frame.
484    pub durations: Vec<u32>,
485    /// Total loop length (ms) — sum of `durations`.
486    pub total_ms: u32,
487}
488
489/// What an auto-advancing [`ClipPlayer`] (#6) drives each
490/// [`advance_voxel_clips`](SceneRenderer::advance_voxel_clips). A flipbook
491/// clip's frame is per-instance; a streaming clip's is per-clip (its
492/// instances share one model), so the targets differ.
493#[derive(Clone, Copy)]
494enum PlayerTarget {
495    Flipbook(SpriteInstanceId),
496    Streaming(StreamingClipId),
497}
498
499/// A standalone clip given its own playback clock (#6): the host calls
500/// `advance_voxel_clips(dt)` once instead of hand-driving `frame_at` +
501/// `set_clip_instance_frame`.
502struct ClipPlayer {
503    target: PlayerTarget,
504    clock: ClipClock,
505    /// When `true`, [`advance_voxel_clips`](SceneRenderer::advance_voxel_clips)
506    /// leaves the clock (and frame) untouched — the editor's play/pause.
507    paused: bool,
508}
509
510/// One live bone attachment: which bone drives it, its local offset, the
511/// renderer instance it owns, and (for a clip target) its playback clock.
512struct AttachInst {
513    bone: usize,
514    local_offset: roxlap_formats::xform::BoneXform,
515    inst: SpriteInstanceId,
516    clip: Option<ClipClock>,
517}
518
519/// A live animated character: the hinge skeleton (the bone-transform
520/// solver) + one [`AttachInst`] per bone attachment.
521struct CharInstance {
522    skeleton: KfaSprite,
523    attaches: Vec<AttachInst>,
524    /// Sprite models + voxel clips this character registered, so
525    /// [`remove_character`](SceneRenderer::remove_character) can free them
526    /// (otherwise they leak until the next `set_sprites`).
527    models: Vec<SpriteModelId>,
528    clips: Vec<VoxelClipId>,
529}
530
531/// Orientation + position for a dynamic sprite instance — the per-frame
532/// pose passed to [`SceneRenderer::add_sprite_instance_posed`] and
533/// [`set_sprite_instance_transform`](SceneRenderer::set_sprite_instance_transform).
534///
535/// `right`/`up`/`forward` are the instance's local axes expressed in
536/// world space (the columns of the model→world rotation), mapping
537/// directly onto the underlying [`Sprite`]'s `s`/`h`/`f` (kv6 local
538/// +x/+y/+z). They **must** be non-singular (`det ≠ 0`) but need not be
539/// orthonormal — a uniform/non-uniform scale or shear is fine. A
540/// near-singular basis falls through the renderer's degenerate-basis
541/// guards and the instance silently skips that frame rather than
542/// panicking. [`Default`] is the identity basis (axis-aligned).
543#[derive(Clone, Copy, Debug)]
544pub struct DynSpriteTransform {
545    /// Instance world position (the kv6 pivot maps here).
546    pub pos: [f32; 3],
547    /// Local +x in world space ↦ [`Sprite::s`].
548    pub right: [f32; 3],
549    /// Local +y in world space ↦ [`Sprite::h`].
550    pub up: [f32; 3],
551    /// Local +z in world space ↦ [`Sprite::f`].
552    pub forward: [f32; 3],
553}
554
555impl Default for DynSpriteTransform {
556    fn default() -> Self {
557        Self {
558            pos: [0.0, 0.0, 0.0],
559            right: [1.0, 0.0, 0.0],
560            up: [0.0, 1.0, 0.0],
561            forward: [0.0, 0.0, 1.0],
562        }
563    }
564}
565
566impl DynSpriteTransform {
567    /// Stamp this pose onto a [`Sprite`] in place: `pos → p`,
568    /// `right/up/forward → s/h/f` (a direct copy — the basis is the
569    /// model→world columns). Both backends keep the rest of the template
570    /// (`kv6`, `flags`) and only overwrite the pose.
571    pub(crate) fn apply_to(self, s: &mut Sprite) {
572        s.p = self.pos;
573        s.s = self.right;
574        s.h = self.up;
575        s.f = self.forward;
576    }
577}
578
579/// Backend-agnostic sprite description. The facade builds the CPU
580/// per-instance draw list and the GPU instanced registry from the
581/// same data, so both backends show identical sprites. The host owns
582/// content (which models, where, recolouring) — building a recoloured
583/// variant is just a second [`Sprite`] model with edited `kv6.voxels`.
584pub struct SpriteSet {
585    /// Distinct voxel models (KV6 + base orientation). Instances index
586    /// into this; their position overrides the model's.
587    pub models: Vec<Sprite>,
588    pub instances: Vec<SpriteInstanceDesc>,
589    /// Model the [`SceneRenderer::carve_active_sprite`] hotkey edits
590    /// (GPU only, mirroring the demo's `G`-carve). `None` disables it.
591    pub carve_model: Option<usize>,
592}
593
594/// Per-frame inputs both backends consume. The host builds the
595/// [`OpticastSettings`] (it owns scan distance etc.); the facade does
596/// everything else (pool config, sky fill, render, present).
597pub struct FrameParams<'a> {
598    /// CPU opticast settings (scan distance, mip ladder, framebuffer
599    /// geometry). Ignored by the GPU backend.
600    pub settings: &'a OpticastSettings,
601    /// Packed engine sky colour: the CPU sky-miss fill + skycast, and
602    /// the clear colour if no scene renders.
603    pub sky_color: u32,
604    /// Optional sky panorama for the CPU rasterizer's sky sampling.
605    pub sky: Option<&'a Sky>,
606    /// CPU fog: packed colour + max scan distance (voxels). `0` scan
607    /// distance disables CPU fog.
608    pub fog_color: u32,
609    pub fog_max_scan_dist: i32,
610    /// CPU: treat z=255 as air (avoids the S1.X bedrock path for
611    /// out-of-bounds cameras).
612    pub treat_z_max_as_air: bool,
613    /// GPU scene-grid LOD scan distance (world units); see GPU.11.1.
614    /// Ignored by the CPU backend.
615    pub gpu_mip_scan_dist: f32,
616    /// GPU outer-DDA step budget (chunks). Ignored by the CPU backend.
617    pub gpu_max_outer_steps: u32,
618    /// GPU vertical field of view (radians). Ignored by the CPU
619    /// backend (it derives projection from [`OpticastSettings`]).
620    pub gpu_fov_y_rad: f32,
621    /// Whether to draw the renderer's sprites this frame. Both backends
622    /// draw KV6 sprites flat-lit (the clean-room DDA sprite raycaster on
623    /// CPU; uploaded model colours on GPU), so no host-supplied lighting
624    /// is needed — this is just the on/off opt-in. `false` skips sprite
625    /// drawing.
626    pub draw_sprites: bool,
627    /// Per-face directional shading for the voxel grids — voxlap's
628    /// `setsideshades(top, bot, left, right, up, down)`, the grid-scan
629    /// analogue of [`draw_sprites`](Self::draw_sprites). Each
630    /// entry darkens the faces pointing that way; the host typically
631    /// passes its engine's `side_shades()`. The default `[0; 6]` keeps
632    /// `sideshademode` off (no per-side shading), so existing hosts and
633    /// the oracle goldens are unaffected. Applied each frame by **both**
634    /// backends: the CPU rasteriser via `gcsub`, and the GPU scene-DDA
635    /// pass by darkening a hit voxel's brightness by the hit face's
636    /// shade (the face taken from the DDA's last-stepped axis).
637    pub side_shades: [i8; 6],
638    /// Dynamic lighting (stage DL) — runtime sun + point lights + stylized
639    /// shadows. **GPU-only**: the CPU backend ignores this and keeps
640    /// multiplying the baked ambient byte. `None` (the default for hosts
641    /// that don't set it) ⇒ exactly the pre-DL render, both backends. The
642    /// baked brightness byte is reinterpreted as the ambient/AO channel;
643    /// direct light composites on top (`albedo*ambient + Σ direct`).
644    pub lights: Option<LightRig<'a>>,
645}
646
647/// Result of [`SceneRenderer::pick`] — a resolved screen→world voxel
648/// hit. `world` is the surface point (`cam.pos + t · normalize(ray)`);
649/// `grid` + `voxel` are the owning grid and its **grid-local** voxel
650/// (transform-correct for rotated / translated grids).
651#[derive(Clone, Copy, PartialEq, Debug)]
652pub struct PickHit {
653    pub world: [f32; 3],
654    pub grid: roxlap_scene::GridId,
655    pub voxel: glam::IVec3,
656}
657
658/// A world-space view ray: the canonical unproject output of
659/// [`SceneRenderer::view_ray`]. `dir` is unit-length. Feed it straight
660/// to [`roxlap_scene::Scene::raycast`] for depth-free, backend-agnostic
661/// voxel picking (`scene.raycast(ray.origin, ray.dir, max_dist)`), or
662/// intersect it with a plane for tile selection.
663#[derive(Clone, Copy, PartialEq, Debug)]
664pub struct Ray {
665    pub origin: glam::DVec3,
666    pub dir: glam::DVec3,
667}
668
669/// A world-space line segment to draw over a rendered frame via
670/// [`SceneRenderer::draw_lines`] — editor gizmos (bounding boxes, floor
671/// grids, axes, hover wireframes), debug paths, etc.
672#[derive(Clone, Copy, PartialEq, Debug)]
673pub struct Line3 {
674    /// World-space endpoints (voxel units), in the same frame the
675    /// rendered scene + `camera` use.
676    pub a: [f64; 3],
677    pub b: [f64; 3],
678    /// `0xAARRGGBB` — the high byte is an alpha blend factor (`0xFF`
679    /// opaque, `0x00` invisible), the low 24 bits the RGB colour.
680    pub color: u32,
681    /// Screen-space thickness in pixels (`<= 1.0` draws a 1px line).
682    pub width_px: f32,
683    /// `true`: the segment is occluded by nearer rendered geometry
684    /// (depth-tested against the frame's z-buffer). `false`: always on
685    /// top (e.g. a hover highlight that should show through the model).
686    pub depth_test: bool,
687}
688
689/// A handle to an uploaded image-sprite texture, returned by
690/// [`SceneRenderer::upload_image`]. Positional (like [`SpriteModelId`]):
691/// it indexes the backend's texture store. Pass it in an [`ImageSprite`]
692/// for [`SceneRenderer::draw_images`], or to
693/// [`drop_image`](SceneRenderer::drop_image) to release it. Opaque on
694/// purpose — there's no arithmetic to do on it.
695#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
696pub struct ImageId(pub(crate) usize);
697
698/// How an [`ImageSprite`]'s quad is oriented in the world.
699#[derive(Clone, Copy, PartialEq, Debug)]
700pub enum ImageFacing {
701    /// Fixed in world space: the quad lies in the plane spanned by `u`
702    /// (the image's +column / width direction) and `v` (its +row /
703    /// height direction). Both are world-space directions; their length
704    /// is ignored (the quad is sized by [`ImageSprite::size`]), so pass
705    /// the plane's axes directly. Row 0 of the image is the `origin`
706    /// edge and rows grow along `v`.
707    World { u: [f32; 3], v: [f32; 3] },
708    /// Always faces the camera (billboard); `up` is the world direction
709    /// the image's top edge points toward (e.g. world `-Z` for the
710    /// scene-demo's z-down world, or any "up" the host prefers).
711    Billboard { up: [f32; 3] },
712}
713
714/// One placed 2D image sprite for the current frame: a flat textured
715/// quad in world space, composited over the rendered scene with the
716/// frame's depth buffer (so the voxel model can occlude it). Built per
717/// frame and passed to [`SceneRenderer::draw_images`], mirroring
718/// [`Line3`] / [`SceneRenderer::draw_lines`]. The texture is uploaded
719/// once via [`SceneRenderer::upload_image`] and referenced by [`image`].
720///
721/// [`image`]: ImageSprite::image
722#[derive(Clone, Copy, PartialEq, Debug)]
723pub struct ImageSprite {
724    /// The uploaded texture to draw (from [`SceneRenderer::upload_image`]).
725    pub image: ImageId,
726    /// World position of the quad's **top-left** corner — the image's
727    /// `(column 0, row 0)` texel. The quad extends `size[0]` along the
728    /// facing's `u` and `size[1]` along its `v`.
729    pub origin: [f32; 3],
730    /// World orientation of the quad — fixed in world or camera-facing.
731    pub facing: ImageFacing,
732    /// World size of the quad along `u` and `v`. For pixel-art traced at
733    /// 1 texel = 1 voxel, pass `[width as f32, height as f32]`.
734    pub size: [f32; 2],
735    /// Multiplied into every sampled texel (tint + opacity), `0xAARRGGBB`.
736    /// `0xFFFFFFFF` draws the texture unchanged; the high byte scales
737    /// the texel alpha (e.g. `0x80FFFFFF` = 50 % opacity).
738    pub tint: u32,
739    /// Alpha cutoff in `0.0..=1.0`. Texels whose **own** alpha is below
740    /// this are discarded outright (not blended) — crisp pixel-art edges
741    /// instead of a semi-transparent haze, and the same threshold decides
742    /// what [`SceneRenderer::pick_image`] treats as solid. `0.0` keeps the
743    /// plain straight-alpha over-blend (every non-zero texel draws).
744    pub alpha_cutoff: f32,
745    /// `true`: occluded by nearer rendered geometry (depth-tested against
746    /// the frame's depth buffer, with a bias so a quad resting on a
747    /// coincident voxel face doesn't z-fight). `false`: always on top.
748    pub depth_test: bool,
749    /// `true`: draw regardless of which way the quad faces (no backface
750    /// cull) — what reference images usually want. `false`: cull when the
751    /// quad faces away from the camera. Ignored for
752    /// [`ImageFacing::Billboard`] (it always faces the camera).
753    pub double_sided: bool,
754}
755
756/// Backend-agnostic resolved quad: four world corners (`TL, TR, BL, BR`,
757/// with UVs `(0,0) (1,0) (0,1) (1,1)`) + the texture to map. The facade
758/// resolves [`ImageSprite::facing`] into corners and culls back-facing
759/// quads once, so both backends draw from the same geometry.
760#[derive(Clone, Copy, Debug)]
761pub(crate) struct QuadDraw {
762    pub corners: [[f32; 3]; 4],
763    pub image: ImageId,
764    pub tint: u32,
765    pub depth_test: bool,
766    pub alpha_cutoff: f32,
767}
768
769/// Result of [`SceneRenderer::pick_image`] — a resolved screen→sprite hit.
770/// `uv` is the normalised position within the quad (`(0,0)` = top-left
771/// corner); `texel` is the matching source-image pixel; `world` is the
772/// hit point; `t` is its euclidean distance from the camera.
773#[derive(Clone, Copy, PartialEq, Debug)]
774pub struct ImagePickHit {
775    pub image: ImageId,
776    pub uv: [f32; 2],
777    pub texel: (u32, u32),
778    pub world: [f32; 3],
779    pub t: f32,
780}
781
782/// Which renderer a [`SceneRenderer`] resolved to at construction.
783#[derive(Clone, Copy, PartialEq, Eq, Debug)]
784pub enum Backend {
785    /// `roxlap-core` opticast, presented via `softbuffer`.
786    Cpu,
787    /// `roxlap-gpu` compute marcher, presented via wgpu.
788    Gpu,
789}
790
791/// Construction-time options for [`SceneRenderer::new`].
792pub struct RenderOptions {
793    /// Try the GPU backend first. When `false`, or when GPU init
794    /// fails, the renderer uses the CPU backend.
795    pub want_gpu: bool,
796    /// Settings forwarded to [`roxlap_gpu::GpuRenderer`] when the GPU
797    /// backend is selected.
798    pub gpu: GpuRendererSettings,
799    /// Packed `0x00RRGGBB` (alpha ignored) the empty/clear frame fills
800    /// with until a scene render lands. Also the CPU sky-miss colour
801    /// default if a frame supplies none.
802    pub clear_sky: u32,
803    /// CPU [`ScratchPool`](roxlap_core::rasterizer::ScratchPool) `lastx`
804    /// sizing — the largest combined grid `vsid` the CPU rasterizer
805    /// will see. Pre-sizing keeps later frames allocation-free.
806    pub cpu_max_grid_vsid: u32,
807    /// CPU strip-parallel render thread count (capped to the rayon
808    /// pool). One [`ScratchPool`](roxlap_core::rasterizer::ScratchPool)
809    /// slot per thread.
810    pub cpu_render_threads: usize,
811}
812
813impl Default for RenderOptions {
814    fn default() -> Self {
815        Self {
816            want_gpu: false,
817            gpu: GpuRendererSettings::default(),
818            clear_sky: 0x0099_b3d9,
819            // 32 chunks × CHUNK_SIZE_XY — the scene-demo's widest
820            // combined ground grid.
821            cpu_max_grid_vsid: 32 * roxlap_scene::CHUNK_SIZE_XY,
822            cpu_render_threads: 4,
823        }
824    }
825}
826
827/// Depth-test slack (same spirit as the backends' `DEPTH_BIAS`) so a
828/// [`SceneRenderer::pick_image`] hit on a sprite resting on a coincident
829/// voxel face isn't rejected as "occluded".
830const PICK_DEPTH_BIAS: f32 = 0.5;
831
832// --- image-sprite geometry helpers (shared by both backends) ---
833
834fn v_sub(a: [f32; 3], b: [f32; 3]) -> [f32; 3] {
835    [a[0] - b[0], a[1] - b[1], a[2] - b[2]]
836}
837fn v_add(a: [f32; 3], b: [f32; 3]) -> [f32; 3] {
838    [a[0] + b[0], a[1] + b[1], a[2] + b[2]]
839}
840fn v_scale(a: [f32; 3], s: f32) -> [f32; 3] {
841    [a[0] * s, a[1] * s, a[2] * s]
842}
843fn v_dot(a: [f32; 3], b: [f32; 3]) -> f32 {
844    a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
845}
846fn v_cross(a: [f32; 3], b: [f32; 3]) -> [f32; 3] {
847    [
848        a[1] * b[2] - a[2] * b[1],
849        a[2] * b[0] - a[0] * b[2],
850        a[0] * b[1] - a[1] * b[0],
851    ]
852}
853fn v_norm(a: [f32; 3]) -> [f32; 3] {
854    let len = v_dot(a, a).sqrt();
855    if len < 1e-12 {
856        a
857    } else {
858        v_scale(a, 1.0 / len)
859    }
860}
861
862/// Intersect a ray (`origin` + `dir`, `dir` un-normalised) with a quad
863/// `[TL, TR, BL, BR]` and return `(uv, t)` for a front/back hit inside
864/// the quad — `uv` in `0..=1` (`(0,0)` = `TL`), `t` the ray parameter
865/// (`hit = origin + dir·t`). `None` for a parallel ray, a hit behind the
866/// origin, a degenerate quad, or a hit outside the `u`/`v` span. Solves
867/// affine coords exactly for a (possibly skew) parallelogram. Standalone
868/// so the geometry is unit-testable without a renderer.
869fn ray_quad_uv(
870    origin: [f32; 3],
871    dir: [f32; 3],
872    corners: &[[f32; 3]; 4],
873) -> Option<([f32; 2], f32)> {
874    let [tl, tr, bl, _br] = *corners;
875    let ue = v_sub(tr, tl); // +u edge (width)
876    let ve = v_sub(bl, tl); // +v edge (height)
877    let n = v_cross(ue, ve);
878    let denom = v_dot(dir, n);
879    if denom.abs() < 1e-12 {
880        return None; // ray parallel to the quad's plane
881    }
882    let t = v_dot(v_sub(tl, origin), n) / denom;
883    if t <= 1e-6 {
884        return None; // behind / at the origin
885    }
886    let p = v_add(origin, v_scale(dir, t));
887    let rel = v_sub(p, tl);
888    let guu = v_dot(ue, ue);
889    let guv = v_dot(ue, ve);
890    let gvv = v_dot(ve, ve);
891    let det = guu * gvv - guv * guv;
892    if det.abs() < 1e-12 {
893        return None; // degenerate quad
894    }
895    let wu = v_dot(rel, ue);
896    let wv = v_dot(rel, ve);
897    let a = (gvv * wu - guv * wv) / det;
898    let b = (guu * wv - guv * wu) / det;
899    if !(0.0..=1.0).contains(&a) || !(0.0..=1.0).contains(&b) {
900        return None; // outside the quad
901    }
902    Some(([a, b], t))
903}
904
905/// Resolve an [`ImageSprite`] into its four world corners (`TL, TR, BL,
906/// BR`), or `None` when a `double_sided == false` world quad faces away
907/// from the camera (back-face cull) or its plane is degenerate. The
908/// camera basis is used only for [`ImageFacing::Billboard`] and the cull
909/// test.
910fn resolve_quad(sprite: &ImageSprite, camera: &Camera) -> Option<QuadDraw> {
911    let cam_pos = [
912        camera.pos[0] as f32,
913        camera.pos[1] as f32,
914        camera.pos[2] as f32,
915    ];
916    let cam_fwd = v_norm([
917        camera.forward[0] as f32,
918        camera.forward[1] as f32,
919        camera.forward[2] as f32,
920    ]);
921
922    let (u_hat, v_hat) = match sprite.facing {
923        ImageFacing::World { u, v } => (v_norm(u), v_norm(v)),
924        ImageFacing::Billboard { up } => {
925            // Horizontal axis ⟂ both the view direction and `up`; fall
926            // back to the camera right when `up` is parallel to the view.
927            let mut u_hat = v_norm(v_cross(up, cam_fwd));
928            if v_dot(u_hat, u_hat) < 1e-12 {
929                u_hat = v_norm([
930                    camera.right[0] as f32,
931                    camera.right[1] as f32,
932                    camera.right[2] as f32,
933                ]);
934            }
935            // Vertical axis ⟂ both, pointing *down* (rows grow downward)
936            // so the top edge ends up toward `up`.
937            let mut v_hat = v_norm(v_cross(cam_fwd, u_hat));
938            if v_dot(v_hat, up) > 0.0 {
939                v_hat = v_scale(v_hat, -1.0);
940            }
941            (u_hat, v_hat)
942        }
943    };
944
945    let du = v_scale(u_hat, sprite.size[0]);
946    let dv = v_scale(v_hat, sprite.size[1]);
947    let tl = sprite.origin;
948    let tr = v_add(tl, du);
949    let bl = v_add(tl, dv);
950    let br = v_add(tr, dv);
951
952    // Back-face cull for fixed world quads (billboards always face us).
953    if !sprite.double_sided {
954        if let ImageFacing::World { .. } = sprite.facing {
955            let normal = v_cross(du, dv);
956            // Front-facing when the quad normal points toward the camera.
957            if v_dot(normal, v_sub(cam_pos, tl)) <= 0.0 {
958                return None;
959            }
960        }
961    }
962
963    Some(QuadDraw {
964        corners: [tl, tr, bl, br],
965        image: sprite.image,
966        tint: sprite.tint,
967        depth_test: sprite.depth_test,
968        alpha_cutoff: sprite.alpha_cutoff,
969    })
970}
971
972/// Renderer-internal backend; never exposes wgpu or softbuffer types.
973/// The GPU variant owns the whole wgpu device/queue/pipelines, so
974/// it's boxed to keep the enum small.
975enum BackendImpl {
976    // Both variants boxed so the enum stays small regardless of which
977    // backend's state is larger (clippy::large_enum_variant).
978    Cpu(Box<CpuBackend>),
979    Gpu(Box<GpuBackend>),
980}
981
982/// Unified renderer over the CPU and GPU paths. See the crate docs.
983pub struct SceneRenderer {
984    inner: BackendImpl,
985    /// Handles for dynamically added sprite instances (see
986    /// [`Self::add_sprite_instance`]). Reset by [`Self::set_sprites`].
987    dyn_map: DynInstanceMap,
988    /// Handles for registered sprite models (see [`Self::add_sprite_model`]
989    /// and the models returned by [`Self::set_sprites`]). Reset by
990    /// [`Self::set_sprites`].
991    model_map: DynModelMap,
992    /// Handles for registered animated voxel clips (see
993    /// [`Self::add_voxel_clip`]). Reset by [`Self::set_sprites`].
994    clip_map: DynClipMap,
995    /// Handles for registered animated characters (see
996    /// [`Self::add_character`]). Reset by [`Self::set_sprites`].
997    char_map: CharMap,
998    /// Live character runtimes, parallel to `char_map` slots (VCL.6).
999    char_instances: Vec<CharInstance>,
1000    /// Handles for registered streaming clips (see
1001    /// [`Self::add_streaming_clip`]). Reset by [`Self::set_sprites`].
1002    streaming_map: StreamingClipMap,
1003    /// Streaming-clip runtimes (cursor + one re-uploaded model), parallel
1004    /// to `streaming_map` slots; `None` once removed (#3).
1005    streaming_clips: Vec<Option<StreamingClipState>>,
1006    /// Metadata per registered flipbook clip, indexed by the backend clip
1007    /// index (parallel to `clip_map`). Captured at [`Self::add_voxel_clip`]
1008    /// so the editor queries ([`Self::clip_metadata`]) + the auto-player
1009    /// don't have to re-pass / shadow the `DecodedClip`. Reset by
1010    /// [`Self::set_sprites`].
1011    clip_meta: Vec<ClipMeta>,
1012    /// Auto-advancing clip players (#6); ticked by
1013    /// [`Self::advance_voxel_clips`]. Reset by [`Self::set_sprites`].
1014    clip_players: Vec<ClipPlayer>,
1015}
1016
1017impl SceneRenderer {
1018    /// Build a renderer for `window` — any [`raw-window-handle`]
1019    /// provider (winit, SDL, GLFW, …) in an `Arc`. `size` is the
1020    /// window's initial physical framebuffer size in pixels; thereafter
1021    /// the host reports changes via [`Self::resize`]. Passing the size
1022    /// explicitly keeps the facade decoupled from any one windowing
1023    /// library's size API.
1024    ///
1025    /// Selects the GPU backend when `opts.want_gpu` and WGPU
1026    /// initialises; otherwise the CPU backend. **Never fails** — a
1027    /// missing/incompatible GPU silently yields the CPU path (the
1028    /// message is logged to stderr).
1029    ///
1030    /// [`raw-window-handle`]: raw_window_handle
1031    #[cfg(not(target_arch = "wasm32"))]
1032    #[must_use]
1033    pub fn new<W>(window: Arc<W>, size: (u32, u32), opts: &RenderOptions) -> Self
1034    where
1035        W: HasWindowHandle + HasDisplayHandle + Send + Sync + 'static,
1036    {
1037        if opts.want_gpu {
1038            match GpuBackend::new(window.clone(), size, opts) {
1039                Ok(g) => {
1040                    return Self {
1041                        inner: BackendImpl::Gpu(Box::new(g)),
1042                        dyn_map: DynInstanceMap::default(),
1043                        model_map: DynModelMap::default(),
1044                        clip_map: DynClipMap::default(),
1045                        char_map: CharMap::default(),
1046                        char_instances: Vec::new(),
1047                        streaming_map: StreamingClipMap::default(),
1048                        streaming_clips: Vec::new(),
1049                        clip_meta: Vec::new(),
1050                        clip_players: Vec::new(),
1051                    };
1052                }
1053                Err(e) => {
1054                    eprintln!(
1055                        "roxlap-render: GPU init failed ({e}); falling back to the CPU renderer",
1056                    );
1057                }
1058            }
1059        }
1060        Self {
1061            inner: BackendImpl::Cpu(Box::new(CpuBackend::new(window, size, opts))),
1062            dyn_map: DynInstanceMap::default(),
1063            model_map: DynModelMap::default(),
1064            clip_map: DynClipMap::default(),
1065            char_map: CharMap::default(),
1066            char_instances: Vec::new(),
1067            streaming_map: StreamingClipMap::default(),
1068            streaming_clips: Vec::new(),
1069            clip_meta: Vec::new(),
1070            clip_players: Vec::new(),
1071        }
1072    }
1073
1074    /// wasm/WebGPU build-time entry: build a renderer over an HTML
1075    /// `canvas`. `size` is the canvas's initial framebuffer size in
1076    /// pixels; the host reports later changes via [`Self::resize`].
1077    ///
1078    /// Async because the browser drives wgpu's adapter/device requests
1079    /// through its event loop — `await` it inside a
1080    /// `wasm_bindgen_futures::spawn_local` task. Selects the GPU
1081    /// (WebGPU) backend when `opts.want_gpu` and WebGPU is available;
1082    /// otherwise (no WebGPU, or init failed) it falls back to the CPU
1083    /// opticast path presented through a WebGL2 blit on the same canvas.
1084    /// **Never fails** — the message is logged to the browser console.
1085    #[cfg(target_arch = "wasm32")]
1086    pub async fn new_from_canvas_async(
1087        canvas: web_sys::HtmlCanvasElement,
1088        size: (u32, u32),
1089        opts: &RenderOptions,
1090    ) -> Self {
1091        if opts.want_gpu {
1092            // `SurfaceTarget::Canvas` moves the canvas into wgpu, so the
1093            // GPU attempt gets a clone — the CPU fallback keeps the
1094            // original if WebGPU init fails.
1095            match GpuBackend::new_async(canvas.clone(), size, opts).await {
1096                Ok(g) => {
1097                    return Self {
1098                        inner: BackendImpl::Gpu(Box::new(g)),
1099                        dyn_map: DynInstanceMap::default(),
1100                        model_map: DynModelMap::default(),
1101                        clip_map: DynClipMap::default(),
1102                        char_map: CharMap::default(),
1103                        char_instances: Vec::new(),
1104                        streaming_map: StreamingClipMap::default(),
1105                        streaming_clips: Vec::new(),
1106                        clip_meta: Vec::new(),
1107                        clip_players: Vec::new(),
1108                    };
1109                }
1110                Err(e) => {
1111                    web_sys::console::warn_1(
1112                        &format!("roxlap-render: WebGPU init failed ({e}); using the CPU renderer")
1113                            .into(),
1114                    );
1115                }
1116            }
1117        }
1118        Self {
1119            inner: BackendImpl::Cpu(Box::new(CpuBackend::new_from_canvas(canvas, size, opts))),
1120            dyn_map: DynInstanceMap::default(),
1121            model_map: DynModelMap::default(),
1122            clip_map: DynClipMap::default(),
1123            char_map: CharMap::default(),
1124            char_instances: Vec::new(),
1125            streaming_map: StreamingClipMap::default(),
1126            streaming_clips: Vec::new(),
1127            clip_meta: Vec::new(),
1128            clip_players: Vec::new(),
1129        }
1130    }
1131
1132    /// Which backend was selected.
1133    #[must_use]
1134    pub fn backend(&self) -> Backend {
1135        match self.inner {
1136            BackendImpl::Cpu(_) => Backend::Cpu,
1137            BackendImpl::Gpu(_) => Backend::Gpu,
1138        }
1139    }
1140
1141    /// The GPU adapter description when on the GPU backend, else
1142    /// `None`.
1143    #[must_use]
1144    pub fn adapter_info(&self) -> Option<&str> {
1145        match &self.inner {
1146            BackendImpl::Gpu(g) => Some(g.adapter_info()),
1147            BackendImpl::Cpu(_) => None,
1148        }
1149    }
1150
1151    /// Upload an equirectangular sky panorama (RGBA8, `w×h`) for the
1152    /// GPU marcher's sky sampling. No-op on the CPU backend, which
1153    /// samples the [`Sky`] passed in each [`FrameParams`] instead.
1154    pub fn set_sky_panorama(&mut self, rgba: &[u8], w: u32, h: u32) {
1155        if let BackendImpl::Gpu(g) = &mut self.inner {
1156            g.set_sky_panorama(rgba, w, h);
1157        }
1158    }
1159
1160    /// Follow a window resize. CPU resizes its framebuffer lazily, so
1161    /// this only matters to the GPU swapchain — but it's safe to call
1162    /// for both.
1163    pub fn resize(&mut self, width: u32, height: u32) {
1164        match &mut self.inner {
1165            BackendImpl::Cpu(c) => c.resize(width, height),
1166            BackendImpl::Gpu(g) => g.resize(width, height),
1167        }
1168    }
1169
1170    /// Composite `scene` from `camera` with `frame` params into the
1171    /// backend's frame buffer — **without presenting**. The CPU backend
1172    /// fills sky + runs the opticast compositor into an owned buffer;
1173    /// the GPU backend uploads/refreshes the scene, runs the compute
1174    /// marcher + sprite pass, and acquires (but does not present) the
1175    /// swapchain frame.
1176    ///
1177    /// Finish the frame with exactly one of [`present`](Self::present)
1178    /// (no overlay) or [`paint_egui`](Self::paint_egui) (UI overlay).
1179    /// Calling `render` again without finishing drops the pending frame.
1180    pub fn render(&mut self, scene: &mut Scene, camera: &Camera, frame: &FrameParams) {
1181        match &mut self.inner {
1182            BackendImpl::Cpu(c) => c.render(scene, camera, frame),
1183            BackendImpl::Gpu(g) => g.render(scene, camera, frame),
1184        }
1185    }
1186
1187    /// Draw world-space [`Line3`] segments over the frame
1188    /// [`render`](Self::render) composited, using that frame's camera +
1189    /// projection + depth buffer. Call **after** [`render`](Self::render)
1190    /// and **before** [`present`](Self::present) /
1191    /// [`paint_egui`](Self::paint_egui) — the lines land in the
1192    /// framebuffer, so a subsequent `paint_egui` still draws its panels
1193    /// on top.
1194    ///
1195    /// `camera` must be the one the last frame rendered with (the
1196    /// projection is taken from that frame). Depth-tested segments
1197    /// (`Line3::depth_test`) are occluded by nearer rendered geometry;
1198    /// always-on-top segments ignore depth. See [`Line3`] for colour /
1199    /// width / blend semantics.
1200    pub fn draw_lines(&mut self, camera: &Camera, lines: &[Line3]) {
1201        match &mut self.inner {
1202            BackendImpl::Cpu(c) => c.draw_lines(camera, lines),
1203            BackendImpl::Gpu(g) => g.draw_lines(camera, lines),
1204        }
1205    }
1206
1207    /// Upload (or replace) an RGBA8 image and return a stable [`ImageId`]
1208    /// to reference it in [`draw_images`](Self::draw_images). `rgba` is
1209    /// row-major, `width * height * 4` bytes, **straight** (un-premultiplied)
1210    /// alpha. The texture is retained until [`drop_image`](Self::drop_image),
1211    /// so the per-frame draw call stays cheap. Sampling is
1212    /// nearest-neighbour (pixel-art friendly — no blurring).
1213    ///
1214    /// Returns `None` for malformed input — a wrong byte count
1215    /// (`!= width·height·4`) or a zero dimension — so a bad upload can't be
1216    /// confused with the first valid id (`ImageId(0)`).
1217    pub fn upload_image(&mut self, rgba: &[u8], width: u32, height: u32) -> Option<ImageId> {
1218        if width == 0 || height == 0 || rgba.len() != (width as usize) * (height as usize) * 4 {
1219            return None;
1220        }
1221        Some(match &mut self.inner {
1222            BackendImpl::Cpu(c) => c.upload_image(rgba, width, height),
1223            BackendImpl::Gpu(g) => g.upload_image(rgba, width, height),
1224        })
1225    }
1226
1227    /// Release a texture uploaded with [`upload_image`](Self::upload_image).
1228    /// The id must not be reused afterwards (a later `upload_image` may
1229    /// hand the slot back out under a fresh id).
1230    pub fn drop_image(&mut self, id: ImageId) {
1231        match &mut self.inner {
1232            BackendImpl::Cpu(c) => c.drop_image(id),
1233            BackendImpl::Gpu(g) => g.drop_image(id),
1234        }
1235    }
1236
1237    /// Draw 2D [`ImageSprite`]s over the frame [`render`](Self::render)
1238    /// composited — flat textured quads placed in world space, using that
1239    /// frame's camera + projection + depth buffer. Same contract as
1240    /// [`draw_lines`](Self::draw_lines): call **after** [`render`](Self::render)
1241    /// and **before** [`present`](Self::present) / [`paint_egui`](Self::paint_egui).
1242    ///
1243    /// UVs are perspective-correct (no affine warp on an obliquely-viewed
1244    /// quad). Depth-tested sprites are occluded by nearer rendered
1245    /// geometry (with a bias to avoid z-fighting on a coincident face);
1246    /// the texture's straight alpha + the [`ImageSprite::tint`] composite
1247    /// over the scene. `camera` must be the one the last frame rendered.
1248    pub fn draw_images(&mut self, camera: &Camera, images: &[ImageSprite]) {
1249        if images.is_empty() {
1250            return;
1251        }
1252        let quads: Vec<QuadDraw> = images
1253            .iter()
1254            .filter_map(|s| resolve_quad(s, camera))
1255            .collect();
1256        if quads.is_empty() {
1257            return;
1258        }
1259        match &mut self.inner {
1260            BackendImpl::Cpu(c) => c.draw_images(camera, &quads),
1261            BackendImpl::Gpu(g) => g.draw_images(camera, &quads),
1262        }
1263    }
1264
1265    /// Project a world point to window pixel coordinates `(x, y)` under
1266    /// the projection the **last frame** rendered with — the backend-correct
1267    /// `world → screen` inverse of [`view_ray`](Self::view_ray). `None`
1268    /// before the first frame or for a point at/behind the camera near
1269    /// plane.
1270    ///
1271    /// Both backends honour their own projection (CPU `setcamera`
1272    /// `hx/hy/hz`, GPU vertical-FOV pinhole), so hosts never reconstruct
1273    /// it themselves. The returned `(x, y)` may fall outside `[0, w) ×
1274    /// [0, h)` for points off-screen but in front of the camera.
1275    #[must_use]
1276    pub fn project_point(&self, camera: &Camera, world: [f32; 3]) -> Option<(f32, f32)> {
1277        match &self.inner {
1278            BackendImpl::Cpu(c) => c.project_point(camera, world),
1279            BackendImpl::Gpu(g) => g.project_point(camera, world),
1280        }
1281    }
1282
1283    /// Screen→sprite pick: the nearest [`ImageSprite`] hit under window
1284    /// pixel `(x, y)`, resolving which texel was clicked. `sprites` is the
1285    /// same list passed to [`draw_images`](Self::draw_images) (image
1286    /// sprites are immediate-mode, so the caller owns the set). `None` for
1287    /// a miss.
1288    ///
1289    /// The ray is intersected with each quad's plane and mapped to its
1290    /// `uv` / source texel. A texel whose alpha is below the sprite's
1291    /// [`ImageSprite::alpha_cutoff`] (and any fully-transparent texel) is
1292    /// **see-through** — the pick passes through it to a sprite behind.
1293    /// For [`depth_test`](ImageSprite::depth_test) sprites the hit is
1294    /// rejected when nearer scene geometry occludes that pixel (shares the
1295    /// depth convention + bias of [`pick`](Self::pick); on the GPU backend
1296    /// the occlusion test costs a click-time depth readback).
1297    #[must_use]
1298    pub fn pick_image(
1299        &self,
1300        camera: &Camera,
1301        x: f64,
1302        y: f64,
1303        sprites: &[ImageSprite],
1304    ) -> Option<ImagePickHit> {
1305        if sprites.is_empty() {
1306            return None;
1307        }
1308        let dir = self.pixel_ray(camera, x, y)?;
1309        let dir = [dir[0] as f32, dir[1] as f32, dir[2] as f32];
1310        let dir_len = v_dot(dir, dir).sqrt();
1311        if dir_len < 1e-9 {
1312            return None;
1313        }
1314        let origin = [
1315            camera.pos[0] as f32,
1316            camera.pos[1] as f32,
1317            camera.pos[2] as f32,
1318        ];
1319        // Scene surface distance under this pixel (sky / no-hit → None);
1320        // used to occlude depth-tested sprites. Same metric as `pick`.
1321        let scene_t = self.pick_depth(x as u32, y as u32);
1322
1323        let mut best: Option<ImagePickHit> = None;
1324        for sprite in sprites {
1325            // Reuse the render-path resolve (back-face cull included), so
1326            // a single-sided quad that isn't drawn also can't be picked.
1327            let Some(q) = resolve_quad(sprite, camera) else {
1328                continue;
1329            };
1330            let Some(([a, b], t)) = ray_quad_uv(origin, dir, &q.corners) else {
1331                continue; // miss / parallel / behind
1332            };
1333            let d_eucl = t * dir_len;
1334            if best.is_some_and(|cur| d_eucl >= cur.t) {
1335                continue; // a nearer sprite already won
1336            }
1337            let p = v_add(origin, v_scale(dir, t));
1338
1339            let Some((iw, ih)) = self.image_dims(sprite.image) else {
1340                continue; // dropped / unknown image
1341            };
1342            let tx = ((a * iw as f32) as i32).clamp(0, iw as i32 - 1) as u32;
1343            let ty = ((b * ih as f32) as i32).clamp(0, ih as i32 - 1) as u32;
1344
1345            // See-through test: a texel is solid when its alpha clears the
1346            // cutoff (and a fully-transparent texel is never solid).
1347            let cutoff_u8 = (sprite.alpha_cutoff.clamp(0.0, 1.0) * 255.0) as u32;
1348            let solid_thresh = cutoff_u8.max(1);
1349            if u32::from(self.image_alpha_at(sprite.image, tx, ty)) < solid_thresh {
1350                continue;
1351            }
1352
1353            // Occlusion: a depth-tested sprite behind nearer geometry loses.
1354            if sprite.depth_test {
1355                if let Some(st) = scene_t {
1356                    if d_eucl > st + PICK_DEPTH_BIAS {
1357                        continue;
1358                    }
1359                }
1360            }
1361
1362            best = Some(ImagePickHit {
1363                image: sprite.image,
1364                uv: [a, b],
1365                texel: (tx, ty),
1366                world: p,
1367                t: d_eucl,
1368            });
1369        }
1370        best
1371    }
1372
1373    /// Source dimensions of an uploaded image, or `None` if the id was
1374    /// dropped / never uploaded. Internal helper for [`Self::pick_image`].
1375    fn image_dims(&self, id: ImageId) -> Option<(u32, u32)> {
1376        match &self.inner {
1377            BackendImpl::Cpu(c) => c.image_dims(id),
1378            BackendImpl::Gpu(g) => g.image_dims(id),
1379        }
1380    }
1381
1382    /// Alpha byte of texel `(tx, ty)` in an uploaded image (`0` for an
1383    /// unknown id / out-of-range texel). Internal helper for
1384    /// [`Self::pick_image`].
1385    fn image_alpha_at(&self, id: ImageId, tx: u32, ty: u32) -> u8 {
1386        match &self.inner {
1387            BackendImpl::Cpu(c) => c.image_alpha_at(id, tx, ty),
1388            BackendImpl::Gpu(g) => g.image_alpha_at(id, tx, ty),
1389        }
1390    }
1391
1392    /// Mirror the rendered 3D scene horizontally before display. The flip is
1393    /// applied *before* any egui overlay, so the UI stays upright while the
1394    /// viewport un-mirrors — a fix for the engine's left-handed render.
1395    /// Supported on both backends (CPU reverses the framebuffer rows; GPU
1396    /// mirrors the scene blit + line/image overlays). Picking/projection are
1397    /// unchanged, so a host that flips must mirror its cursor X (`width - x`)
1398    /// for ray casts.
1399    pub fn set_flip_x(&mut self, flip: bool) {
1400        match &mut self.inner {
1401            BackendImpl::Cpu(c) => c.set_flip_x(flip),
1402            BackendImpl::Gpu(g) => g.set_flip_x(flip),
1403        }
1404    }
1405
1406    /// Present the frame [`render`](Self::render) composited, with no UI
1407    /// overlay. Pairs with `render`; use [`paint_egui`](Self::paint_egui)
1408    /// instead to overlay an egui UI before presenting.
1409    pub fn present(&mut self) {
1410        match &mut self.inner {
1411            BackendImpl::Cpu(c) => c.present(),
1412            BackendImpl::Gpu(g) => g.present(),
1413        }
1414    }
1415
1416    /// Block until the active backend has finished all in-flight work, ready
1417    /// for a clean teardown. On the GPU backend this drains the device queue
1418    /// and releases any acquired-but-unpresented swapchain frame; on the CPU
1419    /// backend it is a no-op (nothing is in flight).
1420    ///
1421    /// Call this at shutdown **before dropping the renderer and its window**,
1422    /// so the GPU device/surface tear down with no commands queued and no
1423    /// half-presented frame. Skipping it (or dropping the window first) can
1424    /// leave the driver/compositor showing stale buffers after an exit — the
1425    /// "leftover triangles / flicker" symptom of an unclean shutdown.
1426    pub fn wait_idle(&mut self) {
1427        match &mut self.inner {
1428            BackendImpl::Cpu(c) => c.wait_idle(),
1429            BackendImpl::Gpu(g) => g.wait_idle(),
1430        }
1431    }
1432
1433    /// Overlay an egui UI on the frame [`render`](Self::render)
1434    /// composited, then present it (`hud` feature). The host runs egui
1435    /// itself (e.g. `egui` + `egui-winit`) and passes the tessellated
1436    /// `jobs` ([`egui::Context::tessellate`]) and the per-frame
1437    /// `textures` delta from [`egui::FullOutput`]; `pixels_per_point` is
1438    /// the UI scale (`ctx.pixels_per_point()`).
1439    ///
1440    /// The GPU backend paints via `egui-wgpu`; the CPU backend
1441    /// software-rasterises the tessellation into its framebuffer. Use
1442    /// this **instead of** [`present`](Self::present) — both finish the
1443    /// frame.
1444    #[cfg(feature = "hud")]
1445    pub fn paint_egui(
1446        &mut self,
1447        jobs: &[egui::ClippedPrimitive],
1448        textures: &egui::TexturesDelta,
1449        pixels_per_point: f32,
1450    ) {
1451        match &mut self.inner {
1452            BackendImpl::Cpu(c) => c.paint_egui(jobs, textures, pixels_per_point),
1453            BackendImpl::Gpu(g) => g.paint_egui(jobs, textures, pixels_per_point),
1454        }
1455    }
1456
1457    /// Register sprite models + instances. The CPU backend builds a
1458    /// per-instance draw list; the GPU backend builds an instanced
1459    /// model registry. Call once at setup (or again to replace).
1460    pub fn set_sprites(&mut self, set: &SpriteSet) -> Vec<SpriteModelId> {
1461        match &mut self.inner {
1462            BackendImpl::Cpu(c) => c.set_sprites(set),
1463            BackendImpl::Gpu(g) => g.set_sprites(set),
1464        }
1465        // A fresh sprite set replaces the instance world, so any
1466        // previously added dynamic instances + models are gone — drop their
1467        // handles and re-seat the model slotmap with `set.models.len()`
1468        // live ids `0..n` (model index = chain id on both backends).
1469        self.dyn_map = DynInstanceMap::default();
1470        self.model_map.reset(set.models.len());
1471        // A full sprite rebuild drops the dynamic + clip layers on both
1472        // backends (the GPU registry is replaced), so reset the clip +
1473        // character maps too.
1474        self.clip_map.reset();
1475        self.char_map.reset();
1476        self.char_instances.clear();
1477        self.streaming_map.reset();
1478        self.streaming_clips.clear();
1479        self.clip_meta.clear();
1480        self.clip_players.clear();
1481        (0..set.models.len() as u32)
1482            .map(|slot| SpriteModelId { slot, gen: 0 })
1483            .collect()
1484    }
1485
1486    /// Re-register one sprite model's geometry after you've edited its
1487    /// content (a carve or recolour of its `kv6`). `model` is the
1488    /// [`SpriteModelId`] handed back by [`set_sprites`](Self::set_sprites);
1489    /// `kv6` is the model's **new** geometry — the caller owns the source
1490    /// of truth (e.g. a dense carve grid the surface-only `kv6` can't
1491    /// represent) and supplies the refreshed mesh here.
1492    ///
1493    /// This is a **backend-agnostic content refresh**, not a GPU upload:
1494    /// the renderer brings its stored model up to date however its active
1495    /// backend needs to. The instance set is left untouched (an edit never
1496    /// moves or adds an instance), so on the GPU backend only that one
1497    /// model's voxel data is re-uploaded — through a slack-backed
1498    /// suballocator, one model's bytes rather than the whole registry —
1499    /// while the CPU backend swaps the cached `kv6` into each instance of
1500    /// the model. Use [`set_sprites`](Self::set_sprites) to add/remove
1501    /// models or change the instance set.
1502    pub fn refresh_sprite_model(&mut self, model: SpriteModelId, kv6: &Kv6) {
1503        let Some(idx) = self.model_map.model_index(model) else {
1504            return; // stale / removed handle → no-op
1505        };
1506        match &mut self.inner {
1507            BackendImpl::Cpu(c) => c.update_sprite_model(idx, kv6),
1508            BackendImpl::Gpu(g) => g.update_sprite_model(idx, kv6),
1509        }
1510    }
1511
1512    /// Like [`refresh_sprite_model`](Self::refresh_sprite_model) but also
1513    /// re-classifies the refreshed voxels into per-voxel material ids by
1514    /// colour (TV.3) via `material_map` — used by the material-aware streaming
1515    /// clip path so a re-uploaded frame keeps its per-voxel materials. An
1516    /// empty map matches `refresh_sprite_model`.
1517    pub fn refresh_sprite_model_with_materials(
1518        &mut self,
1519        model: SpriteModelId,
1520        kv6: &Kv6,
1521        material_map: &[(u32, u8)],
1522    ) {
1523        let Some(idx) = self.model_map.model_index(model) else {
1524            return; // stale / removed handle → no-op
1525        };
1526        match &mut self.inner {
1527            BackendImpl::Cpu(c) => {
1528                c.update_sprite_model_with_materials(idx, kv6, Some(material_map));
1529            }
1530            BackendImpl::Gpu(g) => g.update_sprite_model_with_materials(idx, kv6, material_map),
1531        }
1532    }
1533
1534    /// Add one sprite instance of an already-registered `model` at world
1535    /// `pos`, **incrementally** — the cheap streaming-spawn path that both
1536    /// backends now share (GPU: append to the instance buffer, growing by
1537    /// powers of two; CPU: push one pre-posed [`Sprite`]). Returns a
1538    /// stable [`SpriteInstanceId`] for later removal.
1539    ///
1540    /// `model` must be a [`SpriteModelId`] from the current
1541    /// [`set_sprites`](Self::set_sprites) (a model registered there, even
1542    /// with zero initial instances). Dynamic instances live *after* the
1543    /// static set + any KFA limbs, so register those first.
1544    pub fn add_sprite_instance(&mut self, model: SpriteModelId, pos: [f32; 3]) -> SpriteInstanceId {
1545        self.add_sprite_instance_posed(
1546            model,
1547            DynSpriteTransform {
1548                pos,
1549                ..DynSpriteTransform::default()
1550            },
1551        )
1552    }
1553
1554    /// Add one sprite instance of an already-registered `model`,
1555    /// pre-posed with the orientation in `xf` — the streaming-spawn path
1556    /// for objects that appear mid-flight already rotated (so there's no
1557    /// one-frame axis-aligned flash before the first
1558    /// [`set_sprite_instance_transform`](Self::set_sprite_instance_transform)).
1559    /// Otherwise identical to
1560    /// [`add_sprite_instance`](Self::add_sprite_instance) (which is just
1561    /// this with the identity basis). Returns a stable
1562    /// [`SpriteInstanceId`].
1563    ///
1564    /// A stale/removed `model` handle spawns nothing and returns a handle
1565    /// that is itself already stale (it resolves to no instance). `xf`'s
1566    /// basis must be non-singular; a degenerate one makes the instance
1567    /// silently skip drawing (see [`DynSpriteTransform`]).
1568    pub fn add_sprite_instance_posed(
1569        &mut self,
1570        model: SpriteModelId,
1571        xf: DynSpriteTransform,
1572    ) -> SpriteInstanceId {
1573        let Some(idx) = self.model_map.model_index(model) else {
1574            // Stale model → spawn nothing; hand back a sentinel id that
1575            // resolves to no live instance (a safe no-op everywhere).
1576            return SpriteInstanceId {
1577                slot: u32::MAX,
1578                gen: u32::MAX,
1579            };
1580        };
1581        let dyn_index = match &mut self.inner {
1582            BackendImpl::Cpu(c) => c.add_dyn_instance_posed(idx, xf),
1583            BackendImpl::Gpu(g) => g.add_dyn_instance_posed(idx, xf),
1584        };
1585        self.dyn_map.alloc(dyn_index as u32)
1586    }
1587
1588    /// Remove a dynamic sprite instance added by
1589    /// [`add_sprite_instance`](Self::add_sprite_instance). O(1) on both
1590    /// backends (swap-remove); other dynamic handles stay valid. Returns
1591    /// `false` if the handle is stale / already removed.
1592    pub fn remove_sprite_instance(&mut self, id: SpriteInstanceId) -> bool {
1593        let Some(dyn_index) = self.dyn_map.dyn_index(id) else {
1594            return false;
1595        };
1596        let moved = match &mut self.inner {
1597            BackendImpl::Cpu(c) => c.remove_dyn_instance(dyn_index as usize),
1598            BackendImpl::Gpu(g) => g.remove_dyn_instance(dyn_index as usize),
1599        };
1600        self.dyn_map.remove(id, dyn_index, moved.map(|m| m as u32));
1601        true
1602    }
1603
1604    /// Number of live dynamic sprite instances (those added via
1605    /// [`add_sprite_instance`](Self::add_sprite_instance)).
1606    #[must_use]
1607    pub fn dynamic_sprite_count(&self) -> usize {
1608        self.dyn_map.order.len()
1609    }
1610
1611    /// Register one new sprite **model** incrementally from `kv6`,
1612    /// **without** rebuilding the existing model set — the streaming-in
1613    /// counterpart to [`add_sprite_instance`](Self::add_sprite_instance)
1614    /// for unique generated geometry (procedural asteroids, debris).
1615    /// Returns a stable [`SpriteModelId`] usable immediately with
1616    /// [`add_sprite_instance`](Self::add_sprite_instance) /
1617    /// [`add_sprite_instance_posed`](Self::add_sprite_instance_posed).
1618    ///
1619    /// Works before any [`set_sprites`](Self::set_sprites) (it establishes
1620    /// residency on the GPU backend's first model). The GPU backend
1621    /// appends one LOD chain to the resident registry (amortised O(model
1622    /// Define a global voxel **material** (TV stage): the opacity + blend
1623    /// mode that a per-voxel material id resolves to. The renderer owns one
1624    /// 256-entry palette shared by every model and grid.
1625    ///
1626    /// Id `0` is permanently [`Material::OPAQUE`] — the value every voxel
1627    /// without explicit material data resolves to — and **cannot** be
1628    /// redefined; passing `id == 0` is a no-op that returns `false`. Any
1629    /// other id returns `true`.
1630    ///
1631    /// While no translucent material is defined the renderer stays on the
1632    /// fully-opaque fast path, so this is inert until first called. See
1633    /// `PORTING-TRANSPARENCY.md`.
1634    pub fn define_material(&mut self, id: u8, mat: Material) -> bool {
1635        match &mut self.inner {
1636            BackendImpl::Cpu(c) => c.define_material(id, mat),
1637            BackendImpl::Gpu(g) => g.define_material(id, mat),
1638        }
1639    }
1640
1641    /// The [`Material`] currently at palette `id` ([`Material::OPAQUE`] for
1642    /// any id never passed to [`define_material`](Self::define_material)).
1643    #[must_use]
1644    pub fn material(&self, id: u8) -> Material {
1645        match &self.inner {
1646            BackendImpl::Cpu(c) => c.material(id),
1647            BackendImpl::Gpu(g) => g.material(id),
1648        }
1649    }
1650
1651    /// Set the **terrain** colour→material map (TV.4): pairs of `(rgb,
1652    /// material_id)` that make matching-colour world (grid) voxels translucent
1653    /// — glass walls, water pools. The materials themselves are defined via
1654    /// [`define_material`](Self::define_material). An empty map (the default)
1655    /// keeps all terrain opaque. The CPU backend composites these today; the
1656    /// GPU backend renders them once the TV.6 device path lands.
1657    pub fn set_terrain_materials(&mut self, map: &[(u32, u8)]) {
1658        match &mut self.inner {
1659            BackendImpl::Cpu(c) => c.set_terrain_materials(map),
1660            BackendImpl::Gpu(g) => g.set_terrain_materials(map),
1661        }
1662    }
1663
1664    /// voxels)); the CPU backend pushes an axis-aligned template.
1665    pub fn add_sprite_model(&mut self, kv6: &Kv6) -> SpriteModelId {
1666        let model_index = match &mut self.inner {
1667            BackendImpl::Cpu(c) => c.add_model(kv6),
1668            BackendImpl::Gpu(g) => g.add_model(kv6),
1669        };
1670        self.model_map.alloc(model_index as u32)
1671    }
1672
1673    /// Register a **mixed-material** sprite model (TV.3): `material_map` pairs
1674    /// a voxel RGB colour (`0xRRGGBB`) with a material id (defined via
1675    /// [`define_material`](Self::define_material)), so a single model can mix
1676    /// opaque and translucent voxels — an opaque window frame around glass, a
1677    /// bottle around a translucent potion. Voxels whose colour isn't in the
1678    /// map are opaque (material 0). Like [`add_sprite_model`](Self::add_sprite_model)
1679    /// otherwise.
1680    ///
1681    /// The CPU backend composites per-voxel materials today; the GPU backend
1682    /// carries the data and renders per-voxel materials once the TV.3b device
1683    /// path lands (until then it uses the instance's uniform material).
1684    pub fn add_sprite_model_with_materials(
1685        &mut self,
1686        kv6: &Kv6,
1687        material_map: &[(u32, u8)],
1688    ) -> SpriteModelId {
1689        let model_index = match &mut self.inner {
1690            BackendImpl::Cpu(c) => c.add_model_with_materials(kv6, material_map),
1691            BackendImpl::Gpu(g) => g.add_model_with_materials(kv6, material_map),
1692        };
1693        self.model_map.alloc(model_index as u32)
1694    }
1695
1696    /// Remove a registered sprite model, freeing its voxel data. Returns
1697    /// `false` if `id` is stale / already removed.
1698    ///
1699    /// The model's slot is tombstoned **in place**: its id is never
1700    /// reused, so every other [`SpriteModelId`] stays valid (no remap).
1701    /// Existing instances of the removed model are **not** dropped here —
1702    /// they linger but draw as nothing on the GPU backend (the CPU
1703    /// backend keeps each instance's own kv6 clone, so they keep drawing
1704    /// until removed via
1705    /// [`remove_sprite_instance`](Self::remove_sprite_instance)); remove
1706    /// them when convenient. Call
1707    /// [`compact_sprite_models`](Self::compact_sprite_models) afterwards
1708    /// to reclaim the GPU buffer holes.
1709    pub fn remove_sprite_model(&mut self, id: SpriteModelId) -> bool {
1710        let Some(idx) = self.model_map.model_index(id) else {
1711            return false;
1712        };
1713        match &mut self.inner {
1714            BackendImpl::Cpu(c) => c.remove_model(idx),
1715            BackendImpl::Gpu(g) => g.remove_model(idx),
1716        }
1717        self.model_map.remove(id)
1718    }
1719
1720    /// Reclaim the GPU buffer space left by
1721    /// [`remove_sprite_model`](Self::remove_sprite_model) by repacking the
1722    /// resident registry to its live models only. Model ids are preserved
1723    /// (no remap). O(live voxel volume) — call it when many models have
1724    /// been removed, not every frame. No-op on the CPU backend (which
1725    /// keeps cheap empty placeholders) and when nothing was removed.
1726    pub fn compact_sprite_models(&mut self) {
1727        match &mut self.inner {
1728            BackendImpl::Cpu(c) => c.compact_models(),
1729            BackendImpl::Gpu(g) => g.compact_models(),
1730        }
1731    }
1732
1733    /// Update one dynamic instance's full pose (position + orientation)
1734    /// for this frame. `id` is from
1735    /// [`add_sprite_instance`](Self::add_sprite_instance) /
1736    /// [`add_sprite_instance_posed`](Self::add_sprite_instance_posed). A
1737    /// stale / removed handle is a no-op.
1738    ///
1739    /// For many instances per frame prefer
1740    /// [`set_sprite_instance_transforms`](Self::set_sprite_instance_transforms):
1741    /// the GPU backend flushes all pending pose changes to the device
1742    /// once per [`render`](Self::render), so a per-instance call here is
1743    /// still O(1) device work, but the batch variant avoids re-walking
1744    /// the slotmap.
1745    pub fn set_sprite_instance_transform(&mut self, id: SpriteInstanceId, xf: DynSpriteTransform) {
1746        let Some(dyn_index) = self.dyn_map.dyn_index(id) else {
1747            return;
1748        };
1749        match &mut self.inner {
1750            BackendImpl::Cpu(c) => c.set_dyn_instance_transform(dyn_index as usize, xf),
1751            BackendImpl::Gpu(g) => g.set_dyn_instance_transform(dyn_index as usize, xf),
1752        }
1753    }
1754
1755    /// Batch form of
1756    /// [`set_sprite_instance_transform`](Self::set_sprite_instance_transform)
1757    /// — apply many `(instance, pose)` updates in one call. Stale handles
1758    /// in `updates` are skipped. On the GPU backend this marks the
1759    /// instance buffer dirty once and uploads the new poses a single time
1760    /// at the next [`render`](Self::render), so spinning a whole cluster
1761    /// of instances per frame is one device upload, not one per instance.
1762    pub fn set_sprite_instance_transforms(
1763        &mut self,
1764        updates: &[(SpriteInstanceId, DynSpriteTransform)],
1765    ) {
1766        for &(id, xf) in updates {
1767            let Some(dyn_index) = self.dyn_map.dyn_index(id) else {
1768                continue;
1769            };
1770            match &mut self.inner {
1771                BackendImpl::Cpu(c) => c.set_dyn_instance_transform(dyn_index as usize, xf),
1772                BackendImpl::Gpu(g) => g.set_dyn_instance_transform(dyn_index as usize, xf),
1773            }
1774        }
1775    }
1776
1777    /// Set sprite instance `id`'s voxel-material id (TV stage) — indexes the
1778    /// global palette defined via [`define_material`](Self::define_material)
1779    /// for this whole instance's opacity + blend mode. `0` (the default) is
1780    /// opaque. Stale handles are ignored.
1781    ///
1782    /// Only the CPU backend composites translucent sprites today; the GPU
1783    /// backend retains the value for the forthcoming device-side path (see
1784    /// `PORTING-TRANSPARENCY.md`).
1785    pub fn set_sprite_instance_material(&mut self, id: SpriteInstanceId, material: u8) {
1786        let Some(dyn_index) = self.dyn_map.dyn_index(id) else {
1787            return;
1788        };
1789        match &mut self.inner {
1790            BackendImpl::Cpu(c) => c.set_dyn_instance_material(dyn_index as usize, material),
1791            BackendImpl::Gpu(g) => g.set_dyn_instance_material(dyn_index as usize, material),
1792        }
1793    }
1794
1795    /// Set sprite instance `id`'s per-instance alpha multiplier (TV stage),
1796    /// `0..=255` (`255` = unscaled). Scales the material's opacity so an
1797    /// effect can fade out by cheap per-frame updates without re-uploading
1798    /// its volume. Stale handles are ignored.
1799    pub fn set_sprite_instance_alpha(&mut self, id: SpriteInstanceId, alpha_mul: u8) {
1800        let Some(dyn_index) = self.dyn_map.dyn_index(id) else {
1801            return;
1802        };
1803        match &mut self.inner {
1804            BackendImpl::Cpu(c) => c.set_dyn_instance_alpha(dyn_index as usize, alpha_mul),
1805            BackendImpl::Gpu(g) => g.set_dyn_instance_alpha(dyn_index as usize, alpha_mul),
1806        }
1807    }
1808
1809    // ---- animated voxel clips (VCL.4) ------------------------------------
1810
1811    /// Register an animated voxel clip ("GIF/MP4 for voxels"): decode all
1812    /// its frames and upload the flipbook to the active backend (GPU: one
1813    /// LOD chain per frame; CPU: a cached dense grid per frame). Returns a
1814    /// [`VoxelClipId`] to spawn instances of it via
1815    /// [`add_clip_instance_posed`](Self::add_clip_instance_posed).
1816    ///
1817    /// Build the [`DecodedClip`] from a `.rvc` via
1818    /// [`VoxelClip::decode`](roxlap_formats::voxel_clip::VoxelClip::decode).
1819    /// Like [`add_sprite_model`](Self::add_sprite_model), this works before
1820    /// any [`set_sprites`](Self::set_sprites); a later `set_sprites`
1821    /// **drops** all registered clips (re-register afterwards).
1822    pub fn add_voxel_clip(&mut self, clip: &DecodedClip) -> VoxelClipId {
1823        self.add_voxel_clip_with_materials(clip, &[])
1824    }
1825
1826    /// Register a **mixed-material** animated voxel clip (TV.3): the clip
1827    /// analogue of
1828    /// [`add_sprite_model_with_materials`](Self::add_sprite_model_with_materials).
1829    /// `material_map` pairs a voxel RGB colour (`0xRRGGBB`) with a material id
1830    /// (defined via [`define_material`](Self::define_material)), classifying
1831    /// every frame's voxels so an animated clip can mix opaque and translucent
1832    /// voxels — an opaque torch handle around an additive flame, a spinning
1833    /// glass orb. Voxels whose colour isn't in the map stay opaque
1834    /// (material 0). Like [`add_voxel_clip`](Self::add_voxel_clip) otherwise.
1835    pub fn add_voxel_clip_with_materials(
1836        &mut self,
1837        clip: &DecodedClip,
1838        material_map: &[(u32, u8)],
1839    ) -> VoxelClipId {
1840        let clip_index = match &mut self.inner {
1841            BackendImpl::Cpu(c) => c.add_voxel_clip_with_materials(clip, material_map),
1842            BackendImpl::Gpu(g) => g.add_voxel_clip_with_materials(clip, material_map),
1843        };
1844        // Capture metadata for editor queries + #6 auto-play; clip indices
1845        // are sequential and parallel to `clip_meta`.
1846        debug_assert_eq!(clip_index, self.clip_meta.len());
1847        self.clip_meta.push(ClipMeta {
1848            dims: clip.dims,
1849            pivot: clip.pivot,
1850            voxel_world_size: clip.voxel_world_size,
1851            durations: clip.durations.clone(),
1852            loop_mode: clip.loop_mode,
1853            material_map: material_map.to_vec(),
1854        });
1855        self.clip_map.alloc(clip_index as u32)
1856    }
1857
1858    /// Remove a registered clip, freeing its per-frame volumes. Instances
1859    /// of it linger but draw nothing until removed via
1860    /// [`remove_sprite_instance`](Self::remove_sprite_instance). Returns
1861    /// `false` if `id` is stale / already removed.
1862    pub fn remove_voxel_clip(&mut self, id: VoxelClipId) -> bool {
1863        let Some(clip_index) = self.clip_map.clip_index(id) else {
1864            return false;
1865        };
1866        match &mut self.inner {
1867            BackendImpl::Cpu(c) => c.remove_voxel_clip(clip_index),
1868            BackendImpl::Gpu(g) => g.remove_voxel_clip(clip_index),
1869        }
1870        self.clip_map.remove(id)
1871    }
1872
1873    /// Spawn an instance of clip `clip`, posed by `xf`, starting on frame
1874    /// 0. Returns a [`SpriteInstanceId`] — a clip instance is a dynamic
1875    /// sprite instance, so move it with
1876    /// [`set_sprite_instance_transform`](Self::set_sprite_instance_transform),
1877    /// advance its frame with
1878    /// [`set_clip_instance_frame`](Self::set_clip_instance_frame), and drop
1879    /// it with [`remove_sprite_instance`](Self::remove_sprite_instance).
1880    /// A stale `clip` handle yields an instance id that resolves to nothing
1881    /// (a safe no-op everywhere).
1882    ///
1883    /// This instance has **no playback clock**: drive its frame yourself via
1884    /// [`set_clip_instance_frame`](Self::set_clip_instance_frame) (frame-based
1885    /// scrubbing). For *clock*-based control — auto-advance, play/pause, or
1886    /// [`set_clip_instance_clock_ms`](Self::set_clip_instance_clock_ms)
1887    /// scrubbing — spawn with
1888    /// [`add_clip_instance_playing`](Self::add_clip_instance_playing) instead
1889    /// (the player-control methods no-op on an instance with no player).
1890    pub fn add_clip_instance_posed(
1891        &mut self,
1892        clip: VoxelClipId,
1893        xf: DynSpriteTransform,
1894    ) -> SpriteInstanceId {
1895        let Some(clip_index) = self.clip_map.clip_index(clip) else {
1896            return SpriteInstanceId {
1897                slot: u32::MAX,
1898                gen: u32::MAX,
1899            };
1900        };
1901        let dyn_index = match &mut self.inner {
1902            BackendImpl::Cpu(c) => c.add_clip_instance(clip_index, xf),
1903            BackendImpl::Gpu(g) => g.add_clip_instance(clip_index, xf),
1904        };
1905        self.dyn_map.alloc(dyn_index as u32)
1906    }
1907
1908    /// Select which frame a clip instance shows — the per-frame playback
1909    /// step. Cheap on both backends (GPU: swap the instance's model id;
1910    /// CPU: select the cached frame grid), with no volume re-upload. Drive
1911    /// it from a playback clock via
1912    /// [`DecodedClip::frame_at`](roxlap_formats::voxel_clip::DecodedClip::frame_at).
1913    /// No-op on a stale id or a non-clip instance.
1914    pub fn set_clip_instance_frame(&mut self, id: SpriteInstanceId, frame: u32) {
1915        let Some(dyn_index) = self.dyn_map.dyn_index(id) else {
1916            return;
1917        };
1918        match &mut self.inner {
1919            BackendImpl::Cpu(c) => c.set_clip_frame(dyn_index as usize, frame as usize),
1920            BackendImpl::Gpu(g) => g.set_clip_frame(dyn_index as usize, frame as usize),
1921        }
1922    }
1923
1924    // ---- clip queries (editor inspector) ---------------------------------
1925
1926    /// Frame count of a registered flipbook clip, or `None` if `id` is
1927    /// stale. (Same as `clip_metadata(id)?.frame_count`, without the clone.)
1928    #[must_use]
1929    pub fn clip_frame_count(&self, id: VoxelClipId) -> Option<usize> {
1930        let idx = self.clip_map.clip_index(id)?;
1931        Some(self.clip_meta[idx].durations.len())
1932    }
1933
1934    /// Inspector metadata (dims / pivot / scale / loop mode / per-frame
1935    /// durations) of a registered flipbook clip, or `None` if `id` is stale
1936    /// — so an editor needn't shadow the source [`DecodedClip`].
1937    #[must_use]
1938    pub fn clip_metadata(&self, id: VoxelClipId) -> Option<ClipMetadata> {
1939        let idx = self.clip_map.clip_index(id)?;
1940        let m = &self.clip_meta[idx];
1941        Some(ClipMetadata {
1942            dims: m.dims,
1943            pivot: m.pivot,
1944            voxel_world_size: m.voxel_world_size,
1945            loop_mode: m.loop_mode,
1946            frame_count: m.durations.len(),
1947            durations: m.durations.clone(),
1948            total_ms: m
1949                .durations
1950                .iter()
1951                .fold(0u32, |acc, &d| acc.saturating_add(d)),
1952        })
1953    }
1954
1955    /// Which frame a clip instance is currently showing (the timeline
1956    /// scrubber's read-back), or `None` if `id` isn't a live clip instance.
1957    #[must_use]
1958    pub fn get_clip_instance_frame(&self, id: SpriteInstanceId) -> Option<u32> {
1959        let dyn_index = self.dyn_map.dyn_index(id)? as usize;
1960        let frame = match &self.inner {
1961            BackendImpl::Cpu(c) => c.clip_instance_frame(dyn_index),
1962            BackendImpl::Gpu(g) => g.clip_instance_frame(dyn_index),
1963        }?;
1964        u32::try_from(frame).ok()
1965    }
1966
1967    /// Re-upload a **single** `frame` of registered clip `id` in place — the
1968    /// editor's one-voxel paint, O(1 frame) instead of `remove_voxel_clip` +
1969    /// `add_voxel_clip` (which rebuilds all N volumes). `vf` must fit the
1970    /// clip's fixed `dims`. Returns `false` on a stale `id`, an out-of-range
1971    /// `frame`, or a frame that fails the clip's layout (so it can't corrupt
1972    /// the flipbook).
1973    pub fn update_clip_frame(&mut self, id: VoxelClipId, frame: u32, vf: &VoxelFrame) -> bool {
1974        let Some(clip_index) = self.clip_map.clip_index(id) else {
1975            return false;
1976        };
1977        let m = &self.clip_meta[clip_index];
1978        let (dims, pivot, vws) = (m.dims, m.pivot, m.voxel_world_size);
1979        if vf.validate(dims).is_err() {
1980            return false;
1981        }
1982        // Re-classify with the clip's registered colour→material map (TV.3) so
1983        // an in-place frame edit keeps the clip's per-voxel materials.
1984        let material_map = m.material_map.clone();
1985        let frame = frame as usize;
1986        match &mut self.inner {
1987            BackendImpl::Cpu(c) => {
1988                c.update_clip_frame(clip_index, frame, vf, dims, pivot, &material_map)
1989            }
1990            BackendImpl::Gpu(g) => {
1991                g.update_clip_frame(clip_index, frame, vf, dims, pivot, vws, &material_map)
1992            }
1993        }
1994    }
1995
1996    // ---- streaming voxel clips (#3) --------------------------------------
1997
1998    /// Register a **streaming** voxel clip — `O(1-frame)` memory (one sprite
1999    /// model + the compact encoded stream) rather than the N-volume flipbook
2000    /// [`add_voxel_clip`](Self::add_voxel_clip) builds, for huge clips where
2001    /// N frames are too costly to hold resident. Builds the model from frame
2002    /// 0; advance it with
2003    /// [`set_streaming_clip_frame`](Self::set_streaming_clip_frame). Spawn
2004    /// instances with
2005    /// [`add_streaming_clip_instance`](Self::add_streaming_clip_instance) —
2006    /// note that, unlike a flipbook, **all** instances of a streaming clip
2007    /// share its one model and so always show the same (current) frame.
2008    ///
2009    /// Takes the *encoded* [`VoxelClip`] (not a [`DecodedClip`]) — the whole
2010    /// point is to avoid materialising every frame.
2011    ///
2012    /// # Errors
2013    /// [`DecodeError`] if the clip's frame stream is empty or doesn't begin
2014    /// with a keyframe.
2015    pub fn add_streaming_clip(&mut self, clip: &VoxelClip) -> Result<StreamingClipId, DecodeError> {
2016        self.add_streaming_clip_with_materials(clip, &[])
2017    }
2018
2019    /// Register a **mixed-material** streaming voxel clip (TV.3): the streaming
2020    /// analogue of
2021    /// [`add_voxel_clip_with_materials`](Self::add_voxel_clip_with_materials).
2022    /// `material_map` pairs a voxel RGB colour with a material id (defined via
2023    /// [`define_material`](Self::define_material)); it is re-applied on every
2024    /// per-frame re-upload, so the single streamed model keeps its per-voxel
2025    /// materials as the clip advances. An empty map is identical to
2026    /// [`add_streaming_clip`](Self::add_streaming_clip).
2027    ///
2028    /// # Errors
2029    /// As [`add_streaming_clip`](Self::add_streaming_clip).
2030    pub fn add_streaming_clip_with_materials(
2031        &mut self,
2032        clip: &VoxelClip,
2033        material_map: &[(u32, u8)],
2034    ) -> Result<StreamingClipId, DecodeError> {
2035        let cursor = StreamingClip::new(clip)?;
2036        let dims = cursor.dims();
2037        let pivot = cursor.pivot();
2038        let kv6 = cursor.current_frame().to_kv6(dims, pivot);
2039        let model = self.add_sprite_model_with_materials(&kv6, material_map);
2040        let index = self.streaming_clips.len() as u32;
2041        self.streaming_clips.push(Some(StreamingClipState {
2042            cursor,
2043            model,
2044            dims,
2045            pivot,
2046            material_map: material_map.to_vec(),
2047        }));
2048        Ok(self.streaming_map.alloc(index))
2049    }
2050
2051    /// Spawn an instance of streaming clip `id`, posed by `xf`. Returns a
2052    /// [`SpriteInstanceId`] — move it with
2053    /// [`set_sprite_instance_transform`](Self::set_sprite_instance_transform)
2054    /// and drop it with
2055    /// [`remove_sprite_instance`](Self::remove_sprite_instance), like any
2056    /// dynamic instance. All instances of one streaming clip share its single
2057    /// model. A stale `id` yields a no-op instance handle.
2058    pub fn add_streaming_clip_instance(
2059        &mut self,
2060        id: StreamingClipId,
2061        xf: DynSpriteTransform,
2062    ) -> StreamingInstanceId {
2063        let model = self
2064            .streaming_map
2065            .index(id)
2066            .and_then(|idx| self.streaming_clips[idx].as_ref())
2067            .map(|s| s.model);
2068        let inst = match model {
2069            Some(model) => self.add_sprite_instance_posed(model, xf),
2070            None => SpriteInstanceId {
2071                slot: u32::MAX,
2072                gen: u32::MAX,
2073            },
2074        };
2075        StreamingInstanceId(inst)
2076    }
2077
2078    /// Re-pose a streaming-clip instance (world transform). No-op on a stale
2079    /// handle.
2080    pub fn set_streaming_instance_transform(
2081        &mut self,
2082        id: StreamingInstanceId,
2083        xf: DynSpriteTransform,
2084    ) {
2085        self.set_sprite_instance_transform(id.0, xf);
2086    }
2087
2088    /// Remove a streaming-clip instance. Returns `false` if `id` is stale.
2089    pub fn remove_streaming_instance(&mut self, id: StreamingInstanceId) -> bool {
2090        self.remove_sprite_instance(id.0)
2091    }
2092
2093    /// Advance a streaming clip to `frame`: seek the cursor and re-upload its
2094    /// single model — the per-frame streaming step (one volume re-upload,
2095    /// vs the flipbook's cheap model-select). Updates **every** instance of
2096    /// the clip at once. Drive it from a clock via
2097    /// [`frame_at`](roxlap_formats::voxel_clip::frame_at). No-op on a stale
2098    /// id; `frame` is clamped to the last.
2099    pub fn set_streaming_clip_frame(&mut self, id: StreamingClipId, frame: u32) {
2100        let Some(idx) = self.streaming_map.index(id) else {
2101            return;
2102        };
2103        let Some((model, kv6, material_map)) = self.streaming_clips[idx].as_mut().and_then(|s| {
2104            let vf = s.cursor.seek(frame as usize).ok()?;
2105            Some((s.model, vf.to_kv6(s.dims, s.pivot), s.material_map.clone()))
2106        }) else {
2107            return;
2108        };
2109        self.refresh_sprite_model_with_materials(model, &kv6, &material_map);
2110    }
2111
2112    /// Remove a streaming clip: free its model and drop the cursor (the
2113    /// memory win for huge clips). Instances linger but draw nothing until
2114    /// removed. Returns `false` if `id` is stale / already removed.
2115    pub fn remove_streaming_clip(&mut self, id: StreamingClipId) -> bool {
2116        let Some(idx) = self.streaming_map.index(id) else {
2117            return false;
2118        };
2119        let model = self.streaming_clips[idx].as_ref().map(|s| s.model);
2120        self.streaming_clips[idx] = None;
2121        if let Some(model) = model {
2122            self.remove_sprite_model(model);
2123        }
2124        self.streaming_map.remove(id)
2125    }
2126
2127    // ---- auto-advancing clip players (#6) --------------------------------
2128
2129    /// Spawn a flipbook-clip instance that **plays itself**: like
2130    /// [`add_clip_instance_posed`](Self::add_clip_instance_posed), but the
2131    /// facade tracks a playback clock so a single
2132    /// [`advance_voxel_clips`](Self::advance_voxel_clips) call advances every
2133    /// such instance — no per-frame `frame_at` + `set_clip_instance_frame`
2134    /// bookkeeping in the host. `speed_q8` is the Q8 playback rate (`256` =
2135    /// 1×); `start_phase_ms` offsets the clock (stagger copies of one clip).
2136    /// A stale `clip` yields a no-op instance handle and no player.
2137    pub fn add_clip_instance_playing(
2138        &mut self,
2139        clip: VoxelClipId,
2140        xf: DynSpriteTransform,
2141        speed_q8: i32,
2142        start_phase_ms: u32,
2143    ) -> SpriteInstanceId {
2144        let Some(clip_index) = self.clip_map.clip_index(clip) else {
2145            return SpriteInstanceId {
2146                slot: u32::MAX,
2147                gen: u32::MAX,
2148            };
2149        };
2150        let meta = &self.clip_meta[clip_index];
2151        let clock = ClipClock {
2152            durations: meta.durations.clone(),
2153            loop_mode: meta.loop_mode,
2154            speed_q8,
2155            clock_ms: f64::from(start_phase_ms),
2156        };
2157        let inst = self.add_clip_instance_posed(clip, xf);
2158        self.clip_players.push(ClipPlayer {
2159            target: PlayerTarget::Flipbook(inst),
2160            clock,
2161            paused: false,
2162        });
2163        inst
2164    }
2165
2166    /// Give a streaming clip ([`add_streaming_clip`](Self::add_streaming_clip))
2167    /// its own playback clock, advanced by
2168    /// [`advance_voxel_clips`](Self::advance_voxel_clips). A streaming clip's
2169    /// frame is per-clip (all its instances share one model), so this is
2170    /// keyed on the clip, not an instance — register instances separately
2171    /// with
2172    /// [`add_streaming_clip_instance`](Self::add_streaming_clip_instance).
2173    /// No-op on a stale `clip`.
2174    ///
2175    /// Control the player (play/pause/scrub) via
2176    /// [`set_streaming_clip_paused`](Self::set_streaming_clip_paused) /
2177    /// [`set_streaming_clip_speed`](Self::set_streaming_clip_speed) /
2178    /// [`set_streaming_clip_clock_ms`](Self::set_streaming_clip_clock_ms), the
2179    /// per-clip analogues of the flipbook `set_clip_instance_*` methods.
2180    pub fn play_streaming_clip(
2181        &mut self,
2182        clip: StreamingClipId,
2183        speed_q8: i32,
2184        start_phase_ms: u32,
2185    ) {
2186        let Some(idx) = self.streaming_map.index(clip) else {
2187            return;
2188        };
2189        let Some(state) = self.streaming_clips[idx].as_ref() else {
2190            return;
2191        };
2192        let clock = ClipClock {
2193            durations: state.cursor.durations().to_vec(),
2194            loop_mode: state.cursor.loop_mode(),
2195            speed_q8,
2196            clock_ms: f64::from(start_phase_ms),
2197        };
2198        self.clip_players.push(ClipPlayer {
2199            target: PlayerTarget::Streaming(clip),
2200            clock,
2201            paused: false,
2202        });
2203    }
2204
2205    /// Advance every auto-playing clip ([`add_clip_instance_playing`] /
2206    /// [`play_streaming_clip`]) by `dt` seconds: tick each clock, resolve its
2207    /// frame via [`frame_at`](roxlap_formats::voxel_clip::frame_at), and
2208    /// apply it. Players whose instance / clip was removed are pruned. Call
2209    /// once per frame.
2210    ///
2211    /// [`add_clip_instance_playing`]: Self::add_clip_instance_playing
2212    /// [`play_streaming_clip`]: Self::play_streaming_clip
2213    pub fn advance_voxel_clips(&mut self, dt: f64) {
2214        // Phase 1: tick clocks → (target, frame), pruning dead players.
2215        // Borrow only the maps (disjoint from `clip_players`).
2216        let dyn_map = &self.dyn_map;
2217        let streaming_map = &self.streaming_map;
2218        let mut updates: Vec<(PlayerTarget, u32)> = Vec::new();
2219        self.clip_players.retain_mut(|p| {
2220            let alive = match p.target {
2221                PlayerTarget::Flipbook(inst) => dyn_map.dyn_index(inst).is_some(),
2222                PlayerTarget::Streaming(clip) => streaming_map.index(clip).is_some(),
2223            };
2224            if !alive {
2225                return false;
2226            }
2227            // A paused player keeps its clock + frame (the editor's pause).
2228            if !p.paused {
2229                updates.push((p.target, p.clock.tick(dt)));
2230            }
2231            true
2232        });
2233        // Phase 2: apply (borrows self mutably, disjoint from the above).
2234        for (target, frame) in updates {
2235            self.apply_player_frame(target, frame);
2236        }
2237    }
2238
2239    /// Apply a resolved frame to a player's target (flipbook instance vs.
2240    /// streaming clip).
2241    fn apply_player_frame(&mut self, target: PlayerTarget, frame: u32) {
2242        match target {
2243            PlayerTarget::Flipbook(inst) => self.set_clip_instance_frame(inst, frame),
2244            PlayerTarget::Streaming(clip) => self.set_streaming_clip_frame(clip, frame),
2245        }
2246    }
2247
2248    /// Find the auto-player driving flipbook instance `inst`, if any.
2249    fn flipbook_player_mut(&mut self, inst: SpriteInstanceId) -> Option<&mut ClipPlayer> {
2250        self.clip_players
2251            .iter_mut()
2252            .find(|p| matches!(p.target, PlayerTarget::Flipbook(i) if i == inst))
2253    }
2254
2255    /// Pause / resume the auto-player driving clip instance `id` (the
2256    /// editor's play/pause). No-op if `id` has no player.
2257    pub fn set_clip_instance_paused(&mut self, id: SpriteInstanceId, paused: bool) {
2258        if let Some(p) = self.flipbook_player_mut(id) {
2259            p.paused = paused;
2260        }
2261    }
2262
2263    /// Whether clip instance `id`'s auto-player is paused, or `None` if it
2264    /// has no player.
2265    #[must_use]
2266    pub fn is_clip_instance_paused(&self, id: SpriteInstanceId) -> Option<bool> {
2267        self.clip_players
2268            .iter()
2269            .find(|p| matches!(p.target, PlayerTarget::Flipbook(i) if i == id))
2270            .map(|p| p.paused)
2271    }
2272
2273    /// Set the playback speed (Q8: `256` = 1×, negative = reverse) of clip
2274    /// instance `id`'s auto-player. No-op if `id` has no player.
2275    pub fn set_clip_instance_speed(&mut self, id: SpriteInstanceId, speed_q8: i32) {
2276        if let Some(p) = self.flipbook_player_mut(id) {
2277            p.clock.speed_q8 = speed_q8;
2278        }
2279    }
2280
2281    /// **Scrub**: set clip instance `id`'s playback clock to `clock_ms` and
2282    /// immediately show the matching frame (works while paused). No-op if
2283    /// `id` has no player.
2284    pub fn set_clip_instance_clock_ms(&mut self, id: SpriteInstanceId, clock_ms: f64) {
2285        let Some((target, frame)) = self.flipbook_player_mut(id).map(|p| {
2286            p.clock.clock_ms = clock_ms;
2287            #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
2288            let frame = frame_at(
2289                &p.clock.durations,
2290                p.clock.loop_mode,
2291                clock_ms.max(0.0) as u32,
2292            ) as u32;
2293            (p.target, frame)
2294        }) else {
2295            return;
2296        };
2297        self.apply_player_frame(target, frame);
2298    }
2299
2300    /// Clip instance `id`'s current playback-clock position (ms), or `None`
2301    /// if it has no player — the scrubber's read-back.
2302    #[must_use]
2303    pub fn clip_instance_clock_ms(&self, id: SpriteInstanceId) -> Option<f64> {
2304        self.clip_players
2305            .iter()
2306            .find(|p| matches!(p.target, PlayerTarget::Flipbook(i) if i == id))
2307            .map(|p| p.clock.clock_ms)
2308    }
2309
2310    /// Find the auto-player driving streaming clip `clip`, if any (a player
2311    /// registered via [`play_streaming_clip`](Self::play_streaming_clip)).
2312    fn streaming_player_mut(&mut self, clip: StreamingClipId) -> Option<&mut ClipPlayer> {
2313        self.clip_players
2314            .iter_mut()
2315            .find(|p| matches!(p.target, PlayerTarget::Streaming(c) if c == clip))
2316    }
2317
2318    /// Pause / resume a streaming clip's auto-player
2319    /// ([`play_streaming_clip`](Self::play_streaming_clip)). No-op if `clip`
2320    /// has no player.
2321    pub fn set_streaming_clip_paused(&mut self, clip: StreamingClipId, paused: bool) {
2322        if let Some(p) = self.streaming_player_mut(clip) {
2323            p.paused = paused;
2324        }
2325    }
2326
2327    /// Whether streaming clip `clip`'s auto-player is paused, or `None` if it
2328    /// has no player.
2329    #[must_use]
2330    pub fn is_streaming_clip_paused(&self, clip: StreamingClipId) -> Option<bool> {
2331        self.clip_players
2332            .iter()
2333            .find(|p| matches!(p.target, PlayerTarget::Streaming(c) if c == clip))
2334            .map(|p| p.paused)
2335    }
2336
2337    /// Set the playback speed (Q8: `256` = 1×, negative = reverse) of
2338    /// streaming clip `clip`'s auto-player. No-op if `clip` has no player.
2339    pub fn set_streaming_clip_speed(&mut self, clip: StreamingClipId, speed_q8: i32) {
2340        if let Some(p) = self.streaming_player_mut(clip) {
2341            p.clock.speed_q8 = speed_q8;
2342        }
2343    }
2344
2345    /// **Scrub** a streaming clip: set its auto-player's clock to `clock_ms`
2346    /// and immediately show the matching frame (works while paused). No-op if
2347    /// `clip` has no player.
2348    pub fn set_streaming_clip_clock_ms(&mut self, clip: StreamingClipId, clock_ms: f64) {
2349        let Some((target, frame)) = self.streaming_player_mut(clip).map(|p| {
2350            p.clock.clock_ms = clock_ms;
2351            #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
2352            let frame = frame_at(
2353                &p.clock.durations,
2354                p.clock.loop_mode,
2355                clock_ms.max(0.0) as u32,
2356            ) as u32;
2357            (p.target, frame)
2358        }) else {
2359            return;
2360        };
2361        self.apply_player_frame(target, frame);
2362    }
2363
2364    /// Streaming clip `clip`'s current playback-clock position (ms), or
2365    /// `None` if it has no player — the scrubber's read-back.
2366    #[must_use]
2367    pub fn streaming_clip_clock_ms(&self, clip: StreamingClipId) -> Option<f64> {
2368        self.clip_players
2369            .iter()
2370            .find(|p| matches!(p.target, PlayerTarget::Streaming(c) if c == clip))
2371            .map(|p| p.clock.clock_ms)
2372    }
2373
2374    // ---- animated characters (VCL.6) -------------------------------------
2375
2376    /// Register an animated character (RKC v3): upload its meshes as sprite
2377    /// models + its embedded voxel clips as flipbooks, then spawn one
2378    /// renderer instance **per bone attachment** — a static mesh sits at
2379    /// its bone, a clip attachment plays back on its own clock. `clip`
2380    /// selects a skeletal animation clip to drive the bones (`None` =
2381    /// rest pose). Returns a [`CharacterId`]; advance it each frame with
2382    /// [`advance_character`](Self::advance_character).
2383    ///
2384    /// Like clips, this works before any [`set_sprites`](Self::set_sprites);
2385    /// a later `set_sprites` drops all registered characters.
2386    pub fn add_character(&mut self, ch: &Character, clip: Option<usize>) -> CharacterId {
2387        // 1. Meshes → sprite models.
2388        let model_ids: Vec<SpriteModelId> =
2389            ch.meshes.iter().map(|m| self.add_sprite_model(m)).collect();
2390        // 2. Voxel clips → flipbooks; keep each one's timing for the clocks.
2391        let clip_regs: Vec<Option<(VoxelClipId, Vec<u32>, LoopMode)>> = ch
2392            .voxel_clips
2393            .iter()
2394            .map(|vc| {
2395                vc.decode().ok().map(|d| {
2396                    let id = self.add_voxel_clip(&d);
2397                    (id, d.durations, d.loop_mode)
2398                })
2399            })
2400            .collect();
2401        // 3. Build + solve the skeleton (rest pose → bone transforms).
2402        let mut skeleton = ch.to_kfa_sprite(clip);
2403        solve_kfa_limbs(&mut skeleton);
2404        // 4. One instance per attachment, posed by bone × local_offset.
2405        let mut attaches = Vec::new();
2406        for (bi, bone) in ch.bones.iter().enumerate() {
2407            let limb = &skeleton.limbs[bi];
2408            for att in &bone.attachments {
2409                let (s, h, f, p) =
2410                    compose_attachment(limb.s, limb.h, limb.f, limb.p, &att.local_offset);
2411                let xf = DynSpriteTransform {
2412                    pos: p,
2413                    right: s,
2414                    up: h,
2415                    forward: f,
2416                };
2417                match att.target {
2418                    MeshRef::Static(mi) => {
2419                        if let Some(&mid) = model_ids.get(mi) {
2420                            let inst = self.add_sprite_instance_posed(mid, xf);
2421                            attaches.push(AttachInst {
2422                                bone: bi,
2423                                local_offset: att.local_offset,
2424                                inst,
2425                                clip: None,
2426                            });
2427                        }
2428                    }
2429                    MeshRef::Clip(ci) => {
2430                        if let Some(Some((cid, durations, loop_mode))) = clip_regs.get(ci) {
2431                            let inst = self.add_clip_instance_posed(*cid, xf);
2432                            attaches.push(AttachInst {
2433                                bone: bi,
2434                                local_offset: att.local_offset,
2435                                inst,
2436                                clip: Some(ClipClock {
2437                                    durations: durations.clone(),
2438                                    loop_mode: *loop_mode,
2439                                    speed_q8: att.playback.speed_q8,
2440                                    clock_ms: f64::from(att.playback.start_phase_ms),
2441                                }),
2442                            });
2443                        }
2444                    }
2445                }
2446            }
2447        }
2448        let clips: Vec<VoxelClipId> = clip_regs
2449            .iter()
2450            .filter_map(|r| r.as_ref().map(|(cid, _, _)| *cid))
2451            .collect();
2452        let idx = self.char_instances.len();
2453        self.char_instances.push(CharInstance {
2454            skeleton,
2455            attaches,
2456            models: model_ids,
2457            clips,
2458        });
2459        self.char_map.alloc(idx as u32)
2460    }
2461
2462    /// Advance a character by `dt` seconds: tick its skeletal animation +
2463    /// each clip attachment's clock, then re-pose every attachment
2464    /// (bone × local_offset) and select each clip's current frame. No-op on
2465    /// a stale id.
2466    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
2467    pub fn advance_character(&mut self, id: CharacterId, dt: f64) {
2468        let Some(idx) = self.char_map.index(id) else {
2469            return;
2470        };
2471        // Phase 1: solve the skeleton + compute each attachment's update,
2472        // borrowing only `char_instances[idx]`.
2473        let updates: Vec<(SpriteInstanceId, DynSpriteTransform, Option<u32>)> = {
2474            let CharInstance {
2475                skeleton, attaches, ..
2476            } = &mut self.char_instances[idx];
2477            skeleton.animsprite((dt * 1000.0) as i32);
2478            solve_kfa_limbs(skeleton);
2479            attaches
2480                .iter_mut()
2481                .map(|a| {
2482                    let limb = &skeleton.limbs[a.bone];
2483                    let (s, h, f, p) =
2484                        compose_attachment(limb.s, limb.h, limb.f, limb.p, &a.local_offset);
2485                    let xf = DynSpriteTransform {
2486                        pos: p,
2487                        right: s,
2488                        up: h,
2489                        forward: f,
2490                    };
2491                    let frame = a.clip.as_mut().map(|c| c.tick(dt));
2492                    (a.inst, xf, frame)
2493                })
2494                .collect()
2495        };
2496        // Phase 2: apply via the facade primitives (disjoint from
2497        // `char_instances`).
2498        for (inst, xf, frame) in updates {
2499            self.set_sprite_instance_transform(inst, xf);
2500            if let Some(f) = frame {
2501                self.set_clip_instance_frame(inst, f);
2502            }
2503        }
2504    }
2505
2506    /// Move/re-orient a character to a new world transform `xf` (the root
2507    /// limb's world pose) **without** ticking its animation or clip clocks —
2508    /// a teleport that holds the current animation frame (e.g. dragging a
2509    /// paused character in an editor). Re-solves the skeleton from the new
2510    /// root + re-poses every attachment; clip frames are left as-is. No-op on
2511    /// a stale id.
2512    pub fn set_character_world_transform(&mut self, id: CharacterId, xf: DynSpriteTransform) {
2513        let Some(idx) = self.char_map.index(id) else {
2514            return;
2515        };
2516        // Phase 1: set the root pose + re-solve (no animsprite), then compute
2517        // each attachment's new transform — borrowing only `char_instances`.
2518        let updates: Vec<(SpriteInstanceId, DynSpriteTransform)> = {
2519            let CharInstance {
2520                skeleton, attaches, ..
2521            } = &mut self.char_instances[idx];
2522            skeleton.p = xf.pos;
2523            skeleton.s = xf.right;
2524            skeleton.h = xf.up;
2525            skeleton.f = xf.forward;
2526            solve_kfa_limbs(skeleton);
2527            attaches
2528                .iter()
2529                .map(|a| {
2530                    let limb = &skeleton.limbs[a.bone];
2531                    let (s, h, f, p) =
2532                        compose_attachment(limb.s, limb.h, limb.f, limb.p, &a.local_offset);
2533                    (
2534                        a.inst,
2535                        DynSpriteTransform {
2536                            pos: p,
2537                            right: s,
2538                            up: h,
2539                            forward: f,
2540                        },
2541                    )
2542                })
2543                .collect()
2544        };
2545        // Phase 2: apply (clip frames untouched — clocks didn't tick).
2546        for (inst, t) in updates {
2547            self.set_sprite_instance_transform(inst, t);
2548        }
2549    }
2550
2551    /// Remove a character, dropping all its attachment instances **and**
2552    /// freeing the sprite models + voxel clips it registered. Returns
2553    /// `false` if `id` is stale.
2554    pub fn remove_character(&mut self, id: CharacterId) -> bool {
2555        let Some(idx) = self.char_map.index(id) else {
2556            return false;
2557        };
2558        let insts: Vec<SpriteInstanceId> = self.char_instances[idx]
2559            .attaches
2560            .iter()
2561            .map(|a| a.inst)
2562            .collect();
2563        for inst in insts {
2564            self.remove_sprite_instance(inst);
2565        }
2566        self.char_instances[idx].attaches.clear();
2567        // Free the models + clips this character registered (else they leak
2568        // until a `set_sprites` — costly for an editor hot-swapping all
2569        // session). `mem::take` so the per-id frees can borrow `self`.
2570        let models = std::mem::take(&mut self.char_instances[idx].models);
2571        let clips = std::mem::take(&mut self.char_instances[idx].clips);
2572        for model in models {
2573            self.remove_sprite_model(model);
2574        }
2575        for clip in clips {
2576            self.remove_voxel_clip(clip);
2577        }
2578        self.char_map.remove(id)
2579    }
2580
2581    /// Register animated KFA sprites (one or more bone hierarchies).
2582    /// The GPU backend uploads each limb's kv6 as an instanced model
2583    /// **once** (appended to the sprite registry) and seeds the limb
2584    /// instances at their current pose; the CPU backend caches the
2585    /// posed limbs for drawing. Call once at setup, after
2586    /// [`set_sprites`](Self::set_sprites), then drive motion per frame
2587    /// with [`update_kfa_poses`](Self::update_kfa_poses).
2588    ///
2589    /// Limbs are posed from the sprites' current
2590    /// [`kfaval`](roxlap_formats::kfa::KfaSprite::kfaval) (advance
2591    /// [`animsprite`](roxlap_formats::kfa::KfaSprite::animsprite) first
2592    /// if using a baked curve), so `kfas` is taken `&mut`.
2593    pub fn set_kfa_sprites(&mut self, kfas: &mut [KfaSprite]) {
2594        match &mut self.inner {
2595            BackendImpl::Cpu(c) => c.set_kfa_sprites(kfas),
2596            BackendImpl::Gpu(g) => g.set_kfa_sprites(kfas),
2597        }
2598    }
2599
2600    /// Re-pose the registered KFA sprites from their current
2601    /// `kfaval[]`. Call each frame after advancing the animation
2602    /// (`kfa.animsprite(dt_ms)` or poking `kfaval[]`). The GPU backend
2603    /// takes the cheap transform-only update (no model-volume
2604    /// re-upload); the CPU backend re-solves limb transforms for the
2605    /// next [`render`](Self::render). Must follow a
2606    /// [`set_kfa_sprites`](Self::set_kfa_sprites) with the same sprites.
2607    pub fn update_kfa_poses(&mut self, kfas: &mut [KfaSprite]) {
2608        match &mut self.inner {
2609            BackendImpl::Cpu(c) => c.update_kfa_poses(kfas),
2610            BackendImpl::Gpu(g) => g.update_kfa_poses(kfas),
2611        }
2612    }
2613
2614    /// Carve the next z-layer off the [`SpriteSet::carve_model`] and
2615    /// re-upload (the demo's `G` hotkey + GPU.12 copy-on-modify). GPU
2616    /// only; a no-op on the CPU backend. Returns the voxels removed.
2617    pub fn carve_active_sprite(&mut self) -> u32 {
2618        match &mut self.inner {
2619            BackendImpl::Cpu(_) => 0,
2620            BackendImpl::Gpu(g) => g.carve_active_sprite(),
2621        }
2622    }
2623
2624    /// Request that the next [`render`](Self::render) capture its
2625    /// framebuffer for [`take_capture`](Self::take_capture). CPU only
2626    /// (the GPU swapchain isn't read back) — a no-op on GPU.
2627    pub fn request_capture(&mut self) {
2628        if let BackendImpl::Cpu(c) = &mut self.inner {
2629            c.request_capture();
2630        }
2631    }
2632
2633    /// Take the most recently captured frame as packed `0x00RRGGBB`
2634    /// pixels + dimensions, or `None` if no capture is ready / GPU.
2635    pub fn take_capture(&mut self) -> Option<(Vec<u32>, u32, u32)> {
2636        match &mut self.inner {
2637            BackendImpl::Cpu(c) => c.take_capture(),
2638            BackendImpl::Gpu(_) => None,
2639        }
2640    }
2641
2642    /// Screen→world picking input: the world-space hit distance `t` at
2643    /// window pixel `(x, y)` from the **last rendered frame**, or `None`
2644    /// for out-of-bounds pixels and sky / no-hit. The host reconstructs
2645    /// the world hit point as `cam.pos + t * normalize(ray_dir)`, where
2646    /// `ray_dir` is the same per-pixel ray the frame was rendered with
2647    /// (see the backend's projection).
2648    ///
2649    /// `t` is the distance to the nearest **scene-grid** surface
2650    /// (terrain + grids); sprites do not occlude it (the sprite pass
2651    /// reads depth read-only), so a cursor sprite under the pointer is
2652    /// transparent to the pick.
2653    ///
2654    /// Cost: the CPU backend reads its in-memory z-buffer (free); the
2655    /// GPU backend stages the depth buffer and blocks on a device poll
2656    /// (cheap at click time — do not call every frame). The GPU path
2657    /// only has depth when the last frame drew sprites (`write_depth`).
2658    #[must_use]
2659    pub fn pick_depth(&self, x: u32, y: u32) -> Option<f32> {
2660        match &self.inner {
2661            BackendImpl::Cpu(c) => c.pick_depth(x, y),
2662            BackendImpl::Gpu(g) => g.pick_depth(x, y),
2663        }
2664    }
2665
2666    /// World-space view-ray direction (un-normalised) for window pixel
2667    /// `(x, y)`, under the projection the **last frame** rendered with.
2668    /// The backends differ (CPU `setcamera` vs GPU vertical-FOV
2669    /// pinhole), so this hides which one is active. `None` before the
2670    /// first frame. Intersect it with a plane for tile picking, or feed
2671    /// it to [`Self::pick`] for a voxel.
2672    #[must_use]
2673    pub fn pixel_ray(&self, camera: &Camera, x: f64, y: f64) -> Option<[f64; 3]> {
2674        match &self.inner {
2675            BackendImpl::Cpu(c) => c.pixel_ray(camera, x, y),
2676            BackendImpl::Gpu(g) => g.pixel_ray(camera, x, y),
2677        }
2678    }
2679
2680    /// Canonical screen→world unproject: the full view [`Ray`]
2681    /// (`camera.pos` origin + unit direction) for window pixel
2682    /// `(x, y)`, under whichever projection the last frame used. The
2683    /// one entry point both backends honour — hosts never reconstruct
2684    /// the projection. `None` before the first frame or for a
2685    /// degenerate ray.
2686    ///
2687    /// Compose with [`roxlap_scene::Scene::raycast`] for depth-free
2688    /// picking that's identical on CPU and GPU:
2689    /// `renderer.view_ray(cam, x, y).and_then(|r| scene.raycast(r.origin, r.dir, max))`.
2690    #[must_use]
2691    pub fn view_ray(&self, camera: &Camera, x: f64, y: f64) -> Option<Ray> {
2692        let d = self.pixel_ray(camera, x, y)?;
2693        let len = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt();
2694        if len < 1e-12 {
2695            return None;
2696        }
2697        Some(Ray {
2698            origin: glam::DVec3::from_array([camera.pos[0], camera.pos[1], camera.pos[2]]),
2699            dir: glam::DVec3::new(d[0] / len, d[1] / len, d[2] / len),
2700        })
2701    }
2702
2703    /// One-call screen→world voxel pick: unproject pixel `(x, y)` with
2704    /// the active backend's projection, read the last frame's depth
2705    /// there, reconstruct the world hit, and resolve it to the owning
2706    /// grid + grid-local voxel via [`Scene::resolve_voxel`]. `None` on
2707    /// sky / no-hit, or when no grid claims the surface.
2708    ///
2709    /// `scene` and `camera` must be the ones the last frame rendered;
2710    /// the projection (size + FOV / `hx,hy,hz`) is taken from that
2711    /// frame. Cheap on CPU (in-memory z-buffer); on GPU it stages the
2712    /// depth buffer (a click-time device poll — not per frame).
2713    #[must_use]
2714    pub fn pick(&self, scene: &Scene, camera: &Camera, x: u32, y: u32) -> Option<PickHit> {
2715        let dir = self.pixel_ray(camera, f64::from(x), f64::from(y))?;
2716        let t = f64::from(self.pick_depth(x, y)?);
2717        let len = (dir[0] * dir[0] + dir[1] * dir[1] + dir[2] * dir[2]).sqrt();
2718        if len < 1e-9 {
2719            return None;
2720        }
2721        let s = t / len; // world = cam.pos + t · (dir / |dir|)
2722        let world = glam::DVec3::new(
2723            camera.pos[0] + dir[0] * s,
2724            camera.pos[1] + dir[1] * s,
2725            camera.pos[2] + dir[2] * s,
2726        );
2727        let (grid, voxel) = scene.resolve_voxel(world, glam::DVec3::from_array(dir))?;
2728        #[allow(clippy::cast_possible_truncation)]
2729        let world_f32 = [world.x as f32, world.y as f32, world.z as f32];
2730        Some(PickHit {
2731            world: world_f32,
2732            grid,
2733            voxel,
2734        })
2735    }
2736}
2737
2738#[cfg(test)]
2739mod tests {
2740    use super::*;
2741
2742    /// The handle map must survive the backends' swap-remove indexing:
2743    /// drive a model `DynInstanceMap` against a `Vec` "backend" that
2744    /// swap-removes, and check every live handle keeps resolving to its
2745    /// own payload through a sequence of adds + removes.
2746    #[test]
2747    fn dyn_instance_map_survives_swap_removes() {
2748        let mut map = DynInstanceMap::default();
2749        // The "backend": payload per dynamic index; swap_remove mirrors
2750        // both backends' remove_dyn_instance.
2751        let mut backend: Vec<u32> = Vec::new();
2752        // Our bookkeeping: handle -> the payload we expect it to address.
2753        let mut expect: Vec<(SpriteInstanceId, u32)> = Vec::new();
2754
2755        let add = |map: &mut DynInstanceMap,
2756                   backend: &mut Vec<u32>,
2757                   expect: &mut Vec<(SpriteInstanceId, u32)>,
2758                   payload: u32| {
2759            let dyn_index = backend.len() as u32;
2760            backend.push(payload);
2761            let id = map.alloc(dyn_index);
2762            expect.push((id, payload));
2763        };
2764
2765        for p in 0..6 {
2766            add(&mut map, &mut backend, &mut expect, p);
2767        }
2768
2769        // Remove a middle handle (payload 2) and a later one (payload 4),
2770        // plus the current last — covering swap and no-swap paths.
2771        for victim_payload in [2u32, 4, 5] {
2772            let pos = expect
2773                .iter()
2774                .position(|&(_, p)| p == victim_payload)
2775                .unwrap();
2776            let (id, _) = expect.remove(pos);
2777            let dyn_index = map.dyn_index(id).expect("live handle resolves");
2778            // Backend swap-remove + report moved index (old last), exactly
2779            // like remove_dyn_instance on both backends.
2780            let last = backend.len() - 1;
2781            backend.swap_remove(dyn_index as usize);
2782            let moved = (dyn_index as usize != last).then_some(last as u32);
2783            map.remove(id, dyn_index, moved);
2784            // The removed handle is now stale.
2785            assert!(map.dyn_index(id).is_none(), "removed handle is stale");
2786        }
2787
2788        // Every surviving handle still resolves to its own payload.
2789        for &(id, payload) in &expect {
2790            let idx = map.dyn_index(id).expect("survivor resolves");
2791            assert_eq!(
2792                backend[idx as usize], payload,
2793                "handle addresses its payload"
2794            );
2795        }
2796        assert_eq!(map.order.len(), backend.len());
2797        assert_eq!(backend.len(), expect.len());
2798    }
2799
2800    /// The model slotmap mints stable ids, resolves only live handles,
2801    /// and never reuses a slot — so a removed model's id stays dead and
2802    /// every other id survives the remove.
2803    #[test]
2804    fn dyn_model_map_lifecycle() {
2805        let mut map = DynModelMap::default();
2806        // `set_sprites(3 models)` seeds ids 0..3, all live.
2807        map.reset(3);
2808        let ids: Vec<SpriteModelId> = (0..3).map(|s| SpriteModelId { slot: s, gen: 0 }).collect();
2809        for (i, &id) in ids.iter().enumerate() {
2810            assert_eq!(map.model_index(id), Some(i));
2811        }
2812
2813        // Incrementally add a fourth model.
2814        let extra = map.alloc(3);
2815        assert_eq!(extra, SpriteModelId { slot: 3, gen: 0 });
2816        assert_eq!(map.model_index(extra), Some(3));
2817
2818        // Remove model 1: its handle goes stale, the rest stay valid.
2819        assert!(map.remove(ids[1]));
2820        assert_eq!(map.model_index(ids[1]), None);
2821        assert_eq!(map.model_index(ids[0]), Some(0));
2822        assert_eq!(map.model_index(ids[2]), Some(2));
2823        assert_eq!(map.model_index(extra), Some(3));
2824
2825        // Double remove / stale removal is a no-op returning false.
2826        assert!(!map.remove(ids[1]));
2827
2828        // A bogus / out-of-range handle resolves to nothing, no panic.
2829        let bogus = SpriteModelId { slot: 999, gen: 0 };
2830        assert_eq!(map.model_index(bogus), None);
2831        assert!(!map.remove(bogus));
2832
2833        // A handle with a mismatched generation never resolves (guards a
2834        // future compacting registry).
2835        let wrong_gen = SpriteModelId { slot: 0, gen: 7 };
2836        assert_eq!(map.model_index(wrong_gen), None);
2837    }
2838
2839    /// The voxel-clip slotmap (VCL.4) mints stable ids, resolves only live
2840    /// handles, tombstones in place, and `reset` clears it — mirroring the
2841    /// model slotmap, since clips register append-only too.
2842    #[test]
2843    fn dyn_clip_map_lifecycle() {
2844        let mut map = DynClipMap::default();
2845        // Two clips registered incrementally (indices 0, 1).
2846        let c0 = map.alloc(0);
2847        let c1 = map.alloc(1);
2848        assert_eq!(c0, VoxelClipId { slot: 0, gen: 0 });
2849        assert_eq!(map.clip_index(c0), Some(0));
2850        assert_eq!(map.clip_index(c1), Some(1));
2851
2852        // Remove clip 0: stale handle, clip 1 stays valid; slot not reused.
2853        assert!(map.remove(c0));
2854        assert_eq!(map.clip_index(c0), None);
2855        assert_eq!(map.clip_index(c1), Some(1));
2856        // Double / stale / out-of-range removes are false, no panic.
2857        assert!(!map.remove(c0));
2858        assert!(!map.remove(VoxelClipId { slot: 99, gen: 0 }));
2859        // Mismatched generation never resolves.
2860        assert_eq!(map.clip_index(VoxelClipId { slot: 1, gen: 5 }), None);
2861
2862        // `set_sprites` resets the clip layer → ids restart at slot 0, but
2863        // the epoch bumps so old handles don't alias the new clips.
2864        map.reset();
2865        assert_eq!(map.clip_index(c1), None, "reset invalidates old handles");
2866        let again = map.alloc(0); // re-takes slot 0 under the new epoch
2867        assert_eq!(again, VoxelClipId { slot: 0, gen: 1 });
2868        assert_eq!(map.clip_index(again), Some(0));
2869        // The footgun fix: c0 (slot 0, old epoch) must NOT resolve to the new
2870        // clip now occupying slot 0.
2871        assert_eq!(
2872            map.clip_index(c0),
2873            None,
2874            "a pre-reset handle must not alias a new clip on the same slot"
2875        );
2876    }
2877
2878    /// The character slotmap (VCL.6) mints stable ids, resolves only live
2879    /// handles, tombstones in place, and `reset` clears it.
2880    #[test]
2881    fn char_map_lifecycle() {
2882        let mut map = CharMap::default();
2883        let a = map.alloc(0);
2884        let b = map.alloc(1);
2885        assert_eq!(a, CharacterId { slot: 0, gen: 0 });
2886        assert_eq!(map.index(a), Some(0));
2887        assert_eq!(map.index(b), Some(1));
2888
2889        assert!(map.remove(a));
2890        assert_eq!(map.index(a), None);
2891        assert_eq!(map.index(b), Some(1));
2892        assert!(!map.remove(a)); // double remove is a no-op
2893        assert!(!map.remove(CharacterId { slot: 9, gen: 0 }));
2894        assert_eq!(map.index(CharacterId { slot: 1, gen: 7 }), None);
2895
2896        map.reset();
2897        assert_eq!(map.index(b), None);
2898        assert_eq!(map.alloc(0), CharacterId { slot: 0, gen: 1 });
2899        assert_eq!(map.index(a), None, "pre-reset handle must not alias slot 0");
2900    }
2901
2902    /// The streaming-clip slotmap (#3) mints stable ids, resolves only live
2903    /// handles, tombstones in place, and `reset` clears it.
2904    #[test]
2905    fn streaming_clip_map_lifecycle() {
2906        let mut map = StreamingClipMap::default();
2907        let a = map.alloc(0);
2908        let b = map.alloc(1);
2909        assert_eq!(a, StreamingClipId { slot: 0, gen: 0 });
2910        assert_eq!(map.index(a), Some(0));
2911        assert_eq!(map.index(b), Some(1));
2912
2913        assert!(map.remove(a));
2914        assert_eq!(map.index(a), None);
2915        assert_eq!(map.index(b), Some(1));
2916        assert!(!map.remove(a)); // double remove is a no-op
2917        assert!(!map.remove(StreamingClipId { slot: 9, gen: 0 }));
2918        assert_eq!(map.index(StreamingClipId { slot: 1, gen: 7 }), None);
2919
2920        map.reset();
2921        assert_eq!(map.index(b), None);
2922        assert_eq!(map.alloc(0), StreamingClipId { slot: 0, gen: 1 });
2923        assert_eq!(map.index(a), None, "pre-reset handle must not alias slot 0");
2924    }
2925
2926    /// The shared clip-playback clock (#6 / VCL.6): `tick` accumulates time
2927    /// at its Q8 speed, resolves the frame, honours `start_phase`, and reads
2928    /// a rewound (negative) clock as frame 0.
2929    #[test]
2930    fn clip_clock_tick_advances_and_resolves_frames() {
2931        // 3 frames, 100 ms each → total 300 ms, looping.
2932        let mut c = ClipClock {
2933            durations: vec![100, 100, 100],
2934            loop_mode: LoopMode::Loop,
2935            speed_q8: 256, // 1×
2936            clock_ms: 0.0,
2937        };
2938        assert_eq!(c.tick(0.0), 0); // t=0 → frame 0
2939        assert_eq!(c.tick(0.10), 1); // t=100 → frame 1 (100 is not < 100)
2940        assert_eq!(c.clock_ms as u32, 100);
2941        assert_eq!(c.tick(0.15), 2); // t=250 → frame 2
2942        assert_eq!(c.tick(0.10), 0); // t=350 → 350%300=50 → frame 0
2943                                     // 0.5× speed advances half as fast.
2944        let mut slow = ClipClock {
2945            durations: vec![100, 100],
2946            loop_mode: LoopMode::Once,
2947            speed_q8: 128, // 0.5×
2948            clock_ms: 0.0,
2949        };
2950        assert_eq!(slow.tick(0.20), 1); // 200ms wall → 100ms clock → frame 1
2951        assert!((slow.clock_ms - 100.0).abs() < 1e-6);
2952        // start_phase seeds the clock; negative clock reads as frame 0.
2953        let mut phased = ClipClock {
2954            durations: vec![50, 50, 50],
2955            loop_mode: LoopMode::Loop,
2956            speed_q8: -256, // rewind
2957            clock_ms: 50.0, // start mid frame 1
2958        };
2959        assert_eq!(phased.tick(0.10), 0); // 50 - 100 = -50 → max(0)=0 → frame 0
2960        assert!(phased.clock_ms < 0.0); // kept signed
2961    }
2962
2963    #[test]
2964    fn dyn_sprite_transform_default_is_identity_and_applies() {
2965        let xf = DynSpriteTransform::default();
2966        assert_eq!(xf.pos, [0.0, 0.0, 0.0]);
2967        assert_eq!(xf.right, [1.0, 0.0, 0.0]);
2968        assert_eq!(xf.up, [0.0, 1.0, 0.0]);
2969        assert_eq!(xf.forward, [0.0, 0.0, 1.0]);
2970
2971        let mut s = Sprite::axis_aligned(
2972            roxlap_formats::kv6::Kv6::solid_cube(2, 0x80_FF_FF_FF),
2973            [9.0, 9.0, 9.0],
2974        );
2975        let posed = DynSpriteTransform {
2976            pos: [1.0, 2.0, 3.0],
2977            right: [0.0, 0.0, 1.0],
2978            up: [0.0, 1.0, 0.0],
2979            forward: [1.0, 0.0, 0.0],
2980        };
2981        posed.apply_to(&mut s);
2982        assert_eq!(s.p, [1.0, 2.0, 3.0]);
2983        assert_eq!(s.s, [0.0, 0.0, 1.0]);
2984        assert_eq!(s.h, [0.0, 1.0, 0.0]);
2985        assert_eq!(s.f, [1.0, 0.0, 0.0]);
2986    }
2987
2988    #[test]
2989    fn options_default_is_cpu_intent() {
2990        let o = RenderOptions::default();
2991        assert!(!o.want_gpu);
2992        assert_eq!(o.clear_sky & 0xFF00_0000, 0, "clear_sky is 0x00RRGGBB");
2993    }
2994
2995    /// A camera at the origin looking down +Y (voxlap z-down world): right
2996    /// = +X, down = +Z, forward = +Y. Handedness `right × down == forward`.
2997    fn cam_looking_y() -> Camera {
2998        Camera {
2999            pos: [0.0, 0.0, 0.0],
3000            right: [1.0, 0.0, 0.0],
3001            down: [0.0, 0.0, 1.0],
3002            forward: [0.0, 1.0, 0.0],
3003        }
3004    }
3005
3006    #[test]
3007    fn world_quad_corner_layout() {
3008        // Top-left at (-5, 10, -5); u = +X (width), v = +Z (down). A
3009        // 10×10 quad facing the camera (its +Y normal points back at us).
3010        let sprite = ImageSprite {
3011            image: ImageId(0),
3012            origin: [-5.0, 10.0, -5.0],
3013            facing: ImageFacing::World {
3014                u: [1.0, 0.0, 0.0],
3015                v: [0.0, 0.0, 1.0],
3016            },
3017            size: [10.0, 10.0],
3018            tint: 0xFFFF_FFFF,
3019            alpha_cutoff: 0.0,
3020            depth_test: true,
3021            double_sided: true,
3022        };
3023        let q = resolve_quad(&sprite, &cam_looking_y()).expect("front-facing");
3024        assert_eq!(q.corners[0], [-5.0, 10.0, -5.0], "TL = origin");
3025        assert_eq!(q.corners[1], [5.0, 10.0, -5.0], "TR = origin + u·size");
3026        assert_eq!(q.corners[2], [-5.0, 10.0, 5.0], "BL = origin + v·size");
3027        assert_eq!(q.corners[3], [5.0, 10.0, 5.0], "BR = origin + u + v");
3028    }
3029
3030    #[test]
3031    fn world_quad_backface_culls_when_single_sided() {
3032        // Same plane but spanned so its normal (u × v) points *away* from
3033        // the camera: swap u/v so the winding flips.
3034        let sprite = ImageSprite {
3035            image: ImageId(0),
3036            origin: [-5.0, 10.0, -5.0],
3037            facing: ImageFacing::World {
3038                u: [0.0, 0.0, 1.0], // v-ish
3039                v: [1.0, 0.0, 0.0], // u-ish → normal flips to -Y... toward camera?
3040            },
3041            size: [10.0, 10.0],
3042            tint: 0xFFFF_FFFF,
3043            alpha_cutoff: 0.0,
3044            depth_test: true,
3045            double_sided: false,
3046        };
3047        // With double_sided=false one of the two windings must cull; the
3048        // opposite winding must draw. Exactly one of the two resolves.
3049        let a = resolve_quad(&sprite, &cam_looking_y()).is_some();
3050        let mut flipped = sprite;
3051        flipped.facing = ImageFacing::World {
3052            u: [1.0, 0.0, 0.0],
3053            v: [0.0, 0.0, 1.0],
3054        };
3055        let b = resolve_quad(&flipped, &cam_looking_y()).is_some();
3056        assert!(a ^ b, "exactly one winding is front-facing");
3057    }
3058
3059    #[test]
3060    fn double_sided_never_culls() {
3061        let mut sprite = ImageSprite {
3062            image: ImageId(0),
3063            origin: [-5.0, 10.0, -5.0],
3064            facing: ImageFacing::World {
3065                u: [0.0, 0.0, 1.0],
3066                v: [1.0, 0.0, 0.0],
3067            },
3068            size: [10.0, 10.0],
3069            tint: 0xFFFF_FFFF,
3070            alpha_cutoff: 0.0,
3071            depth_test: true,
3072            double_sided: true,
3073        };
3074        assert!(resolve_quad(&sprite, &cam_looking_y()).is_some());
3075        sprite.facing = ImageFacing::World {
3076            u: [1.0, 0.0, 0.0],
3077            v: [0.0, 0.0, 1.0],
3078        };
3079        assert!(resolve_quad(&sprite, &cam_looking_y()).is_some());
3080    }
3081
3082    #[test]
3083    fn ray_quad_uv_center_and_corners() {
3084        // 10×10 quad on the y=10 plane: TL(-5,10,-5) u=+X v=+Z. Camera at
3085        // origin looking +Y. A ray straight at the quad centre → uv (.5,.5).
3086        let corners = [
3087            [-5.0, 10.0, -5.0], // TL
3088            [5.0, 10.0, -5.0],  // TR
3089            [-5.0, 10.0, 5.0],  // BL
3090            [5.0, 10.0, 5.0],   // BR
3091        ];
3092        let (uv, t) = ray_quad_uv([0.0, 0.0, 0.0], [0.0, 1.0, 0.0], &corners).expect("center hit");
3093        assert!(
3094            (uv[0] - 0.5).abs() < 1e-5 && (uv[1] - 0.5).abs() < 1e-5,
3095            "centre → (.5,.5)"
3096        );
3097        assert!((t - 10.0).abs() < 1e-4, "t = plane distance");
3098        // Ray toward the TL corner texel region (−x, +y, −z) → uv near (0,0).
3099        let (uv_tl, _) = ray_quad_uv([0.0, 0.0, 0.0], [-4.0, 10.0, -4.0], &corners).unwrap();
3100        assert!(uv_tl[0] < 0.2 && uv_tl[1] < 0.2, "toward TL → small uv");
3101    }
3102
3103    #[test]
3104    fn ray_quad_uv_misses_outside_and_behind() {
3105        let corners = [
3106            [-5.0, 10.0, -5.0],
3107            [5.0, 10.0, -5.0],
3108            [-5.0, 10.0, 5.0],
3109            [5.0, 10.0, 5.0],
3110        ];
3111        // Ray pointing away (−Y) never reaches the +Y plane in front.
3112        assert!(ray_quad_uv([0.0, 0.0, 0.0], [0.0, -1.0, 0.0], &corners).is_none());
3113        // Ray parallel to the quad plane (in +X) → no intersection.
3114        assert!(ray_quad_uv([0.0, 0.0, 0.0], [1.0, 0.0, 0.0], &corners).is_none());
3115        // Ray hitting the plane far outside the quad → outside uv.
3116        assert!(ray_quad_uv([100.0, 0.0, 0.0], [0.0, 1.0, 0.0], &corners).is_none());
3117    }
3118
3119    #[test]
3120    fn billboard_axes_orthogonal_and_top_toward_up() {
3121        // World up = -Z (z-down world). The billboard's v (top→bottom)
3122        // must point away from `up`, and u/v must be ⟂ the view direction.
3123        let up = [0.0, 0.0, -1.0];
3124        let sprite = ImageSprite {
3125            image: ImageId(0),
3126            origin: [0.0, 50.0, 0.0],
3127            facing: ImageFacing::Billboard { up },
3128            size: [4.0, 4.0],
3129            tint: 0xFFFF_FFFF,
3130            alpha_cutoff: 0.0,
3131            depth_test: false,
3132            double_sided: false, // billboards must NEVER cull
3133        };
3134        let q = resolve_quad(&sprite, &cam_looking_y()).expect("billboard always faces camera");
3135        let u = v_sub(q.corners[1], q.corners[0]); // TR - TL = u·size
3136        let v = v_sub(q.corners[2], q.corners[0]); // BL - TL = v·size
3137        let fwd = [0.0, 1.0, 0.0];
3138        assert!(v_dot(u, fwd).abs() < 1e-5, "u ⟂ view");
3139        assert!(v_dot(v, fwd).abs() < 1e-5, "v ⟂ view");
3140        assert!(v_dot(u, v).abs() < 1e-5, "u ⟂ v");
3141        assert!(
3142            v_dot(v, up) < 0.0,
3143            "rows grow away from `up` (top edge toward up)"
3144        );
3145    }
3146}