Skip to main content

roxlap_render/
lib.rs

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