Skip to main content

roxlap_render/
lib.rs

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