Skip to main content

roxlap_render/
lib.rs

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