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