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