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