Skip to main content

roxlap_render/
lib.rs

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