roxlap_render/lib.rs
1//! roxlap-render — unified CPU/GPU renderer facade.
2//!
3//! One [`SceneRenderer`] hides the choice between the CPU opticast
4//! path (`roxlap-core` / `roxlap-scene`, presented via `softbuffer`)
5//! and the GPU compute-shader path (`roxlap-gpu`, presented via its
6//! own wgpu surface). Construction picks the GPU backend when asked
7//! and able, and **falls back to CPU automatically** when WGPU init
8//! fails — so a host never has to branch on GPU availability or carry
9//! the `Scene`→GPU upload/refresh/transform glue itself.
10//!
11//! Hosts stay thin: build a `Scene`, advance it from input, then call
12//! [`SceneRenderer::render`] each frame. The facade owns the window
13//! surface, the framebuffer/z-buffer (CPU) or the resident scene +
14//! dirty-chunk tracking (GPU), and presentation.
15//!
16//! The per-frame flow is `render` → *(optional overlays)* → finish.
17//! Between [`SceneRenderer::render`] and the finishing
18//! [`SceneRenderer::present`] / [`SceneRenderer::paint_egui`] call, a
19//! host may overlay depth-tested world-space lines with
20//! [`SceneRenderer::draw_lines`] (editor gizmos, debug geometry — see
21//! [`Line3`]); they land in the framebuffer, occluded by the rendered
22//! scene, with egui still painting panels on top.
23//!
24//! This is the RF.0 skeleton: backend selection + fallback + a
25//! clear-to-sky frame. RF.1/RF.2 fill in the real CPU/GPU scene
26//! render; RF.3 adds sprites; RF.4 adds framebuffer capture.
27
28#![forbid(unsafe_code)]
29
30mod cpu;
31/// WebGL2 framebuffer presenter for the CPU backend on wasm (the
32/// browser has no `softbuffer`).
33#[cfg(target_arch = "wasm32")]
34mod cpu_blit;
35#[cfg(feature = "hud")]
36mod cpu_egui;
37mod gpu;
38
39#[cfg(not(target_arch = "wasm32"))]
40use std::sync::Arc;
41
42use roxlap_core::kfa_draw::{compose_attachment, solve_kfa_limbs};
43use roxlap_core::opticast::OpticastSettings;
44use roxlap_core::sky::Sky;
45use roxlap_core::Camera;
46use roxlap_formats::voxel_clip::frame_at;
47use roxlap_scene::Scene;
48
49pub use roxlap_formats::character::{Attachment, Character, MeshRef};
50pub use roxlap_formats::kfa::KfaSprite;
51pub use roxlap_formats::kv6::Kv6;
52pub use roxlap_formats::material::{BlendMode, Material};
53pub use roxlap_formats::sprite::Sprite;
54pub use roxlap_formats::voxel_clip::{
55 DecodeError, DecodedClip, LoopMode, StreamingClip, VoxelClip, VoxelFrame,
56};
57pub use roxlap_gpu::{GpuInitError, GpuRendererSettings, PowerPreference};
58// Re-exported so hosts can name the [`SceneRenderer::new`] bounds
59// without adding a direct `raw-window-handle` dependency of their own.
60pub use raw_window_handle::{HasDisplayHandle, HasWindowHandle};
61// Re-exported so hosts feed [`SceneRenderer::paint_egui`] from the exact
62// egui version the renderer was built against (`hud` feature).
63#[cfg(feature = "hud")]
64pub use egui;
65
66use crate::cpu::CpuBackend;
67use crate::gpu::GpuBackend;
68
69/// Type-erased display handle stored by the CPU backend's softbuffer
70/// surface. `raw-window-handle` implements `HasDisplayHandle` for
71/// `Arc<H>` (`H: ?Sized`), and the bare trait object implements its
72/// own object-safe trait — so `Arc<W>` coerces to `Arc<DynDisplay>`
73/// for any provider `W`.
74#[cfg(not(target_arch = "wasm32"))]
75pub(crate) type DynDisplay = dyn HasDisplayHandle + Send + Sync + 'static;
76/// Type-erased window handle counterpart to [`DynDisplay`].
77#[cfg(not(target_arch = "wasm32"))]
78pub(crate) type DynWindow = dyn HasWindowHandle + Send + Sync + 'static;
79
80/// One placed sprite instance: which [`SpriteSet::models`] entry and
81/// where in the world.
82pub struct SpriteInstanceDesc {
83 pub model: usize,
84 pub pos: [f32; 3],
85}
86
87/// Stable handle to a registered sprite model, returned (one per
88/// [`SpriteSet::models`] entry, in order) by
89/// [`SceneRenderer::set_sprites`]. Pass it to
90/// [`refresh_sprite_model`](SceneRenderer::refresh_sprite_model) to
91/// re-register that model's geometry after a content edit — so callers
92/// never track the positional `usize` index themselves. Opaque on
93/// purpose: there is no arithmetic to do on it.
94///
95/// Also returned by [`SceneRenderer::add_sprite_model`] for an
96/// incrementally registered model, and accepted by
97/// [`remove_sprite_model`](SceneRenderer::remove_sprite_model). A handle
98/// to a removed model is **stale**: it resolves to nothing, so passing
99/// it anywhere is a safe no-op. The `gen` (generation) field guards a
100/// future compacting registry; it stays `0` today because model slots
101/// are tombstoned in place and never reused (GPU chain ids are
102/// append-only).
103#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
104pub struct SpriteModelId {
105 pub(crate) slot: u32,
106 pub(crate) gen: u32,
107}
108
109/// Stable handle to a **dynamically added** sprite instance — the result
110/// of [`SceneRenderer::add_sprite_instance`], passed to
111/// [`remove_sprite_instance`](SceneRenderer::remove_sprite_instance).
112///
113/// Backends remove instances by swap (O(1)), which moves another instance
114/// into the freed slot; this handle survives that because the facade keeps
115/// the id↔slot mapping up to date. The generation guards against a stale
116/// handle aliasing a recycled slot.
117#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
118pub struct SpriteInstanceId {
119 slot: u32,
120 gen: u32,
121}
122
123/// Facade-side slotmap that turns the backends' swap-remove indexing into
124/// stable [`SpriteInstanceId`] handles. Both backends keep their dynamic
125/// instances as a tail sublist indexed `0..n`; `order[dyn_index]` is the
126/// owning slot, and a removal fixes up the one slot whose instance was
127/// swapped into the hole.
128#[derive(Default)]
129struct DynInstanceMap {
130 /// Per slot: `(generation, Some(dyn_index) while live)`.
131 slots: Vec<(u32, Option<u32>)>,
132 /// Per live `dyn_index`: the owning slot. Parallel to the backends'
133 /// dynamic sublist (so `order.len()` == the dynamic instance count).
134 order: Vec<u32>,
135 free: Vec<u32>,
136}
137
138impl DynInstanceMap {
139 /// Register a freshly appended instance (always at `dyn_index ==
140 /// order.len()`); returns its stable handle.
141 fn alloc(&mut self, dyn_index: u32) -> SpriteInstanceId {
142 debug_assert_eq!(self.order.len() as u32, dyn_index);
143 let slot = self.free.pop().unwrap_or_else(|| {
144 self.slots.push((0, None));
145 (self.slots.len() - 1) as u32
146 });
147 let gen = self.slots[slot as usize].0;
148 self.slots[slot as usize].1 = Some(dyn_index);
149 self.order.push(slot);
150 SpriteInstanceId { slot, gen }
151 }
152
153 /// Resolve a handle to its current backend `dyn_index`, or `None` if
154 /// it's stale / already removed.
155 fn dyn_index(&self, id: SpriteInstanceId) -> Option<u32> {
156 let (gen, idx) = *self.slots.get(id.slot as usize)?;
157 (gen == id.gen).then_some(idx).flatten()
158 }
159
160 /// Apply a removal: the backend swap-removed `removed` and reported
161 /// `moved` (the old-last `dyn_index` that slid into `removed`, or
162 /// `None` if `removed` was itself the last).
163 fn remove(&mut self, id: SpriteInstanceId, removed: u32, moved: Option<u32>) {
164 self.slots[id.slot as usize].1 = None;
165 self.slots[id.slot as usize].0 += 1; // bump generation
166 self.free.push(id.slot);
167 if let Some(last) = moved {
168 let moved_slot = self.order[last as usize];
169 self.slots[moved_slot as usize].1 = Some(removed);
170 self.order[removed as usize] = moved_slot;
171 }
172 self.order.pop();
173 }
174}
175
176/// Facade-side slotmap for registered sprite **models**, mirroring
177/// [`DynInstanceMap`] but **without** the swap-remove fixup: a model
178/// slot maps 1:1 to the backends' positional model index (the GPU LOD
179/// chain id), which is append-only and never reused. A removed model
180/// tombstones its slot *in place* (the backend frees the voxel data but
181/// keeps the id), so a stale [`SpriteModelId`] resolves to `None` → a
182/// safe no-op rather than aliasing another model.
183#[derive(Default)]
184struct DynModelMap {
185 /// Per slot (== backend model index): `(generation, live)`. Slots are
186 /// never reused, so `generation` stays `0`; `live` flips to `false`
187 /// on removal.
188 slots: Vec<(u32, bool)>,
189}
190
191impl DynModelMap {
192 /// Reset to `n` live models with ids `0..n` — used by
193 /// [`SceneRenderer::set_sprites`], which rebuilds the whole model set
194 /// positionally (model index = chain id on both backends).
195 fn reset(&mut self, n: usize) {
196 self.slots.clear();
197 self.slots.resize(n, (0, true));
198 }
199
200 /// Register a freshly appended model at positional index
201 /// `model_index` (always the new `slots.len()`); returns its handle.
202 fn alloc(&mut self, model_index: u32) -> SpriteModelId {
203 debug_assert_eq!(self.slots.len() as u32, model_index);
204 self.slots.push((0, true));
205 SpriteModelId {
206 slot: model_index,
207 gen: 0,
208 }
209 }
210
211 /// Resolve a handle to its backend model index, or `None` if it's
212 /// stale / already removed.
213 fn model_index(&self, id: SpriteModelId) -> Option<usize> {
214 let (gen, live) = *self.slots.get(id.slot as usize)?;
215 (gen == id.gen && live).then_some(id.slot as usize)
216 }
217
218 /// Tombstone a model slot in place. Returns `false` if the handle is
219 /// stale / already removed.
220 fn remove(&mut self, id: SpriteModelId) -> bool {
221 let Some(slot) = self.slots.get_mut(id.slot as usize) else {
222 return false;
223 };
224 if slot.0 != id.gen || !slot.1 {
225 return false;
226 }
227 slot.1 = false;
228 true
229 }
230}
231
232/// Stable handle to a registered animated voxel clip (VCL.4) — the
233/// result of [`SceneRenderer::add_voxel_clip`], passed to
234/// [`add_clip_instance_posed`](SceneRenderer::add_clip_instance_posed)
235/// and [`remove_voxel_clip`](SceneRenderer::remove_voxel_clip). Like
236/// [`SpriteModelId`], a removed clip's handle is stale → a safe no-op.
237/// Reset by [`set_sprites`](SceneRenderer::set_sprites) (which drops the
238/// dynamic + clip layers).
239#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
240pub struct VoxelClipId {
241 slot: u32,
242 gen: u32,
243}
244
245/// Facade-side slotmap for registered voxel clips — mirrors
246/// [`DynModelMap`]: a clip slot maps 1:1 to the backends' positional clip
247/// index (append-only, tombstoned in place on removal, never reused).
248///
249/// `reset` clears the slots **and bumps `epoch`**, which is baked into each
250/// minted id's `gen`. A handle from before a `set_sprites` therefore carries
251/// the old epoch and resolves to `None` rather than silently aliasing the
252/// new clip that re-took its slot.
253#[derive(Default)]
254struct DynClipMap {
255 /// Per slot: `(epoch_at_alloc, live)`.
256 slots: Vec<(u32, bool)>,
257 epoch: u32,
258}
259
260impl DynClipMap {
261 fn alloc(&mut self, clip_index: u32) -> VoxelClipId {
262 debug_assert_eq!(self.slots.len() as u32, clip_index);
263 self.slots.push((self.epoch, true));
264 VoxelClipId {
265 slot: clip_index,
266 gen: self.epoch,
267 }
268 }
269
270 fn clip_index(&self, id: VoxelClipId) -> Option<usize> {
271 let (gen, live) = *self.slots.get(id.slot as usize)?;
272 (gen == id.gen && live).then_some(id.slot as usize)
273 }
274
275 fn remove(&mut self, id: VoxelClipId) -> bool {
276 let Some(slot) = self.slots.get_mut(id.slot as usize) else {
277 return false;
278 };
279 if slot.0 != id.gen || !slot.1 {
280 return false;
281 }
282 slot.1 = false;
283 true
284 }
285
286 fn reset(&mut self) {
287 self.slots.clear();
288 self.epoch = self.epoch.wrapping_add(1);
289 }
290}
291
292/// Stable handle to a registered animated character (VCL.6) — the result
293/// of [`SceneRenderer::add_character`], advanced each frame with
294/// [`advance_character`](SceneRenderer::advance_character) and dropped with
295/// [`remove_character`](SceneRenderer::remove_character). Reset by
296/// [`set_sprites`](SceneRenderer::set_sprites).
297#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
298pub struct CharacterId {
299 slot: u32,
300 gen: u32,
301}
302
303/// Facade-side slotmap for registered characters (mirrors [`DynClipMap`],
304/// including the epoch bump on `reset` so a pre-`set_sprites` handle
305/// resolves to `None` instead of aliasing a new character).
306#[derive(Default)]
307struct CharMap {
308 /// Per slot: `(epoch_at_alloc, live)`.
309 slots: Vec<(u32, bool)>,
310 epoch: u32,
311}
312
313impl CharMap {
314 fn alloc(&mut self, index: u32) -> CharacterId {
315 debug_assert_eq!(self.slots.len() as u32, index);
316 self.slots.push((self.epoch, true));
317 CharacterId {
318 slot: index,
319 gen: self.epoch,
320 }
321 }
322 fn index(&self, id: CharacterId) -> Option<usize> {
323 let (gen, live) = *self.slots.get(id.slot as usize)?;
324 (gen == id.gen && live).then_some(id.slot as usize)
325 }
326 fn remove(&mut self, id: CharacterId) -> bool {
327 let Some(slot) = self.slots.get_mut(id.slot as usize) else {
328 return false;
329 };
330 if slot.0 != id.gen || !slot.1 {
331 return false;
332 }
333 slot.1 = false;
334 true
335 }
336 fn reset(&mut self) {
337 self.slots.clear();
338 self.epoch = self.epoch.wrapping_add(1);
339 }
340}
341
342/// Stable handle to a registered **streaming** voxel clip (follow-up #3) —
343/// the result of [`SceneRenderer::add_streaming_clip`], advanced with
344/// [`set_streaming_clip_frame`](SceneRenderer::set_streaming_clip_frame) and
345/// dropped with
346/// [`remove_streaming_clip`](SceneRenderer::remove_streaming_clip). Reset by
347/// [`set_sprites`](SceneRenderer::set_sprites).
348#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
349pub struct StreamingClipId {
350 slot: u32,
351 gen: u32,
352}
353
354/// Handle to an instance of a streaming clip
355/// ([`add_streaming_clip_instance`](SceneRenderer::add_streaming_clip_instance)).
356///
357/// Deliberately **distinct** from [`SpriteInstanceId`]: a streaming clip's
358/// frame is per-*clip* (all its instances share one re-uploaded model,
359/// advanced by
360/// [`set_streaming_clip_frame`](SceneRenderer::set_streaming_clip_frame)), so
361/// a streaming instance is *not* accepted by the per-instance
362/// [`set_clip_instance_frame`](SceneRenderer::set_clip_instance_frame) —
363/// trying to scrub two instances of one streaming clip independently is a
364/// compile error, not a silent coupling. (Use a flipbook clip for
365/// per-instance frames.) Move it with
366/// [`set_streaming_instance_transform`](SceneRenderer::set_streaming_instance_transform)
367/// and drop it with
368/// [`remove_streaming_instance`](SceneRenderer::remove_streaming_instance).
369#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
370pub struct StreamingInstanceId(SpriteInstanceId);
371
372/// Facade-side slotmap for streaming clips (mirrors [`CharMap`], epoch bump
373/// on `reset` included).
374#[derive(Default)]
375struct StreamingClipMap {
376 /// Per slot: `(epoch_at_alloc, live)`.
377 slots: Vec<(u32, bool)>,
378 epoch: u32,
379}
380
381impl StreamingClipMap {
382 fn alloc(&mut self, index: u32) -> StreamingClipId {
383 debug_assert_eq!(self.slots.len() as u32, index);
384 self.slots.push((self.epoch, true));
385 StreamingClipId {
386 slot: index,
387 gen: self.epoch,
388 }
389 }
390 fn index(&self, id: StreamingClipId) -> Option<usize> {
391 let (gen, live) = *self.slots.get(id.slot as usize)?;
392 (gen == id.gen && live).then_some(id.slot as usize)
393 }
394 fn remove(&mut self, id: StreamingClipId) -> bool {
395 let Some(slot) = self.slots.get_mut(id.slot as usize) else {
396 return false;
397 };
398 if slot.0 != id.gen || !slot.1 {
399 return false;
400 }
401 slot.1 = false;
402 true
403 }
404 fn reset(&mut self) {
405 self.slots.clear();
406 self.epoch = self.epoch.wrapping_add(1);
407 }
408}
409
410/// One registered streaming clip: the seekable cursor + the single sprite
411/// model it re-uploads each frame, plus the dims/pivot used to rebuild it.
412struct StreamingClipState {
413 cursor: StreamingClip,
414 model: SpriteModelId,
415 dims: [u32; 3],
416 pivot: [f32; 3],
417 /// Colour→material map (TV.3), empty for an all-opaque streaming clip.
418 /// Re-applied on every per-frame re-upload so the streamed model keeps
419 /// its per-voxel materials as it advances.
420 material_map: Vec<(u32, u8)>,
421}
422
423/// Per-clip-attachment playback clock (VCL.6): the timing it needs to
424/// resolve a frame, plus its own accumulating clock.
425struct ClipClock {
426 durations: Vec<u32>,
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 /// Advance the clock by `dt` seconds at its Q8 `speed` and return the
437 /// frame to show. Shared by character attachments and standalone clip
438 /// players. A negative clock (rewind past 0) reads as frame 0 but is
439 /// kept signed so resuming forward is continuous.
440 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
441 fn tick(&mut self, dt: f64) -> u32 {
442 self.clock_ms += dt * 1000.0 * f64::from(self.speed_q8) / 256.0;
443 frame_at(
444 &self.durations,
445 self.loop_mode,
446 self.clock_ms.max(0.0) as u32,
447 ) as u32
448 }
449}
450
451/// Facade-side metadata captured for a registered flipbook clip, so editor
452/// queries + the auto-player don't shadow the `DecodedClip`.
453struct ClipMeta {
454 dims: [u32; 3],
455 pivot: [f32; 3],
456 voxel_world_size: f32,
457 durations: Vec<u32>,
458 loop_mode: LoopMode,
459 /// Colour→material map the clip was registered with (TV.3), empty for an
460 /// all-opaque clip. Retained so an in-place
461 /// [`update_clip_frame`](SceneRenderer::update_clip_frame) re-classifies
462 /// the edited frame's voxels instead of dropping its per-voxel materials.
463 material_map: Vec<(u32, u8)>,
464}
465
466/// Public metadata for a registered clip — the inspector view returned by
467/// [`SceneRenderer::clip_metadata`].
468#[derive(Clone, Debug, PartialEq)]
469pub struct ClipMetadata {
470 /// Fixed bounding box (voxels).
471 pub dims: [u32; 3],
472 /// Model pivot (the kv6 pivot frames share).
473 pub pivot: [f32; 3],
474 /// Render scale (1 voxel = this many world units).
475 pub voxel_world_size: f32,
476 /// Playback wrap behaviour.
477 pub loop_mode: LoopMode,
478 /// Number of frames.
479 pub frame_count: usize,
480 /// Per-frame durations (ms), one per frame.
481 pub durations: Vec<u32>,
482 /// Total loop length (ms) — sum of `durations`.
483 pub total_ms: u32,
484}
485
486/// What an auto-advancing [`ClipPlayer`] (#6) drives each
487/// [`advance_voxel_clips`](SceneRenderer::advance_voxel_clips). A flipbook
488/// clip's frame is per-instance; a streaming clip's is per-clip (its
489/// instances share one model), so the targets differ.
490#[derive(Clone, Copy)]
491enum PlayerTarget {
492 Flipbook(SpriteInstanceId),
493 Streaming(StreamingClipId),
494}
495
496/// A standalone clip given its own playback clock (#6): the host calls
497/// `advance_voxel_clips(dt)` once instead of hand-driving `frame_at` +
498/// `set_clip_instance_frame`.
499struct ClipPlayer {
500 target: PlayerTarget,
501 clock: ClipClock,
502 /// When `true`, [`advance_voxel_clips`](SceneRenderer::advance_voxel_clips)
503 /// leaves the clock (and frame) untouched — the editor's play/pause.
504 paused: bool,
505}
506
507/// One live bone attachment: which bone drives it, its local offset, the
508/// renderer instance it owns, and (for a clip target) its playback clock.
509struct AttachInst {
510 bone: usize,
511 local_offset: roxlap_formats::xform::BoneXform,
512 inst: SpriteInstanceId,
513 clip: Option<ClipClock>,
514}
515
516/// A live animated character: the hinge skeleton (the bone-transform
517/// solver) + one [`AttachInst`] per bone attachment.
518struct CharInstance {
519 skeleton: KfaSprite,
520 attaches: Vec<AttachInst>,
521 /// Sprite models + voxel clips this character registered, so
522 /// [`remove_character`](SceneRenderer::remove_character) can free them
523 /// (otherwise they leak until the next `set_sprites`).
524 models: Vec<SpriteModelId>,
525 clips: Vec<VoxelClipId>,
526}
527
528/// Orientation + position for a dynamic sprite instance — the per-frame
529/// pose passed to [`SceneRenderer::add_sprite_instance_posed`] and
530/// [`set_sprite_instance_transform`](SceneRenderer::set_sprite_instance_transform).
531///
532/// `right`/`up`/`forward` are the instance's local axes expressed in
533/// world space (the columns of the model→world rotation), mapping
534/// directly onto the underlying [`Sprite`]'s `s`/`h`/`f` (kv6 local
535/// +x/+y/+z). They **must** be non-singular (`det ≠ 0`) but need not be
536/// orthonormal — a uniform/non-uniform scale or shear is fine. A
537/// near-singular basis falls through the renderer's degenerate-basis
538/// guards and the instance silently skips that frame rather than
539/// panicking. [`Default`] is the identity basis (axis-aligned).
540#[derive(Clone, Copy, Debug)]
541pub struct DynSpriteTransform {
542 /// Instance world position (the kv6 pivot maps here).
543 pub pos: [f32; 3],
544 /// Local +x in world space ↦ [`Sprite::s`].
545 pub right: [f32; 3],
546 /// Local +y in world space ↦ [`Sprite::h`].
547 pub up: [f32; 3],
548 /// Local +z in world space ↦ [`Sprite::f`].
549 pub forward: [f32; 3],
550}
551
552impl Default for DynSpriteTransform {
553 fn default() -> Self {
554 Self {
555 pos: [0.0, 0.0, 0.0],
556 right: [1.0, 0.0, 0.0],
557 up: [0.0, 1.0, 0.0],
558 forward: [0.0, 0.0, 1.0],
559 }
560 }
561}
562
563impl DynSpriteTransform {
564 /// Stamp this pose onto a [`Sprite`] in place: `pos → p`,
565 /// `right/up/forward → s/h/f` (a direct copy — the basis is the
566 /// model→world columns). Both backends keep the rest of the template
567 /// (`kv6`, `flags`) and only overwrite the pose.
568 pub(crate) fn apply_to(self, s: &mut Sprite) {
569 s.p = self.pos;
570 s.s = self.right;
571 s.h = self.up;
572 s.f = self.forward;
573 }
574}
575
576/// Backend-agnostic sprite description. The facade builds the CPU
577/// per-instance draw list and the GPU instanced registry from the
578/// same data, so both backends show identical sprites. The host owns
579/// content (which models, where, recolouring) — building a recoloured
580/// variant is just a second [`Sprite`] model with edited `kv6.voxels`.
581pub struct SpriteSet {
582 /// Distinct voxel models (KV6 + base orientation). Instances index
583 /// into this; their position overrides the model's.
584 pub models: Vec<Sprite>,
585 pub instances: Vec<SpriteInstanceDesc>,
586 /// Model the [`SceneRenderer::carve_active_sprite`] hotkey edits
587 /// (GPU only, mirroring the demo's `G`-carve). `None` disables it.
588 pub carve_model: Option<usize>,
589}
590
591/// Per-frame inputs both backends consume. The host builds the
592/// [`OpticastSettings`] (it owns scan distance etc.); the facade does
593/// everything else (pool config, sky fill, render, present).
594pub struct FrameParams<'a> {
595 /// CPU opticast settings (scan distance, mip ladder, framebuffer
596 /// geometry). Ignored by the GPU backend.
597 pub settings: &'a OpticastSettings,
598 /// Packed engine sky colour: the CPU sky-miss fill + skycast, and
599 /// the clear colour if no scene renders.
600 pub sky_color: u32,
601 /// Optional sky panorama for the CPU rasterizer's sky sampling.
602 pub sky: Option<&'a Sky>,
603 /// CPU fog: packed colour + max scan distance (voxels). `0` scan
604 /// distance disables CPU fog.
605 pub fog_color: u32,
606 pub fog_max_scan_dist: i32,
607 /// CPU: treat z=255 as air (avoids the S1.X bedrock path for
608 /// out-of-bounds cameras).
609 pub treat_z_max_as_air: bool,
610 /// GPU scene-grid LOD scan distance (world units); see GPU.11.1.
611 /// Ignored by the CPU backend.
612 pub gpu_mip_scan_dist: f32,
613 /// GPU outer-DDA step budget (chunks). Ignored by the CPU backend.
614 pub gpu_max_outer_steps: u32,
615 /// GPU vertical field of view (radians). Ignored by the CPU
616 /// backend (it derives projection from [`OpticastSettings`]).
617 pub gpu_fov_y_rad: f32,
618 /// Whether to draw the renderer's sprites this frame. Both backends
619 /// draw KV6 sprites flat-lit (the clean-room DDA sprite raycaster on
620 /// CPU; uploaded model colours on GPU), so no host-supplied lighting
621 /// is needed — this is just the on/off opt-in. `false` skips sprite
622 /// drawing.
623 pub draw_sprites: bool,
624 /// Per-face directional shading for the voxel grids — voxlap's
625 /// `setsideshades(top, bot, left, right, up, down)`, the grid-scan
626 /// analogue of [`draw_sprites`](Self::draw_sprites). Each
627 /// entry darkens the faces pointing that way; the host typically
628 /// passes its engine's `side_shades()`. The default `[0; 6]` keeps
629 /// `sideshademode` off (no per-side shading), so existing hosts and
630 /// the oracle goldens are unaffected. Applied each frame by **both**
631 /// backends: the CPU rasteriser via `gcsub`, and the GPU scene-DDA
632 /// pass by darkening a hit voxel's brightness by the hit face's
633 /// shade (the face taken from the DDA's last-stepped axis).
634 pub side_shades: [i8; 6],
635}
636
637/// Result of [`SceneRenderer::pick`] — a resolved screen→world voxel
638/// hit. `world` is the surface point (`cam.pos + t · normalize(ray)`);
639/// `grid` + `voxel` are the owning grid and its **grid-local** voxel
640/// (transform-correct for rotated / translated grids).
641#[derive(Clone, Copy, PartialEq, Debug)]
642pub struct PickHit {
643 pub world: [f32; 3],
644 pub grid: roxlap_scene::GridId,
645 pub voxel: glam::IVec3,
646}
647
648/// A world-space view ray: the canonical unproject output of
649/// [`SceneRenderer::view_ray`]. `dir` is unit-length. Feed it straight
650/// to [`roxlap_scene::Scene::raycast`] for depth-free, backend-agnostic
651/// voxel picking (`scene.raycast(ray.origin, ray.dir, max_dist)`), or
652/// intersect it with a plane for tile selection.
653#[derive(Clone, Copy, PartialEq, Debug)]
654pub struct Ray {
655 pub origin: glam::DVec3,
656 pub dir: glam::DVec3,
657}
658
659/// A world-space line segment to draw over a rendered frame via
660/// [`SceneRenderer::draw_lines`] — editor gizmos (bounding boxes, floor
661/// grids, axes, hover wireframes), debug paths, etc.
662#[derive(Clone, Copy, PartialEq, Debug)]
663pub struct Line3 {
664 /// World-space endpoints (voxel units), in the same frame the
665 /// rendered scene + `camera` use.
666 pub a: [f64; 3],
667 pub b: [f64; 3],
668 /// `0xAARRGGBB` — the high byte is an alpha blend factor (`0xFF`
669 /// opaque, `0x00` invisible), the low 24 bits the RGB colour.
670 pub color: u32,
671 /// Screen-space thickness in pixels (`<= 1.0` draws a 1px line).
672 pub width_px: f32,
673 /// `true`: the segment is occluded by nearer rendered geometry
674 /// (depth-tested against the frame's z-buffer). `false`: always on
675 /// top (e.g. a hover highlight that should show through the model).
676 pub depth_test: bool,
677}
678
679/// A handle to an uploaded image-sprite texture, returned by
680/// [`SceneRenderer::upload_image`]. Positional (like [`SpriteModelId`]):
681/// it indexes the backend's texture store. Pass it in an [`ImageSprite`]
682/// for [`SceneRenderer::draw_images`], or to
683/// [`drop_image`](SceneRenderer::drop_image) to release it. Opaque on
684/// purpose — there's no arithmetic to do on it.
685#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
686pub struct ImageId(pub(crate) usize);
687
688/// How an [`ImageSprite`]'s quad is oriented in the world.
689#[derive(Clone, Copy, PartialEq, Debug)]
690pub enum ImageFacing {
691 /// Fixed in world space: the quad lies in the plane spanned by `u`
692 /// (the image's +column / width direction) and `v` (its +row /
693 /// height direction). Both are world-space directions; their length
694 /// is ignored (the quad is sized by [`ImageSprite::size`]), so pass
695 /// the plane's axes directly. Row 0 of the image is the `origin`
696 /// edge and rows grow along `v`.
697 World { u: [f32; 3], v: [f32; 3] },
698 /// Always faces the camera (billboard); `up` is the world direction
699 /// the image's top edge points toward (e.g. world `-Z` for the
700 /// scene-demo's z-down world, or any "up" the host prefers).
701 Billboard { up: [f32; 3] },
702}
703
704/// One placed 2D image sprite for the current frame: a flat textured
705/// quad in world space, composited over the rendered scene with the
706/// frame's depth buffer (so the voxel model can occlude it). Built per
707/// frame and passed to [`SceneRenderer::draw_images`], mirroring
708/// [`Line3`] / [`SceneRenderer::draw_lines`]. The texture is uploaded
709/// once via [`SceneRenderer::upload_image`] and referenced by [`image`].
710///
711/// [`image`]: ImageSprite::image
712#[derive(Clone, Copy, PartialEq, Debug)]
713pub struct ImageSprite {
714 /// The uploaded texture to draw (from [`SceneRenderer::upload_image`]).
715 pub image: ImageId,
716 /// World position of the quad's **top-left** corner — the image's
717 /// `(column 0, row 0)` texel. The quad extends `size[0]` along the
718 /// facing's `u` and `size[1]` along its `v`.
719 pub origin: [f32; 3],
720 /// World orientation of the quad — fixed in world or camera-facing.
721 pub facing: ImageFacing,
722 /// World size of the quad along `u` and `v`. For pixel-art traced at
723 /// 1 texel = 1 voxel, pass `[width as f32, height as f32]`.
724 pub size: [f32; 2],
725 /// Multiplied into every sampled texel (tint + opacity), `0xAARRGGBB`.
726 /// `0xFFFFFFFF` draws the texture unchanged; the high byte scales
727 /// the texel alpha (e.g. `0x80FFFFFF` = 50 % opacity).
728 pub tint: u32,
729 /// Alpha cutoff in `0.0..=1.0`. Texels whose **own** alpha is below
730 /// this are discarded outright (not blended) — crisp pixel-art edges
731 /// instead of a semi-transparent haze, and the same threshold decides
732 /// what [`SceneRenderer::pick_image`] treats as solid. `0.0` keeps the
733 /// plain straight-alpha over-blend (every non-zero texel draws).
734 pub alpha_cutoff: f32,
735 /// `true`: occluded by nearer rendered geometry (depth-tested against
736 /// the frame's depth buffer, with a bias so a quad resting on a
737 /// coincident voxel face doesn't z-fight). `false`: always on top.
738 pub depth_test: bool,
739 /// `true`: draw regardless of which way the quad faces (no backface
740 /// cull) — what reference images usually want. `false`: cull when the
741 /// quad faces away from the camera. Ignored for
742 /// [`ImageFacing::Billboard`] (it always faces the camera).
743 pub double_sided: bool,
744}
745
746/// Backend-agnostic resolved quad: four world corners (`TL, TR, BL, BR`,
747/// with UVs `(0,0) (1,0) (0,1) (1,1)`) + the texture to map. The facade
748/// resolves [`ImageSprite::facing`] into corners and culls back-facing
749/// quads once, so both backends draw from the same geometry.
750#[derive(Clone, Copy, Debug)]
751pub(crate) struct QuadDraw {
752 pub corners: [[f32; 3]; 4],
753 pub image: ImageId,
754 pub tint: u32,
755 pub depth_test: bool,
756 pub alpha_cutoff: f32,
757}
758
759/// Result of [`SceneRenderer::pick_image`] — a resolved screen→sprite hit.
760/// `uv` is the normalised position within the quad (`(0,0)` = top-left
761/// corner); `texel` is the matching source-image pixel; `world` is the
762/// hit point; `t` is its euclidean distance from the camera.
763#[derive(Clone, Copy, PartialEq, Debug)]
764pub struct ImagePickHit {
765 pub image: ImageId,
766 pub uv: [f32; 2],
767 pub texel: (u32, u32),
768 pub world: [f32; 3],
769 pub t: f32,
770}
771
772/// Which renderer a [`SceneRenderer`] resolved to at construction.
773#[derive(Clone, Copy, PartialEq, Eq, Debug)]
774pub enum Backend {
775 /// `roxlap-core` opticast, presented via `softbuffer`.
776 Cpu,
777 /// `roxlap-gpu` compute marcher, presented via wgpu.
778 Gpu,
779}
780
781/// Construction-time options for [`SceneRenderer::new`].
782pub struct RenderOptions {
783 /// Try the GPU backend first. When `false`, or when GPU init
784 /// fails, the renderer uses the CPU backend.
785 pub want_gpu: bool,
786 /// Settings forwarded to [`roxlap_gpu::GpuRenderer`] when the GPU
787 /// backend is selected.
788 pub gpu: GpuRendererSettings,
789 /// Packed `0x00RRGGBB` (alpha ignored) the empty/clear frame fills
790 /// with until a scene render lands. Also the CPU sky-miss colour
791 /// default if a frame supplies none.
792 pub clear_sky: u32,
793 /// CPU [`ScratchPool`](roxlap_core::rasterizer::ScratchPool) `lastx`
794 /// sizing — the largest combined grid `vsid` the CPU rasterizer
795 /// will see. Pre-sizing keeps later frames allocation-free.
796 pub cpu_max_grid_vsid: u32,
797 /// CPU strip-parallel render thread count (capped to the rayon
798 /// pool). One [`ScratchPool`](roxlap_core::rasterizer::ScratchPool)
799 /// slot per thread.
800 pub cpu_render_threads: usize,
801}
802
803impl Default for RenderOptions {
804 fn default() -> Self {
805 Self {
806 want_gpu: false,
807 gpu: GpuRendererSettings::default(),
808 clear_sky: 0x0099_b3d9,
809 // 32 chunks × CHUNK_SIZE_XY — the scene-demo's widest
810 // combined ground grid.
811 cpu_max_grid_vsid: 32 * roxlap_scene::CHUNK_SIZE_XY,
812 cpu_render_threads: 4,
813 }
814 }
815}
816
817/// Depth-test slack (same spirit as the backends' `DEPTH_BIAS`) so a
818/// [`SceneRenderer::pick_image`] hit on a sprite resting on a coincident
819/// voxel face isn't rejected as "occluded".
820const PICK_DEPTH_BIAS: f32 = 0.5;
821
822// --- image-sprite geometry helpers (shared by both backends) ---
823
824fn v_sub(a: [f32; 3], b: [f32; 3]) -> [f32; 3] {
825 [a[0] - b[0], a[1] - b[1], a[2] - b[2]]
826}
827fn v_add(a: [f32; 3], b: [f32; 3]) -> [f32; 3] {
828 [a[0] + b[0], a[1] + b[1], a[2] + b[2]]
829}
830fn v_scale(a: [f32; 3], s: f32) -> [f32; 3] {
831 [a[0] * s, a[1] * s, a[2] * s]
832}
833fn v_dot(a: [f32; 3], b: [f32; 3]) -> f32 {
834 a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
835}
836fn v_cross(a: [f32; 3], b: [f32; 3]) -> [f32; 3] {
837 [
838 a[1] * b[2] - a[2] * b[1],
839 a[2] * b[0] - a[0] * b[2],
840 a[0] * b[1] - a[1] * b[0],
841 ]
842}
843fn v_norm(a: [f32; 3]) -> [f32; 3] {
844 let len = v_dot(a, a).sqrt();
845 if len < 1e-12 {
846 a
847 } else {
848 v_scale(a, 1.0 / len)
849 }
850}
851
852/// Intersect a ray (`origin` + `dir`, `dir` un-normalised) with a quad
853/// `[TL, TR, BL, BR]` and return `(uv, t)` for a front/back hit inside
854/// the quad — `uv` in `0..=1` (`(0,0)` = `TL`), `t` the ray parameter
855/// (`hit = origin + dir·t`). `None` for a parallel ray, a hit behind the
856/// origin, a degenerate quad, or a hit outside the `u`/`v` span. Solves
857/// affine coords exactly for a (possibly skew) parallelogram. Standalone
858/// so the geometry is unit-testable without a renderer.
859fn ray_quad_uv(
860 origin: [f32; 3],
861 dir: [f32; 3],
862 corners: &[[f32; 3]; 4],
863) -> Option<([f32; 2], f32)> {
864 let [tl, tr, bl, _br] = *corners;
865 let ue = v_sub(tr, tl); // +u edge (width)
866 let ve = v_sub(bl, tl); // +v edge (height)
867 let n = v_cross(ue, ve);
868 let denom = v_dot(dir, n);
869 if denom.abs() < 1e-12 {
870 return None; // ray parallel to the quad's plane
871 }
872 let t = v_dot(v_sub(tl, origin), n) / denom;
873 if t <= 1e-6 {
874 return None; // behind / at the origin
875 }
876 let p = v_add(origin, v_scale(dir, t));
877 let rel = v_sub(p, tl);
878 let guu = v_dot(ue, ue);
879 let guv = v_dot(ue, ve);
880 let gvv = v_dot(ve, ve);
881 let det = guu * gvv - guv * guv;
882 if det.abs() < 1e-12 {
883 return None; // degenerate quad
884 }
885 let wu = v_dot(rel, ue);
886 let wv = v_dot(rel, ve);
887 let a = (gvv * wu - guv * wv) / det;
888 let b = (guu * wv - guv * wu) / det;
889 if !(0.0..=1.0).contains(&a) || !(0.0..=1.0).contains(&b) {
890 return None; // outside the quad
891 }
892 Some(([a, b], t))
893}
894
895/// Resolve an [`ImageSprite`] into its four world corners (`TL, TR, BL,
896/// BR`), or `None` when a `double_sided == false` world quad faces away
897/// from the camera (back-face cull) or its plane is degenerate. The
898/// camera basis is used only for [`ImageFacing::Billboard`] and the cull
899/// test.
900fn resolve_quad(sprite: &ImageSprite, camera: &Camera) -> Option<QuadDraw> {
901 let cam_pos = [
902 camera.pos[0] as f32,
903 camera.pos[1] as f32,
904 camera.pos[2] as f32,
905 ];
906 let cam_fwd = v_norm([
907 camera.forward[0] as f32,
908 camera.forward[1] as f32,
909 camera.forward[2] as f32,
910 ]);
911
912 let (u_hat, v_hat) = match sprite.facing {
913 ImageFacing::World { u, v } => (v_norm(u), v_norm(v)),
914 ImageFacing::Billboard { up } => {
915 // Horizontal axis ⟂ both the view direction and `up`; fall
916 // back to the camera right when `up` is parallel to the view.
917 let mut u_hat = v_norm(v_cross(up, cam_fwd));
918 if v_dot(u_hat, u_hat) < 1e-12 {
919 u_hat = v_norm([
920 camera.right[0] as f32,
921 camera.right[1] as f32,
922 camera.right[2] as f32,
923 ]);
924 }
925 // Vertical axis ⟂ both, pointing *down* (rows grow downward)
926 // so the top edge ends up toward `up`.
927 let mut v_hat = v_norm(v_cross(cam_fwd, u_hat));
928 if v_dot(v_hat, up) > 0.0 {
929 v_hat = v_scale(v_hat, -1.0);
930 }
931 (u_hat, v_hat)
932 }
933 };
934
935 let du = v_scale(u_hat, sprite.size[0]);
936 let dv = v_scale(v_hat, sprite.size[1]);
937 let tl = sprite.origin;
938 let tr = v_add(tl, du);
939 let bl = v_add(tl, dv);
940 let br = v_add(tr, dv);
941
942 // Back-face cull for fixed world quads (billboards always face us).
943 if !sprite.double_sided {
944 if let ImageFacing::World { .. } = sprite.facing {
945 let normal = v_cross(du, dv);
946 // Front-facing when the quad normal points toward the camera.
947 if v_dot(normal, v_sub(cam_pos, tl)) <= 0.0 {
948 return None;
949 }
950 }
951 }
952
953 Some(QuadDraw {
954 corners: [tl, tr, bl, br],
955 image: sprite.image,
956 tint: sprite.tint,
957 depth_test: sprite.depth_test,
958 alpha_cutoff: sprite.alpha_cutoff,
959 })
960}
961
962/// Renderer-internal backend; never exposes wgpu or softbuffer types.
963/// The GPU variant owns the whole wgpu device/queue/pipelines, so
964/// it's boxed to keep the enum small.
965enum BackendImpl {
966 // Both variants boxed so the enum stays small regardless of which
967 // backend's state is larger (clippy::large_enum_variant).
968 Cpu(Box<CpuBackend>),
969 Gpu(Box<GpuBackend>),
970}
971
972/// Unified renderer over the CPU and GPU paths. See the crate docs.
973pub struct SceneRenderer {
974 inner: BackendImpl,
975 /// Handles for dynamically added sprite instances (see
976 /// [`Self::add_sprite_instance`]). Reset by [`Self::set_sprites`].
977 dyn_map: DynInstanceMap,
978 /// Handles for registered sprite models (see [`Self::add_sprite_model`]
979 /// and the models returned by [`Self::set_sprites`]). Reset by
980 /// [`Self::set_sprites`].
981 model_map: DynModelMap,
982 /// Handles for registered animated voxel clips (see
983 /// [`Self::add_voxel_clip`]). Reset by [`Self::set_sprites`].
984 clip_map: DynClipMap,
985 /// Handles for registered animated characters (see
986 /// [`Self::add_character`]). Reset by [`Self::set_sprites`].
987 char_map: CharMap,
988 /// Live character runtimes, parallel to `char_map` slots (VCL.6).
989 char_instances: Vec<CharInstance>,
990 /// Handles for registered streaming clips (see
991 /// [`Self::add_streaming_clip`]). Reset by [`Self::set_sprites`].
992 streaming_map: StreamingClipMap,
993 /// Streaming-clip runtimes (cursor + one re-uploaded model), parallel
994 /// to `streaming_map` slots; `None` once removed (#3).
995 streaming_clips: Vec<Option<StreamingClipState>>,
996 /// Metadata per registered flipbook clip, indexed by the backend clip
997 /// index (parallel to `clip_map`). Captured at [`Self::add_voxel_clip`]
998 /// so the editor queries ([`Self::clip_metadata`]) + the auto-player
999 /// don't have to re-pass / shadow the `DecodedClip`. Reset by
1000 /// [`Self::set_sprites`].
1001 clip_meta: Vec<ClipMeta>,
1002 /// Auto-advancing clip players (#6); ticked by
1003 /// [`Self::advance_voxel_clips`]. Reset by [`Self::set_sprites`].
1004 clip_players: Vec<ClipPlayer>,
1005}
1006
1007impl SceneRenderer {
1008 /// Build a renderer for `window` — any [`raw-window-handle`]
1009 /// provider (winit, SDL, GLFW, …) in an `Arc`. `size` is the
1010 /// window's initial physical framebuffer size in pixels; thereafter
1011 /// the host reports changes via [`Self::resize`]. Passing the size
1012 /// explicitly keeps the facade decoupled from any one windowing
1013 /// library's size API.
1014 ///
1015 /// Selects the GPU backend when `opts.want_gpu` and WGPU
1016 /// initialises; otherwise the CPU backend. **Never fails** — a
1017 /// missing/incompatible GPU silently yields the CPU path (the
1018 /// message is logged to stderr).
1019 ///
1020 /// [`raw-window-handle`]: raw_window_handle
1021 #[cfg(not(target_arch = "wasm32"))]
1022 #[must_use]
1023 pub fn new<W>(window: Arc<W>, size: (u32, u32), opts: &RenderOptions) -> Self
1024 where
1025 W: HasWindowHandle + HasDisplayHandle + Send + Sync + 'static,
1026 {
1027 if opts.want_gpu {
1028 match GpuBackend::new(window.clone(), size, opts) {
1029 Ok(g) => {
1030 return Self {
1031 inner: BackendImpl::Gpu(Box::new(g)),
1032 dyn_map: DynInstanceMap::default(),
1033 model_map: DynModelMap::default(),
1034 clip_map: DynClipMap::default(),
1035 char_map: CharMap::default(),
1036 char_instances: Vec::new(),
1037 streaming_map: StreamingClipMap::default(),
1038 streaming_clips: Vec::new(),
1039 clip_meta: Vec::new(),
1040 clip_players: Vec::new(),
1041 };
1042 }
1043 Err(e) => {
1044 eprintln!(
1045 "roxlap-render: GPU init failed ({e}); falling back to the CPU renderer",
1046 );
1047 }
1048 }
1049 }
1050 Self {
1051 inner: BackendImpl::Cpu(Box::new(CpuBackend::new(window, size, opts))),
1052 dyn_map: DynInstanceMap::default(),
1053 model_map: DynModelMap::default(),
1054 clip_map: DynClipMap::default(),
1055 char_map: CharMap::default(),
1056 char_instances: Vec::new(),
1057 streaming_map: StreamingClipMap::default(),
1058 streaming_clips: Vec::new(),
1059 clip_meta: Vec::new(),
1060 clip_players: Vec::new(),
1061 }
1062 }
1063
1064 /// wasm/WebGPU build-time entry: build a renderer over an HTML
1065 /// `canvas`. `size` is the canvas's initial framebuffer size in
1066 /// pixels; the host reports later changes via [`Self::resize`].
1067 ///
1068 /// Async because the browser drives wgpu's adapter/device requests
1069 /// through its event loop — `await` it inside a
1070 /// `wasm_bindgen_futures::spawn_local` task. Selects the GPU
1071 /// (WebGPU) backend when `opts.want_gpu` and WebGPU is available;
1072 /// otherwise (no WebGPU, or init failed) it falls back to the CPU
1073 /// opticast path presented through a WebGL2 blit on the same canvas.
1074 /// **Never fails** — the message is logged to the browser console.
1075 #[cfg(target_arch = "wasm32")]
1076 pub async fn new_from_canvas_async(
1077 canvas: web_sys::HtmlCanvasElement,
1078 size: (u32, u32),
1079 opts: &RenderOptions,
1080 ) -> Self {
1081 if opts.want_gpu {
1082 // `SurfaceTarget::Canvas` moves the canvas into wgpu, so the
1083 // GPU attempt gets a clone — the CPU fallback keeps the
1084 // original if WebGPU init fails.
1085 match GpuBackend::new_async(canvas.clone(), size, opts).await {
1086 Ok(g) => {
1087 return Self {
1088 inner: BackendImpl::Gpu(Box::new(g)),
1089 dyn_map: DynInstanceMap::default(),
1090 model_map: DynModelMap::default(),
1091 clip_map: DynClipMap::default(),
1092 char_map: CharMap::default(),
1093 char_instances: Vec::new(),
1094 streaming_map: StreamingClipMap::default(),
1095 streaming_clips: Vec::new(),
1096 clip_meta: Vec::new(),
1097 clip_players: Vec::new(),
1098 };
1099 }
1100 Err(e) => {
1101 web_sys::console::warn_1(
1102 &format!("roxlap-render: WebGPU init failed ({e}); using the CPU renderer")
1103 .into(),
1104 );
1105 }
1106 }
1107 }
1108 Self {
1109 inner: BackendImpl::Cpu(Box::new(CpuBackend::new_from_canvas(canvas, size, opts))),
1110 dyn_map: DynInstanceMap::default(),
1111 model_map: DynModelMap::default(),
1112 clip_map: DynClipMap::default(),
1113 char_map: CharMap::default(),
1114 char_instances: Vec::new(),
1115 streaming_map: StreamingClipMap::default(),
1116 streaming_clips: Vec::new(),
1117 clip_meta: Vec::new(),
1118 clip_players: Vec::new(),
1119 }
1120 }
1121
1122 /// Which backend was selected.
1123 #[must_use]
1124 pub fn backend(&self) -> Backend {
1125 match self.inner {
1126 BackendImpl::Cpu(_) => Backend::Cpu,
1127 BackendImpl::Gpu(_) => Backend::Gpu,
1128 }
1129 }
1130
1131 /// The GPU adapter description when on the GPU backend, else
1132 /// `None`.
1133 #[must_use]
1134 pub fn adapter_info(&self) -> Option<&str> {
1135 match &self.inner {
1136 BackendImpl::Gpu(g) => Some(g.adapter_info()),
1137 BackendImpl::Cpu(_) => None,
1138 }
1139 }
1140
1141 /// Upload an equirectangular sky panorama (RGBA8, `w×h`) for the
1142 /// GPU marcher's sky sampling. No-op on the CPU backend, which
1143 /// samples the [`Sky`] passed in each [`FrameParams`] instead.
1144 pub fn set_sky_panorama(&mut self, rgba: &[u8], w: u32, h: u32) {
1145 if let BackendImpl::Gpu(g) = &mut self.inner {
1146 g.set_sky_panorama(rgba, w, h);
1147 }
1148 }
1149
1150 /// Follow a window resize. CPU resizes its framebuffer lazily, so
1151 /// this only matters to the GPU swapchain — but it's safe to call
1152 /// for both.
1153 pub fn resize(&mut self, width: u32, height: u32) {
1154 match &mut self.inner {
1155 BackendImpl::Cpu(c) => c.resize(width, height),
1156 BackendImpl::Gpu(g) => g.resize(width, height),
1157 }
1158 }
1159
1160 /// Composite `scene` from `camera` with `frame` params into the
1161 /// backend's frame buffer — **without presenting**. The CPU backend
1162 /// fills sky + runs the opticast compositor into an owned buffer;
1163 /// the GPU backend uploads/refreshes the scene, runs the compute
1164 /// marcher + sprite pass, and acquires (but does not present) the
1165 /// swapchain frame.
1166 ///
1167 /// Finish the frame with exactly one of [`present`](Self::present)
1168 /// (no overlay) or [`paint_egui`](Self::paint_egui) (UI overlay).
1169 /// Calling `render` again without finishing drops the pending frame.
1170 pub fn render(&mut self, scene: &mut Scene, camera: &Camera, frame: &FrameParams) {
1171 match &mut self.inner {
1172 BackendImpl::Cpu(c) => c.render(scene, camera, frame),
1173 BackendImpl::Gpu(g) => g.render(scene, camera, frame),
1174 }
1175 }
1176
1177 /// Draw world-space [`Line3`] segments over the frame
1178 /// [`render`](Self::render) composited, using that frame's camera +
1179 /// projection + depth buffer. Call **after** [`render`](Self::render)
1180 /// and **before** [`present`](Self::present) /
1181 /// [`paint_egui`](Self::paint_egui) — the lines land in the
1182 /// framebuffer, so a subsequent `paint_egui` still draws its panels
1183 /// on top.
1184 ///
1185 /// `camera` must be the one the last frame rendered with (the
1186 /// projection is taken from that frame). Depth-tested segments
1187 /// (`Line3::depth_test`) are occluded by nearer rendered geometry;
1188 /// always-on-top segments ignore depth. See [`Line3`] for colour /
1189 /// width / blend semantics.
1190 pub fn draw_lines(&mut self, camera: &Camera, lines: &[Line3]) {
1191 match &mut self.inner {
1192 BackendImpl::Cpu(c) => c.draw_lines(camera, lines),
1193 BackendImpl::Gpu(g) => g.draw_lines(camera, lines),
1194 }
1195 }
1196
1197 /// Upload (or replace) an RGBA8 image and return a stable [`ImageId`]
1198 /// to reference it in [`draw_images`](Self::draw_images). `rgba` is
1199 /// row-major, `width * height * 4` bytes, **straight** (un-premultiplied)
1200 /// alpha. The texture is retained until [`drop_image`](Self::drop_image),
1201 /// so the per-frame draw call stays cheap. Sampling is
1202 /// nearest-neighbour (pixel-art friendly — no blurring).
1203 ///
1204 /// Returns `None` for malformed input — a wrong byte count
1205 /// (`!= width·height·4`) or a zero dimension — so a bad upload can't be
1206 /// confused with the first valid id (`ImageId(0)`).
1207 pub fn upload_image(&mut self, rgba: &[u8], width: u32, height: u32) -> Option<ImageId> {
1208 if width == 0 || height == 0 || rgba.len() != (width as usize) * (height as usize) * 4 {
1209 return None;
1210 }
1211 Some(match &mut self.inner {
1212 BackendImpl::Cpu(c) => c.upload_image(rgba, width, height),
1213 BackendImpl::Gpu(g) => g.upload_image(rgba, width, height),
1214 })
1215 }
1216
1217 /// Release a texture uploaded with [`upload_image`](Self::upload_image).
1218 /// The id must not be reused afterwards (a later `upload_image` may
1219 /// hand the slot back out under a fresh id).
1220 pub fn drop_image(&mut self, id: ImageId) {
1221 match &mut self.inner {
1222 BackendImpl::Cpu(c) => c.drop_image(id),
1223 BackendImpl::Gpu(g) => g.drop_image(id),
1224 }
1225 }
1226
1227 /// Draw 2D [`ImageSprite`]s over the frame [`render`](Self::render)
1228 /// composited — flat textured quads placed in world space, using that
1229 /// frame's camera + projection + depth buffer. Same contract as
1230 /// [`draw_lines`](Self::draw_lines): call **after** [`render`](Self::render)
1231 /// and **before** [`present`](Self::present) / [`paint_egui`](Self::paint_egui).
1232 ///
1233 /// UVs are perspective-correct (no affine warp on an obliquely-viewed
1234 /// quad). Depth-tested sprites are occluded by nearer rendered
1235 /// geometry (with a bias to avoid z-fighting on a coincident face);
1236 /// the texture's straight alpha + the [`ImageSprite::tint`] composite
1237 /// over the scene. `camera` must be the one the last frame rendered.
1238 pub fn draw_images(&mut self, camera: &Camera, images: &[ImageSprite]) {
1239 if images.is_empty() {
1240 return;
1241 }
1242 let quads: Vec<QuadDraw> = images
1243 .iter()
1244 .filter_map(|s| resolve_quad(s, camera))
1245 .collect();
1246 if quads.is_empty() {
1247 return;
1248 }
1249 match &mut self.inner {
1250 BackendImpl::Cpu(c) => c.draw_images(camera, &quads),
1251 BackendImpl::Gpu(g) => g.draw_images(camera, &quads),
1252 }
1253 }
1254
1255 /// Project a world point to window pixel coordinates `(x, y)` under
1256 /// the projection the **last frame** rendered with — the backend-correct
1257 /// `world → screen` inverse of [`view_ray`](Self::view_ray). `None`
1258 /// before the first frame or for a point at/behind the camera near
1259 /// plane.
1260 ///
1261 /// Both backends honour their own projection (CPU `setcamera`
1262 /// `hx/hy/hz`, GPU vertical-FOV pinhole), so hosts never reconstruct
1263 /// it themselves. The returned `(x, y)` may fall outside `[0, w) ×
1264 /// [0, h)` for points off-screen but in front of the camera.
1265 #[must_use]
1266 pub fn project_point(&self, camera: &Camera, world: [f32; 3]) -> Option<(f32, f32)> {
1267 match &self.inner {
1268 BackendImpl::Cpu(c) => c.project_point(camera, world),
1269 BackendImpl::Gpu(g) => g.project_point(camera, world),
1270 }
1271 }
1272
1273 /// Screen→sprite pick: the nearest [`ImageSprite`] hit under window
1274 /// pixel `(x, y)`, resolving which texel was clicked. `sprites` is the
1275 /// same list passed to [`draw_images`](Self::draw_images) (image
1276 /// sprites are immediate-mode, so the caller owns the set). `None` for
1277 /// a miss.
1278 ///
1279 /// The ray is intersected with each quad's plane and mapped to its
1280 /// `uv` / source texel. A texel whose alpha is below the sprite's
1281 /// [`ImageSprite::alpha_cutoff`] (and any fully-transparent texel) is
1282 /// **see-through** — the pick passes through it to a sprite behind.
1283 /// For [`depth_test`](ImageSprite::depth_test) sprites the hit is
1284 /// rejected when nearer scene geometry occludes that pixel (shares the
1285 /// depth convention + bias of [`pick`](Self::pick); on the GPU backend
1286 /// the occlusion test costs a click-time depth readback).
1287 #[must_use]
1288 pub fn pick_image(
1289 &self,
1290 camera: &Camera,
1291 x: f64,
1292 y: f64,
1293 sprites: &[ImageSprite],
1294 ) -> Option<ImagePickHit> {
1295 if sprites.is_empty() {
1296 return None;
1297 }
1298 let dir = self.pixel_ray(camera, x, y)?;
1299 let dir = [dir[0] as f32, dir[1] as f32, dir[2] as f32];
1300 let dir_len = v_dot(dir, dir).sqrt();
1301 if dir_len < 1e-9 {
1302 return None;
1303 }
1304 let origin = [
1305 camera.pos[0] as f32,
1306 camera.pos[1] as f32,
1307 camera.pos[2] as f32,
1308 ];
1309 // Scene surface distance under this pixel (sky / no-hit → None);
1310 // used to occlude depth-tested sprites. Same metric as `pick`.
1311 let scene_t = self.pick_depth(x as u32, y as u32);
1312
1313 let mut best: Option<ImagePickHit> = None;
1314 for sprite in sprites {
1315 // Reuse the render-path resolve (back-face cull included), so
1316 // a single-sided quad that isn't drawn also can't be picked.
1317 let Some(q) = resolve_quad(sprite, camera) else {
1318 continue;
1319 };
1320 let Some(([a, b], t)) = ray_quad_uv(origin, dir, &q.corners) else {
1321 continue; // miss / parallel / behind
1322 };
1323 let d_eucl = t * dir_len;
1324 if best.is_some_and(|cur| d_eucl >= cur.t) {
1325 continue; // a nearer sprite already won
1326 }
1327 let p = v_add(origin, v_scale(dir, t));
1328
1329 let Some((iw, ih)) = self.image_dims(sprite.image) else {
1330 continue; // dropped / unknown image
1331 };
1332 let tx = ((a * iw as f32) as i32).clamp(0, iw as i32 - 1) as u32;
1333 let ty = ((b * ih as f32) as i32).clamp(0, ih as i32 - 1) as u32;
1334
1335 // See-through test: a texel is solid when its alpha clears the
1336 // cutoff (and a fully-transparent texel is never solid).
1337 let cutoff_u8 = (sprite.alpha_cutoff.clamp(0.0, 1.0) * 255.0) as u32;
1338 let solid_thresh = cutoff_u8.max(1);
1339 if u32::from(self.image_alpha_at(sprite.image, tx, ty)) < solid_thresh {
1340 continue;
1341 }
1342
1343 // Occlusion: a depth-tested sprite behind nearer geometry loses.
1344 if sprite.depth_test {
1345 if let Some(st) = scene_t {
1346 if d_eucl > st + PICK_DEPTH_BIAS {
1347 continue;
1348 }
1349 }
1350 }
1351
1352 best = Some(ImagePickHit {
1353 image: sprite.image,
1354 uv: [a, b],
1355 texel: (tx, ty),
1356 world: p,
1357 t: d_eucl,
1358 });
1359 }
1360 best
1361 }
1362
1363 /// Source dimensions of an uploaded image, or `None` if the id was
1364 /// dropped / never uploaded. Internal helper for [`Self::pick_image`].
1365 fn image_dims(&self, id: ImageId) -> Option<(u32, u32)> {
1366 match &self.inner {
1367 BackendImpl::Cpu(c) => c.image_dims(id),
1368 BackendImpl::Gpu(g) => g.image_dims(id),
1369 }
1370 }
1371
1372 /// Alpha byte of texel `(tx, ty)` in an uploaded image (`0` for an
1373 /// unknown id / out-of-range texel). Internal helper for
1374 /// [`Self::pick_image`].
1375 fn image_alpha_at(&self, id: ImageId, tx: u32, ty: u32) -> u8 {
1376 match &self.inner {
1377 BackendImpl::Cpu(c) => c.image_alpha_at(id, tx, ty),
1378 BackendImpl::Gpu(g) => g.image_alpha_at(id, tx, ty),
1379 }
1380 }
1381
1382 /// Mirror the rendered 3D scene horizontally before display. The flip is
1383 /// applied *before* any egui overlay, so the UI stays upright while the
1384 /// viewport un-mirrors — a fix for the engine's left-handed render.
1385 /// Supported on both backends (CPU reverses the framebuffer rows; GPU
1386 /// mirrors the scene blit + line/image overlays). Picking/projection are
1387 /// unchanged, so a host that flips must mirror its cursor X (`width - x`)
1388 /// for ray casts.
1389 pub fn set_flip_x(&mut self, flip: bool) {
1390 match &mut self.inner {
1391 BackendImpl::Cpu(c) => c.set_flip_x(flip),
1392 BackendImpl::Gpu(g) => g.set_flip_x(flip),
1393 }
1394 }
1395
1396 /// Present the frame [`render`](Self::render) composited, with no UI
1397 /// overlay. Pairs with `render`; use [`paint_egui`](Self::paint_egui)
1398 /// instead to overlay an egui UI before presenting.
1399 pub fn present(&mut self) {
1400 match &mut self.inner {
1401 BackendImpl::Cpu(c) => c.present(),
1402 BackendImpl::Gpu(g) => g.present(),
1403 }
1404 }
1405
1406 /// Block until the active backend has finished all in-flight work, ready
1407 /// for a clean teardown. On the GPU backend this drains the device queue
1408 /// and releases any acquired-but-unpresented swapchain frame; on the CPU
1409 /// backend it is a no-op (nothing is in flight).
1410 ///
1411 /// Call this at shutdown **before dropping the renderer and its window**,
1412 /// so the GPU device/surface tear down with no commands queued and no
1413 /// half-presented frame. Skipping it (or dropping the window first) can
1414 /// leave the driver/compositor showing stale buffers after an exit — the
1415 /// "leftover triangles / flicker" symptom of an unclean shutdown.
1416 pub fn wait_idle(&mut self) {
1417 match &mut self.inner {
1418 BackendImpl::Cpu(c) => c.wait_idle(),
1419 BackendImpl::Gpu(g) => g.wait_idle(),
1420 }
1421 }
1422
1423 /// Overlay an egui UI on the frame [`render`](Self::render)
1424 /// composited, then present it (`hud` feature). The host runs egui
1425 /// itself (e.g. `egui` + `egui-winit`) and passes the tessellated
1426 /// `jobs` ([`egui::Context::tessellate`]) and the per-frame
1427 /// `textures` delta from [`egui::FullOutput`]; `pixels_per_point` is
1428 /// the UI scale (`ctx.pixels_per_point()`).
1429 ///
1430 /// The GPU backend paints via `egui-wgpu`; the CPU backend
1431 /// software-rasterises the tessellation into its framebuffer. Use
1432 /// this **instead of** [`present`](Self::present) — both finish the
1433 /// frame.
1434 #[cfg(feature = "hud")]
1435 pub fn paint_egui(
1436 &mut self,
1437 jobs: &[egui::ClippedPrimitive],
1438 textures: &egui::TexturesDelta,
1439 pixels_per_point: f32,
1440 ) {
1441 match &mut self.inner {
1442 BackendImpl::Cpu(c) => c.paint_egui(jobs, textures, pixels_per_point),
1443 BackendImpl::Gpu(g) => g.paint_egui(jobs, textures, pixels_per_point),
1444 }
1445 }
1446
1447 /// Register sprite models + instances. The CPU backend builds a
1448 /// per-instance draw list; the GPU backend builds an instanced
1449 /// model registry. Call once at setup (or again to replace).
1450 pub fn set_sprites(&mut self, set: &SpriteSet) -> Vec<SpriteModelId> {
1451 match &mut self.inner {
1452 BackendImpl::Cpu(c) => c.set_sprites(set),
1453 BackendImpl::Gpu(g) => g.set_sprites(set),
1454 }
1455 // A fresh sprite set replaces the instance world, so any
1456 // previously added dynamic instances + models are gone — drop their
1457 // handles and re-seat the model slotmap with `set.models.len()`
1458 // live ids `0..n` (model index = chain id on both backends).
1459 self.dyn_map = DynInstanceMap::default();
1460 self.model_map.reset(set.models.len());
1461 // A full sprite rebuild drops the dynamic + clip layers on both
1462 // backends (the GPU registry is replaced), so reset the clip +
1463 // character maps too.
1464 self.clip_map.reset();
1465 self.char_map.reset();
1466 self.char_instances.clear();
1467 self.streaming_map.reset();
1468 self.streaming_clips.clear();
1469 self.clip_meta.clear();
1470 self.clip_players.clear();
1471 (0..set.models.len() as u32)
1472 .map(|slot| SpriteModelId { slot, gen: 0 })
1473 .collect()
1474 }
1475
1476 /// Re-register one sprite model's geometry after you've edited its
1477 /// content (a carve or recolour of its `kv6`). `model` is the
1478 /// [`SpriteModelId`] handed back by [`set_sprites`](Self::set_sprites);
1479 /// `kv6` is the model's **new** geometry — the caller owns the source
1480 /// of truth (e.g. a dense carve grid the surface-only `kv6` can't
1481 /// represent) and supplies the refreshed mesh here.
1482 ///
1483 /// This is a **backend-agnostic content refresh**, not a GPU upload:
1484 /// the renderer brings its stored model up to date however its active
1485 /// backend needs to. The instance set is left untouched (an edit never
1486 /// moves or adds an instance), so on the GPU backend only that one
1487 /// model's voxel data is re-uploaded — through a slack-backed
1488 /// suballocator, one model's bytes rather than the whole registry —
1489 /// while the CPU backend swaps the cached `kv6` into each instance of
1490 /// the model. Use [`set_sprites`](Self::set_sprites) to add/remove
1491 /// models or change the instance set.
1492 pub fn refresh_sprite_model(&mut self, model: SpriteModelId, kv6: &Kv6) {
1493 let Some(idx) = self.model_map.model_index(model) else {
1494 return; // stale / removed handle → no-op
1495 };
1496 match &mut self.inner {
1497 BackendImpl::Cpu(c) => c.update_sprite_model(idx, kv6),
1498 BackendImpl::Gpu(g) => g.update_sprite_model(idx, kv6),
1499 }
1500 }
1501
1502 /// Like [`refresh_sprite_model`](Self::refresh_sprite_model) but also
1503 /// re-classifies the refreshed voxels into per-voxel material ids by
1504 /// colour (TV.3) via `material_map` — used by the material-aware streaming
1505 /// clip path so a re-uploaded frame keeps its per-voxel materials. An
1506 /// empty map matches `refresh_sprite_model`.
1507 pub fn refresh_sprite_model_with_materials(
1508 &mut self,
1509 model: SpriteModelId,
1510 kv6: &Kv6,
1511 material_map: &[(u32, u8)],
1512 ) {
1513 let Some(idx) = self.model_map.model_index(model) else {
1514 return; // stale / removed handle → no-op
1515 };
1516 match &mut self.inner {
1517 BackendImpl::Cpu(c) => {
1518 c.update_sprite_model_with_materials(idx, kv6, Some(material_map));
1519 }
1520 BackendImpl::Gpu(g) => g.update_sprite_model_with_materials(idx, kv6, material_map),
1521 }
1522 }
1523
1524 /// Add one sprite instance of an already-registered `model` at world
1525 /// `pos`, **incrementally** — the cheap streaming-spawn path that both
1526 /// backends now share (GPU: append to the instance buffer, growing by
1527 /// powers of two; CPU: push one pre-posed [`Sprite`]). Returns a
1528 /// stable [`SpriteInstanceId`] for later removal.
1529 ///
1530 /// `model` must be a [`SpriteModelId`] from the current
1531 /// [`set_sprites`](Self::set_sprites) (a model registered there, even
1532 /// with zero initial instances). Dynamic instances live *after* the
1533 /// static set + any KFA limbs, so register those first.
1534 pub fn add_sprite_instance(&mut self, model: SpriteModelId, pos: [f32; 3]) -> SpriteInstanceId {
1535 self.add_sprite_instance_posed(
1536 model,
1537 DynSpriteTransform {
1538 pos,
1539 ..DynSpriteTransform::default()
1540 },
1541 )
1542 }
1543
1544 /// Add one sprite instance of an already-registered `model`,
1545 /// pre-posed with the orientation in `xf` — the streaming-spawn path
1546 /// for objects that appear mid-flight already rotated (so there's no
1547 /// one-frame axis-aligned flash before the first
1548 /// [`set_sprite_instance_transform`](Self::set_sprite_instance_transform)).
1549 /// Otherwise identical to
1550 /// [`add_sprite_instance`](Self::add_sprite_instance) (which is just
1551 /// this with the identity basis). Returns a stable
1552 /// [`SpriteInstanceId`].
1553 ///
1554 /// A stale/removed `model` handle spawns nothing and returns a handle
1555 /// that is itself already stale (it resolves to no instance). `xf`'s
1556 /// basis must be non-singular; a degenerate one makes the instance
1557 /// silently skip drawing (see [`DynSpriteTransform`]).
1558 pub fn add_sprite_instance_posed(
1559 &mut self,
1560 model: SpriteModelId,
1561 xf: DynSpriteTransform,
1562 ) -> SpriteInstanceId {
1563 let Some(idx) = self.model_map.model_index(model) else {
1564 // Stale model → spawn nothing; hand back a sentinel id that
1565 // resolves to no live instance (a safe no-op everywhere).
1566 return SpriteInstanceId {
1567 slot: u32::MAX,
1568 gen: u32::MAX,
1569 };
1570 };
1571 let dyn_index = match &mut self.inner {
1572 BackendImpl::Cpu(c) => c.add_dyn_instance_posed(idx, xf),
1573 BackendImpl::Gpu(g) => g.add_dyn_instance_posed(idx, xf),
1574 };
1575 self.dyn_map.alloc(dyn_index as u32)
1576 }
1577
1578 /// Remove a dynamic sprite instance added by
1579 /// [`add_sprite_instance`](Self::add_sprite_instance). O(1) on both
1580 /// backends (swap-remove); other dynamic handles stay valid. Returns
1581 /// `false` if the handle is stale / already removed.
1582 pub fn remove_sprite_instance(&mut self, id: SpriteInstanceId) -> bool {
1583 let Some(dyn_index) = self.dyn_map.dyn_index(id) else {
1584 return false;
1585 };
1586 let moved = match &mut self.inner {
1587 BackendImpl::Cpu(c) => c.remove_dyn_instance(dyn_index as usize),
1588 BackendImpl::Gpu(g) => g.remove_dyn_instance(dyn_index as usize),
1589 };
1590 self.dyn_map.remove(id, dyn_index, moved.map(|m| m as u32));
1591 true
1592 }
1593
1594 /// Number of live dynamic sprite instances (those added via
1595 /// [`add_sprite_instance`](Self::add_sprite_instance)).
1596 #[must_use]
1597 pub fn dynamic_sprite_count(&self) -> usize {
1598 self.dyn_map.order.len()
1599 }
1600
1601 /// Register one new sprite **model** incrementally from `kv6`,
1602 /// **without** rebuilding the existing model set — the streaming-in
1603 /// counterpart to [`add_sprite_instance`](Self::add_sprite_instance)
1604 /// for unique generated geometry (procedural asteroids, debris).
1605 /// Returns a stable [`SpriteModelId`] usable immediately with
1606 /// [`add_sprite_instance`](Self::add_sprite_instance) /
1607 /// [`add_sprite_instance_posed`](Self::add_sprite_instance_posed).
1608 ///
1609 /// Works before any [`set_sprites`](Self::set_sprites) (it establishes
1610 /// residency on the GPU backend's first model). The GPU backend
1611 /// appends one LOD chain to the resident registry (amortised O(model
1612 /// Define a global voxel **material** (TV stage): the opacity + blend
1613 /// mode that a per-voxel material id resolves to. The renderer owns one
1614 /// 256-entry palette shared by every model and grid.
1615 ///
1616 /// Id `0` is permanently [`Material::OPAQUE`] — the value every voxel
1617 /// without explicit material data resolves to — and **cannot** be
1618 /// redefined; passing `id == 0` is a no-op that returns `false`. Any
1619 /// other id returns `true`.
1620 ///
1621 /// While no translucent material is defined the renderer stays on the
1622 /// fully-opaque fast path, so this is inert until first called. See
1623 /// `PORTING-TRANSPARENCY.md`.
1624 pub fn define_material(&mut self, id: u8, mat: Material) -> bool {
1625 match &mut self.inner {
1626 BackendImpl::Cpu(c) => c.define_material(id, mat),
1627 BackendImpl::Gpu(g) => g.define_material(id, mat),
1628 }
1629 }
1630
1631 /// The [`Material`] currently at palette `id` ([`Material::OPAQUE`] for
1632 /// any id never passed to [`define_material`](Self::define_material)).
1633 #[must_use]
1634 pub fn material(&self, id: u8) -> Material {
1635 match &self.inner {
1636 BackendImpl::Cpu(c) => c.material(id),
1637 BackendImpl::Gpu(g) => g.material(id),
1638 }
1639 }
1640
1641 /// Set the **terrain** colour→material map (TV.4): pairs of `(rgb,
1642 /// material_id)` that make matching-colour world (grid) voxels translucent
1643 /// — glass walls, water pools. The materials themselves are defined via
1644 /// [`define_material`](Self::define_material). An empty map (the default)
1645 /// keeps all terrain opaque. The CPU backend composites these today; the
1646 /// GPU backend renders them once the TV.6 device path lands.
1647 pub fn set_terrain_materials(&mut self, map: &[(u32, u8)]) {
1648 match &mut self.inner {
1649 BackendImpl::Cpu(c) => c.set_terrain_materials(map),
1650 BackendImpl::Gpu(g) => g.set_terrain_materials(map),
1651 }
1652 }
1653
1654 /// voxels)); the CPU backend pushes an axis-aligned template.
1655 pub fn add_sprite_model(&mut self, kv6: &Kv6) -> SpriteModelId {
1656 let model_index = match &mut self.inner {
1657 BackendImpl::Cpu(c) => c.add_model(kv6),
1658 BackendImpl::Gpu(g) => g.add_model(kv6),
1659 };
1660 self.model_map.alloc(model_index as u32)
1661 }
1662
1663 /// Register a **mixed-material** sprite model (TV.3): `material_map` pairs
1664 /// a voxel RGB colour (`0xRRGGBB`) with a material id (defined via
1665 /// [`define_material`](Self::define_material)), so a single model can mix
1666 /// opaque and translucent voxels — an opaque window frame around glass, a
1667 /// bottle around a translucent potion. Voxels whose colour isn't in the
1668 /// map are opaque (material 0). Like [`add_sprite_model`](Self::add_sprite_model)
1669 /// otherwise.
1670 ///
1671 /// The CPU backend composites per-voxel materials today; the GPU backend
1672 /// carries the data and renders per-voxel materials once the TV.3b device
1673 /// path lands (until then it uses the instance's uniform material).
1674 pub fn add_sprite_model_with_materials(
1675 &mut self,
1676 kv6: &Kv6,
1677 material_map: &[(u32, u8)],
1678 ) -> SpriteModelId {
1679 let model_index = match &mut self.inner {
1680 BackendImpl::Cpu(c) => c.add_model_with_materials(kv6, material_map),
1681 BackendImpl::Gpu(g) => g.add_model_with_materials(kv6, material_map),
1682 };
1683 self.model_map.alloc(model_index as u32)
1684 }
1685
1686 /// Remove a registered sprite model, freeing its voxel data. Returns
1687 /// `false` if `id` is stale / already removed.
1688 ///
1689 /// The model's slot is tombstoned **in place**: its id is never
1690 /// reused, so every other [`SpriteModelId`] stays valid (no remap).
1691 /// Existing instances of the removed model are **not** dropped here —
1692 /// they linger but draw as nothing on the GPU backend (the CPU
1693 /// backend keeps each instance's own kv6 clone, so they keep drawing
1694 /// until removed via
1695 /// [`remove_sprite_instance`](Self::remove_sprite_instance)); remove
1696 /// them when convenient. Call
1697 /// [`compact_sprite_models`](Self::compact_sprite_models) afterwards
1698 /// to reclaim the GPU buffer holes.
1699 pub fn remove_sprite_model(&mut self, id: SpriteModelId) -> bool {
1700 let Some(idx) = self.model_map.model_index(id) else {
1701 return false;
1702 };
1703 match &mut self.inner {
1704 BackendImpl::Cpu(c) => c.remove_model(idx),
1705 BackendImpl::Gpu(g) => g.remove_model(idx),
1706 }
1707 self.model_map.remove(id)
1708 }
1709
1710 /// Reclaim the GPU buffer space left by
1711 /// [`remove_sprite_model`](Self::remove_sprite_model) by repacking the
1712 /// resident registry to its live models only. Model ids are preserved
1713 /// (no remap). O(live voxel volume) — call it when many models have
1714 /// been removed, not every frame. No-op on the CPU backend (which
1715 /// keeps cheap empty placeholders) and when nothing was removed.
1716 pub fn compact_sprite_models(&mut self) {
1717 match &mut self.inner {
1718 BackendImpl::Cpu(c) => c.compact_models(),
1719 BackendImpl::Gpu(g) => g.compact_models(),
1720 }
1721 }
1722
1723 /// Update one dynamic instance's full pose (position + orientation)
1724 /// for this frame. `id` is from
1725 /// [`add_sprite_instance`](Self::add_sprite_instance) /
1726 /// [`add_sprite_instance_posed`](Self::add_sprite_instance_posed). A
1727 /// stale / removed handle is a no-op.
1728 ///
1729 /// For many instances per frame prefer
1730 /// [`set_sprite_instance_transforms`](Self::set_sprite_instance_transforms):
1731 /// the GPU backend flushes all pending pose changes to the device
1732 /// once per [`render`](Self::render), so a per-instance call here is
1733 /// still O(1) device work, but the batch variant avoids re-walking
1734 /// the slotmap.
1735 pub fn set_sprite_instance_transform(&mut self, id: SpriteInstanceId, xf: DynSpriteTransform) {
1736 let Some(dyn_index) = self.dyn_map.dyn_index(id) else {
1737 return;
1738 };
1739 match &mut self.inner {
1740 BackendImpl::Cpu(c) => c.set_dyn_instance_transform(dyn_index as usize, xf),
1741 BackendImpl::Gpu(g) => g.set_dyn_instance_transform(dyn_index as usize, xf),
1742 }
1743 }
1744
1745 /// Batch form of
1746 /// [`set_sprite_instance_transform`](Self::set_sprite_instance_transform)
1747 /// — apply many `(instance, pose)` updates in one call. Stale handles
1748 /// in `updates` are skipped. On the GPU backend this marks the
1749 /// instance buffer dirty once and uploads the new poses a single time
1750 /// at the next [`render`](Self::render), so spinning a whole cluster
1751 /// of instances per frame is one device upload, not one per instance.
1752 pub fn set_sprite_instance_transforms(
1753 &mut self,
1754 updates: &[(SpriteInstanceId, DynSpriteTransform)],
1755 ) {
1756 for &(id, xf) in updates {
1757 let Some(dyn_index) = self.dyn_map.dyn_index(id) else {
1758 continue;
1759 };
1760 match &mut self.inner {
1761 BackendImpl::Cpu(c) => c.set_dyn_instance_transform(dyn_index as usize, xf),
1762 BackendImpl::Gpu(g) => g.set_dyn_instance_transform(dyn_index as usize, xf),
1763 }
1764 }
1765 }
1766
1767 /// Set sprite instance `id`'s voxel-material id (TV stage) — indexes the
1768 /// global palette defined via [`define_material`](Self::define_material)
1769 /// for this whole instance's opacity + blend mode. `0` (the default) is
1770 /// opaque. Stale handles are ignored.
1771 ///
1772 /// Only the CPU backend composites translucent sprites today; the GPU
1773 /// backend retains the value for the forthcoming device-side path (see
1774 /// `PORTING-TRANSPARENCY.md`).
1775 pub fn set_sprite_instance_material(&mut self, id: SpriteInstanceId, material: u8) {
1776 let Some(dyn_index) = self.dyn_map.dyn_index(id) else {
1777 return;
1778 };
1779 match &mut self.inner {
1780 BackendImpl::Cpu(c) => c.set_dyn_instance_material(dyn_index as usize, material),
1781 BackendImpl::Gpu(g) => g.set_dyn_instance_material(dyn_index as usize, material),
1782 }
1783 }
1784
1785 /// Set sprite instance `id`'s per-instance alpha multiplier (TV stage),
1786 /// `0..=255` (`255` = unscaled). Scales the material's opacity so an
1787 /// effect can fade out by cheap per-frame updates without re-uploading
1788 /// its volume. Stale handles are ignored.
1789 pub fn set_sprite_instance_alpha(&mut self, id: SpriteInstanceId, alpha_mul: u8) {
1790 let Some(dyn_index) = self.dyn_map.dyn_index(id) else {
1791 return;
1792 };
1793 match &mut self.inner {
1794 BackendImpl::Cpu(c) => c.set_dyn_instance_alpha(dyn_index as usize, alpha_mul),
1795 BackendImpl::Gpu(g) => g.set_dyn_instance_alpha(dyn_index as usize, alpha_mul),
1796 }
1797 }
1798
1799 // ---- animated voxel clips (VCL.4) ------------------------------------
1800
1801 /// Register an animated voxel clip ("GIF/MP4 for voxels"): decode all
1802 /// its frames and upload the flipbook to the active backend (GPU: one
1803 /// LOD chain per frame; CPU: a cached dense grid per frame). Returns a
1804 /// [`VoxelClipId`] to spawn instances of it via
1805 /// [`add_clip_instance_posed`](Self::add_clip_instance_posed).
1806 ///
1807 /// Build the [`DecodedClip`] from a `.rvc` via
1808 /// [`VoxelClip::decode`](roxlap_formats::voxel_clip::VoxelClip::decode).
1809 /// Like [`add_sprite_model`](Self::add_sprite_model), this works before
1810 /// any [`set_sprites`](Self::set_sprites); a later `set_sprites`
1811 /// **drops** all registered clips (re-register afterwards).
1812 pub fn add_voxel_clip(&mut self, clip: &DecodedClip) -> VoxelClipId {
1813 self.add_voxel_clip_with_materials(clip, &[])
1814 }
1815
1816 /// Register a **mixed-material** animated voxel clip (TV.3): the clip
1817 /// analogue of
1818 /// [`add_sprite_model_with_materials`](Self::add_sprite_model_with_materials).
1819 /// `material_map` pairs a voxel RGB colour (`0xRRGGBB`) with a material id
1820 /// (defined via [`define_material`](Self::define_material)), classifying
1821 /// every frame's voxels so an animated clip can mix opaque and translucent
1822 /// voxels — an opaque torch handle around an additive flame, a spinning
1823 /// glass orb. Voxels whose colour isn't in the map stay opaque
1824 /// (material 0). Like [`add_voxel_clip`](Self::add_voxel_clip) otherwise.
1825 pub fn add_voxel_clip_with_materials(
1826 &mut self,
1827 clip: &DecodedClip,
1828 material_map: &[(u32, u8)],
1829 ) -> VoxelClipId {
1830 let clip_index = match &mut self.inner {
1831 BackendImpl::Cpu(c) => c.add_voxel_clip_with_materials(clip, material_map),
1832 BackendImpl::Gpu(g) => g.add_voxel_clip_with_materials(clip, material_map),
1833 };
1834 // Capture metadata for editor queries + #6 auto-play; clip indices
1835 // are sequential and parallel to `clip_meta`.
1836 debug_assert_eq!(clip_index, self.clip_meta.len());
1837 self.clip_meta.push(ClipMeta {
1838 dims: clip.dims,
1839 pivot: clip.pivot,
1840 voxel_world_size: clip.voxel_world_size,
1841 durations: clip.durations.clone(),
1842 loop_mode: clip.loop_mode,
1843 material_map: material_map.to_vec(),
1844 });
1845 self.clip_map.alloc(clip_index as u32)
1846 }
1847
1848 /// Remove a registered clip, freeing its per-frame volumes. Instances
1849 /// of it linger but draw nothing until removed via
1850 /// [`remove_sprite_instance`](Self::remove_sprite_instance). Returns
1851 /// `false` if `id` is stale / already removed.
1852 pub fn remove_voxel_clip(&mut self, id: VoxelClipId) -> bool {
1853 let Some(clip_index) = self.clip_map.clip_index(id) else {
1854 return false;
1855 };
1856 match &mut self.inner {
1857 BackendImpl::Cpu(c) => c.remove_voxel_clip(clip_index),
1858 BackendImpl::Gpu(g) => g.remove_voxel_clip(clip_index),
1859 }
1860 self.clip_map.remove(id)
1861 }
1862
1863 /// Spawn an instance of clip `clip`, posed by `xf`, starting on frame
1864 /// 0. Returns a [`SpriteInstanceId`] — a clip instance is a dynamic
1865 /// sprite instance, so move it with
1866 /// [`set_sprite_instance_transform`](Self::set_sprite_instance_transform),
1867 /// advance its frame with
1868 /// [`set_clip_instance_frame`](Self::set_clip_instance_frame), and drop
1869 /// it with [`remove_sprite_instance`](Self::remove_sprite_instance).
1870 /// A stale `clip` handle yields an instance id that resolves to nothing
1871 /// (a safe no-op everywhere).
1872 ///
1873 /// This instance has **no playback clock**: drive its frame yourself via
1874 /// [`set_clip_instance_frame`](Self::set_clip_instance_frame) (frame-based
1875 /// scrubbing). For *clock*-based control — auto-advance, play/pause, or
1876 /// [`set_clip_instance_clock_ms`](Self::set_clip_instance_clock_ms)
1877 /// scrubbing — spawn with
1878 /// [`add_clip_instance_playing`](Self::add_clip_instance_playing) instead
1879 /// (the player-control methods no-op on an instance with no player).
1880 pub fn add_clip_instance_posed(
1881 &mut self,
1882 clip: VoxelClipId,
1883 xf: DynSpriteTransform,
1884 ) -> SpriteInstanceId {
1885 let Some(clip_index) = self.clip_map.clip_index(clip) else {
1886 return SpriteInstanceId {
1887 slot: u32::MAX,
1888 gen: u32::MAX,
1889 };
1890 };
1891 let dyn_index = match &mut self.inner {
1892 BackendImpl::Cpu(c) => c.add_clip_instance(clip_index, xf),
1893 BackendImpl::Gpu(g) => g.add_clip_instance(clip_index, xf),
1894 };
1895 self.dyn_map.alloc(dyn_index as u32)
1896 }
1897
1898 /// Select which frame a clip instance shows — the per-frame playback
1899 /// step. Cheap on both backends (GPU: swap the instance's model id;
1900 /// CPU: select the cached frame grid), with no volume re-upload. Drive
1901 /// it from a playback clock via
1902 /// [`DecodedClip::frame_at`](roxlap_formats::voxel_clip::DecodedClip::frame_at).
1903 /// No-op on a stale id or a non-clip instance.
1904 pub fn set_clip_instance_frame(&mut self, id: SpriteInstanceId, frame: u32) {
1905 let Some(dyn_index) = self.dyn_map.dyn_index(id) else {
1906 return;
1907 };
1908 match &mut self.inner {
1909 BackendImpl::Cpu(c) => c.set_clip_frame(dyn_index as usize, frame as usize),
1910 BackendImpl::Gpu(g) => g.set_clip_frame(dyn_index as usize, frame as usize),
1911 }
1912 }
1913
1914 // ---- clip queries (editor inspector) ---------------------------------
1915
1916 /// Frame count of a registered flipbook clip, or `None` if `id` is
1917 /// stale. (Same as `clip_metadata(id)?.frame_count`, without the clone.)
1918 #[must_use]
1919 pub fn clip_frame_count(&self, id: VoxelClipId) -> Option<usize> {
1920 let idx = self.clip_map.clip_index(id)?;
1921 Some(self.clip_meta[idx].durations.len())
1922 }
1923
1924 /// Inspector metadata (dims / pivot / scale / loop mode / per-frame
1925 /// durations) of a registered flipbook clip, or `None` if `id` is stale
1926 /// — so an editor needn't shadow the source [`DecodedClip`].
1927 #[must_use]
1928 pub fn clip_metadata(&self, id: VoxelClipId) -> Option<ClipMetadata> {
1929 let idx = self.clip_map.clip_index(id)?;
1930 let m = &self.clip_meta[idx];
1931 Some(ClipMetadata {
1932 dims: m.dims,
1933 pivot: m.pivot,
1934 voxel_world_size: m.voxel_world_size,
1935 loop_mode: m.loop_mode,
1936 frame_count: m.durations.len(),
1937 durations: m.durations.clone(),
1938 total_ms: m
1939 .durations
1940 .iter()
1941 .fold(0u32, |acc, &d| acc.saturating_add(d)),
1942 })
1943 }
1944
1945 /// Which frame a clip instance is currently showing (the timeline
1946 /// scrubber's read-back), or `None` if `id` isn't a live clip instance.
1947 #[must_use]
1948 pub fn get_clip_instance_frame(&self, id: SpriteInstanceId) -> Option<u32> {
1949 let dyn_index = self.dyn_map.dyn_index(id)? as usize;
1950 let frame = match &self.inner {
1951 BackendImpl::Cpu(c) => c.clip_instance_frame(dyn_index),
1952 BackendImpl::Gpu(g) => g.clip_instance_frame(dyn_index),
1953 }?;
1954 u32::try_from(frame).ok()
1955 }
1956
1957 /// Re-upload a **single** `frame` of registered clip `id` in place — the
1958 /// editor's one-voxel paint, O(1 frame) instead of `remove_voxel_clip` +
1959 /// `add_voxel_clip` (which rebuilds all N volumes). `vf` must fit the
1960 /// clip's fixed `dims`. Returns `false` on a stale `id`, an out-of-range
1961 /// `frame`, or a frame that fails the clip's layout (so it can't corrupt
1962 /// the flipbook).
1963 pub fn update_clip_frame(&mut self, id: VoxelClipId, frame: u32, vf: &VoxelFrame) -> bool {
1964 let Some(clip_index) = self.clip_map.clip_index(id) else {
1965 return false;
1966 };
1967 let m = &self.clip_meta[clip_index];
1968 let (dims, pivot, vws) = (m.dims, m.pivot, m.voxel_world_size);
1969 if vf.validate(dims).is_err() {
1970 return false;
1971 }
1972 // Re-classify with the clip's registered colour→material map (TV.3) so
1973 // an in-place frame edit keeps the clip's per-voxel materials.
1974 let material_map = m.material_map.clone();
1975 let frame = frame as usize;
1976 match &mut self.inner {
1977 BackendImpl::Cpu(c) => {
1978 c.update_clip_frame(clip_index, frame, vf, dims, pivot, &material_map)
1979 }
1980 BackendImpl::Gpu(g) => {
1981 g.update_clip_frame(clip_index, frame, vf, dims, pivot, vws, &material_map)
1982 }
1983 }
1984 }
1985
1986 // ---- streaming voxel clips (#3) --------------------------------------
1987
1988 /// Register a **streaming** voxel clip — `O(1-frame)` memory (one sprite
1989 /// model + the compact encoded stream) rather than the N-volume flipbook
1990 /// [`add_voxel_clip`](Self::add_voxel_clip) builds, for huge clips where
1991 /// N frames are too costly to hold resident. Builds the model from frame
1992 /// 0; advance it with
1993 /// [`set_streaming_clip_frame`](Self::set_streaming_clip_frame). Spawn
1994 /// instances with
1995 /// [`add_streaming_clip_instance`](Self::add_streaming_clip_instance) —
1996 /// note that, unlike a flipbook, **all** instances of a streaming clip
1997 /// share its one model and so always show the same (current) frame.
1998 ///
1999 /// Takes the *encoded* [`VoxelClip`] (not a [`DecodedClip`]) — the whole
2000 /// point is to avoid materialising every frame.
2001 ///
2002 /// # Errors
2003 /// [`DecodeError`] if the clip's frame stream is empty or doesn't begin
2004 /// with a keyframe.
2005 pub fn add_streaming_clip(&mut self, clip: &VoxelClip) -> Result<StreamingClipId, DecodeError> {
2006 self.add_streaming_clip_with_materials(clip, &[])
2007 }
2008
2009 /// Register a **mixed-material** streaming voxel clip (TV.3): the streaming
2010 /// analogue of
2011 /// [`add_voxel_clip_with_materials`](Self::add_voxel_clip_with_materials).
2012 /// `material_map` pairs a voxel RGB colour with a material id (defined via
2013 /// [`define_material`](Self::define_material)); it is re-applied on every
2014 /// per-frame re-upload, so the single streamed model keeps its per-voxel
2015 /// materials as the clip advances. An empty map is identical to
2016 /// [`add_streaming_clip`](Self::add_streaming_clip).
2017 ///
2018 /// # Errors
2019 /// As [`add_streaming_clip`](Self::add_streaming_clip).
2020 pub fn add_streaming_clip_with_materials(
2021 &mut self,
2022 clip: &VoxelClip,
2023 material_map: &[(u32, u8)],
2024 ) -> Result<StreamingClipId, DecodeError> {
2025 let cursor = StreamingClip::new(clip)?;
2026 let dims = cursor.dims();
2027 let pivot = cursor.pivot();
2028 let kv6 = cursor.current_frame().to_kv6(dims, pivot);
2029 let model = self.add_sprite_model_with_materials(&kv6, material_map);
2030 let index = self.streaming_clips.len() as u32;
2031 self.streaming_clips.push(Some(StreamingClipState {
2032 cursor,
2033 model,
2034 dims,
2035 pivot,
2036 material_map: material_map.to_vec(),
2037 }));
2038 Ok(self.streaming_map.alloc(index))
2039 }
2040
2041 /// Spawn an instance of streaming clip `id`, posed by `xf`. Returns a
2042 /// [`SpriteInstanceId`] — move it with
2043 /// [`set_sprite_instance_transform`](Self::set_sprite_instance_transform)
2044 /// and drop it with
2045 /// [`remove_sprite_instance`](Self::remove_sprite_instance), like any
2046 /// dynamic instance. All instances of one streaming clip share its single
2047 /// model. A stale `id` yields a no-op instance handle.
2048 pub fn add_streaming_clip_instance(
2049 &mut self,
2050 id: StreamingClipId,
2051 xf: DynSpriteTransform,
2052 ) -> StreamingInstanceId {
2053 let model = self
2054 .streaming_map
2055 .index(id)
2056 .and_then(|idx| self.streaming_clips[idx].as_ref())
2057 .map(|s| s.model);
2058 let inst = match model {
2059 Some(model) => self.add_sprite_instance_posed(model, xf),
2060 None => SpriteInstanceId {
2061 slot: u32::MAX,
2062 gen: u32::MAX,
2063 },
2064 };
2065 StreamingInstanceId(inst)
2066 }
2067
2068 /// Re-pose a streaming-clip instance (world transform). No-op on a stale
2069 /// handle.
2070 pub fn set_streaming_instance_transform(
2071 &mut self,
2072 id: StreamingInstanceId,
2073 xf: DynSpriteTransform,
2074 ) {
2075 self.set_sprite_instance_transform(id.0, xf);
2076 }
2077
2078 /// Remove a streaming-clip instance. Returns `false` if `id` is stale.
2079 pub fn remove_streaming_instance(&mut self, id: StreamingInstanceId) -> bool {
2080 self.remove_sprite_instance(id.0)
2081 }
2082
2083 /// Advance a streaming clip to `frame`: seek the cursor and re-upload its
2084 /// single model — the per-frame streaming step (one volume re-upload,
2085 /// vs the flipbook's cheap model-select). Updates **every** instance of
2086 /// the clip at once. Drive it from a clock via
2087 /// [`frame_at`](roxlap_formats::voxel_clip::frame_at). No-op on a stale
2088 /// id; `frame` is clamped to the last.
2089 pub fn set_streaming_clip_frame(&mut self, id: StreamingClipId, frame: u32) {
2090 let Some(idx) = self.streaming_map.index(id) else {
2091 return;
2092 };
2093 let Some((model, kv6, material_map)) = self.streaming_clips[idx].as_mut().and_then(|s| {
2094 let vf = s.cursor.seek(frame as usize).ok()?;
2095 Some((s.model, vf.to_kv6(s.dims, s.pivot), s.material_map.clone()))
2096 }) else {
2097 return;
2098 };
2099 self.refresh_sprite_model_with_materials(model, &kv6, &material_map);
2100 }
2101
2102 /// Remove a streaming clip: free its model and drop the cursor (the
2103 /// memory win for huge clips). Instances linger but draw nothing until
2104 /// removed. Returns `false` if `id` is stale / already removed.
2105 pub fn remove_streaming_clip(&mut self, id: StreamingClipId) -> bool {
2106 let Some(idx) = self.streaming_map.index(id) else {
2107 return false;
2108 };
2109 let model = self.streaming_clips[idx].as_ref().map(|s| s.model);
2110 self.streaming_clips[idx] = None;
2111 if let Some(model) = model {
2112 self.remove_sprite_model(model);
2113 }
2114 self.streaming_map.remove(id)
2115 }
2116
2117 // ---- auto-advancing clip players (#6) --------------------------------
2118
2119 /// Spawn a flipbook-clip instance that **plays itself**: like
2120 /// [`add_clip_instance_posed`](Self::add_clip_instance_posed), but the
2121 /// facade tracks a playback clock so a single
2122 /// [`advance_voxel_clips`](Self::advance_voxel_clips) call advances every
2123 /// such instance — no per-frame `frame_at` + `set_clip_instance_frame`
2124 /// bookkeeping in the host. `speed_q8` is the Q8 playback rate (`256` =
2125 /// 1×); `start_phase_ms` offsets the clock (stagger copies of one clip).
2126 /// A stale `clip` yields a no-op instance handle and no player.
2127 pub fn add_clip_instance_playing(
2128 &mut self,
2129 clip: VoxelClipId,
2130 xf: DynSpriteTransform,
2131 speed_q8: i32,
2132 start_phase_ms: u32,
2133 ) -> SpriteInstanceId {
2134 let Some(clip_index) = self.clip_map.clip_index(clip) else {
2135 return SpriteInstanceId {
2136 slot: u32::MAX,
2137 gen: u32::MAX,
2138 };
2139 };
2140 let meta = &self.clip_meta[clip_index];
2141 let clock = ClipClock {
2142 durations: meta.durations.clone(),
2143 loop_mode: meta.loop_mode,
2144 speed_q8,
2145 clock_ms: f64::from(start_phase_ms),
2146 };
2147 let inst = self.add_clip_instance_posed(clip, xf);
2148 self.clip_players.push(ClipPlayer {
2149 target: PlayerTarget::Flipbook(inst),
2150 clock,
2151 paused: false,
2152 });
2153 inst
2154 }
2155
2156 /// Give a streaming clip ([`add_streaming_clip`](Self::add_streaming_clip))
2157 /// its own playback clock, advanced by
2158 /// [`advance_voxel_clips`](Self::advance_voxel_clips). A streaming clip's
2159 /// frame is per-clip (all its instances share one model), so this is
2160 /// keyed on the clip, not an instance — register instances separately
2161 /// with
2162 /// [`add_streaming_clip_instance`](Self::add_streaming_clip_instance).
2163 /// No-op on a stale `clip`.
2164 ///
2165 /// Control the player (play/pause/scrub) via
2166 /// [`set_streaming_clip_paused`](Self::set_streaming_clip_paused) /
2167 /// [`set_streaming_clip_speed`](Self::set_streaming_clip_speed) /
2168 /// [`set_streaming_clip_clock_ms`](Self::set_streaming_clip_clock_ms), the
2169 /// per-clip analogues of the flipbook `set_clip_instance_*` methods.
2170 pub fn play_streaming_clip(
2171 &mut self,
2172 clip: StreamingClipId,
2173 speed_q8: i32,
2174 start_phase_ms: u32,
2175 ) {
2176 let Some(idx) = self.streaming_map.index(clip) else {
2177 return;
2178 };
2179 let Some(state) = self.streaming_clips[idx].as_ref() else {
2180 return;
2181 };
2182 let clock = ClipClock {
2183 durations: state.cursor.durations().to_vec(),
2184 loop_mode: state.cursor.loop_mode(),
2185 speed_q8,
2186 clock_ms: f64::from(start_phase_ms),
2187 };
2188 self.clip_players.push(ClipPlayer {
2189 target: PlayerTarget::Streaming(clip),
2190 clock,
2191 paused: false,
2192 });
2193 }
2194
2195 /// Advance every auto-playing clip ([`add_clip_instance_playing`] /
2196 /// [`play_streaming_clip`]) by `dt` seconds: tick each clock, resolve its
2197 /// frame via [`frame_at`](roxlap_formats::voxel_clip::frame_at), and
2198 /// apply it. Players whose instance / clip was removed are pruned. Call
2199 /// once per frame.
2200 ///
2201 /// [`add_clip_instance_playing`]: Self::add_clip_instance_playing
2202 /// [`play_streaming_clip`]: Self::play_streaming_clip
2203 pub fn advance_voxel_clips(&mut self, dt: f64) {
2204 // Phase 1: tick clocks → (target, frame), pruning dead players.
2205 // Borrow only the maps (disjoint from `clip_players`).
2206 let dyn_map = &self.dyn_map;
2207 let streaming_map = &self.streaming_map;
2208 let mut updates: Vec<(PlayerTarget, u32)> = Vec::new();
2209 self.clip_players.retain_mut(|p| {
2210 let alive = match p.target {
2211 PlayerTarget::Flipbook(inst) => dyn_map.dyn_index(inst).is_some(),
2212 PlayerTarget::Streaming(clip) => streaming_map.index(clip).is_some(),
2213 };
2214 if !alive {
2215 return false;
2216 }
2217 // A paused player keeps its clock + frame (the editor's pause).
2218 if !p.paused {
2219 updates.push((p.target, p.clock.tick(dt)));
2220 }
2221 true
2222 });
2223 // Phase 2: apply (borrows self mutably, disjoint from the above).
2224 for (target, frame) in updates {
2225 self.apply_player_frame(target, frame);
2226 }
2227 }
2228
2229 /// Apply a resolved frame to a player's target (flipbook instance vs.
2230 /// streaming clip).
2231 fn apply_player_frame(&mut self, target: PlayerTarget, frame: u32) {
2232 match target {
2233 PlayerTarget::Flipbook(inst) => self.set_clip_instance_frame(inst, frame),
2234 PlayerTarget::Streaming(clip) => self.set_streaming_clip_frame(clip, frame),
2235 }
2236 }
2237
2238 /// Find the auto-player driving flipbook instance `inst`, if any.
2239 fn flipbook_player_mut(&mut self, inst: SpriteInstanceId) -> Option<&mut ClipPlayer> {
2240 self.clip_players
2241 .iter_mut()
2242 .find(|p| matches!(p.target, PlayerTarget::Flipbook(i) if i == inst))
2243 }
2244
2245 /// Pause / resume the auto-player driving clip instance `id` (the
2246 /// editor's play/pause). No-op if `id` has no player.
2247 pub fn set_clip_instance_paused(&mut self, id: SpriteInstanceId, paused: bool) {
2248 if let Some(p) = self.flipbook_player_mut(id) {
2249 p.paused = paused;
2250 }
2251 }
2252
2253 /// Whether clip instance `id`'s auto-player is paused, or `None` if it
2254 /// has no player.
2255 #[must_use]
2256 pub fn is_clip_instance_paused(&self, id: SpriteInstanceId) -> Option<bool> {
2257 self.clip_players
2258 .iter()
2259 .find(|p| matches!(p.target, PlayerTarget::Flipbook(i) if i == id))
2260 .map(|p| p.paused)
2261 }
2262
2263 /// Set the playback speed (Q8: `256` = 1×, negative = reverse) of clip
2264 /// instance `id`'s auto-player. No-op if `id` has no player.
2265 pub fn set_clip_instance_speed(&mut self, id: SpriteInstanceId, speed_q8: i32) {
2266 if let Some(p) = self.flipbook_player_mut(id) {
2267 p.clock.speed_q8 = speed_q8;
2268 }
2269 }
2270
2271 /// **Scrub**: set clip instance `id`'s playback clock to `clock_ms` and
2272 /// immediately show the matching frame (works while paused). No-op if
2273 /// `id` has no player.
2274 pub fn set_clip_instance_clock_ms(&mut self, id: SpriteInstanceId, clock_ms: f64) {
2275 let Some((target, frame)) = self.flipbook_player_mut(id).map(|p| {
2276 p.clock.clock_ms = clock_ms;
2277 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
2278 let frame = frame_at(
2279 &p.clock.durations,
2280 p.clock.loop_mode,
2281 clock_ms.max(0.0) as u32,
2282 ) as u32;
2283 (p.target, frame)
2284 }) else {
2285 return;
2286 };
2287 self.apply_player_frame(target, frame);
2288 }
2289
2290 /// Clip instance `id`'s current playback-clock position (ms), or `None`
2291 /// if it has no player — the scrubber's read-back.
2292 #[must_use]
2293 pub fn clip_instance_clock_ms(&self, id: SpriteInstanceId) -> Option<f64> {
2294 self.clip_players
2295 .iter()
2296 .find(|p| matches!(p.target, PlayerTarget::Flipbook(i) if i == id))
2297 .map(|p| p.clock.clock_ms)
2298 }
2299
2300 /// Find the auto-player driving streaming clip `clip`, if any (a player
2301 /// registered via [`play_streaming_clip`](Self::play_streaming_clip)).
2302 fn streaming_player_mut(&mut self, clip: StreamingClipId) -> Option<&mut ClipPlayer> {
2303 self.clip_players
2304 .iter_mut()
2305 .find(|p| matches!(p.target, PlayerTarget::Streaming(c) if c == clip))
2306 }
2307
2308 /// Pause / resume a streaming clip's auto-player
2309 /// ([`play_streaming_clip`](Self::play_streaming_clip)). No-op if `clip`
2310 /// has no player.
2311 pub fn set_streaming_clip_paused(&mut self, clip: StreamingClipId, paused: bool) {
2312 if let Some(p) = self.streaming_player_mut(clip) {
2313 p.paused = paused;
2314 }
2315 }
2316
2317 /// Whether streaming clip `clip`'s auto-player is paused, or `None` if it
2318 /// has no player.
2319 #[must_use]
2320 pub fn is_streaming_clip_paused(&self, clip: StreamingClipId) -> Option<bool> {
2321 self.clip_players
2322 .iter()
2323 .find(|p| matches!(p.target, PlayerTarget::Streaming(c) if c == clip))
2324 .map(|p| p.paused)
2325 }
2326
2327 /// Set the playback speed (Q8: `256` = 1×, negative = reverse) of
2328 /// streaming clip `clip`'s auto-player. No-op if `clip` has no player.
2329 pub fn set_streaming_clip_speed(&mut self, clip: StreamingClipId, speed_q8: i32) {
2330 if let Some(p) = self.streaming_player_mut(clip) {
2331 p.clock.speed_q8 = speed_q8;
2332 }
2333 }
2334
2335 /// **Scrub** a streaming clip: set its auto-player's clock to `clock_ms`
2336 /// and immediately show the matching frame (works while paused). No-op if
2337 /// `clip` has no player.
2338 pub fn set_streaming_clip_clock_ms(&mut self, clip: StreamingClipId, clock_ms: f64) {
2339 let Some((target, frame)) = self.streaming_player_mut(clip).map(|p| {
2340 p.clock.clock_ms = clock_ms;
2341 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
2342 let frame = frame_at(
2343 &p.clock.durations,
2344 p.clock.loop_mode,
2345 clock_ms.max(0.0) as u32,
2346 ) as u32;
2347 (p.target, frame)
2348 }) else {
2349 return;
2350 };
2351 self.apply_player_frame(target, frame);
2352 }
2353
2354 /// Streaming clip `clip`'s current playback-clock position (ms), or
2355 /// `None` if it has no player — the scrubber's read-back.
2356 #[must_use]
2357 pub fn streaming_clip_clock_ms(&self, clip: StreamingClipId) -> Option<f64> {
2358 self.clip_players
2359 .iter()
2360 .find(|p| matches!(p.target, PlayerTarget::Streaming(c) if c == clip))
2361 .map(|p| p.clock.clock_ms)
2362 }
2363
2364 // ---- animated characters (VCL.6) -------------------------------------
2365
2366 /// Register an animated character (RKC v3): upload its meshes as sprite
2367 /// models + its embedded voxel clips as flipbooks, then spawn one
2368 /// renderer instance **per bone attachment** — a static mesh sits at
2369 /// its bone, a clip attachment plays back on its own clock. `clip`
2370 /// selects a skeletal animation clip to drive the bones (`None` =
2371 /// rest pose). Returns a [`CharacterId`]; advance it each frame with
2372 /// [`advance_character`](Self::advance_character).
2373 ///
2374 /// Like clips, this works before any [`set_sprites`](Self::set_sprites);
2375 /// a later `set_sprites` drops all registered characters.
2376 pub fn add_character(&mut self, ch: &Character, clip: Option<usize>) -> CharacterId {
2377 // 1. Meshes → sprite models.
2378 let model_ids: Vec<SpriteModelId> =
2379 ch.meshes.iter().map(|m| self.add_sprite_model(m)).collect();
2380 // 2. Voxel clips → flipbooks; keep each one's timing for the clocks.
2381 let clip_regs: Vec<Option<(VoxelClipId, Vec<u32>, LoopMode)>> = ch
2382 .voxel_clips
2383 .iter()
2384 .map(|vc| {
2385 vc.decode().ok().map(|d| {
2386 let id = self.add_voxel_clip(&d);
2387 (id, d.durations, d.loop_mode)
2388 })
2389 })
2390 .collect();
2391 // 3. Build + solve the skeleton (rest pose → bone transforms).
2392 let mut skeleton = ch.to_kfa_sprite(clip);
2393 solve_kfa_limbs(&mut skeleton);
2394 // 4. One instance per attachment, posed by bone × local_offset.
2395 let mut attaches = Vec::new();
2396 for (bi, bone) in ch.bones.iter().enumerate() {
2397 let limb = &skeleton.limbs[bi];
2398 for att in &bone.attachments {
2399 let (s, h, f, p) =
2400 compose_attachment(limb.s, limb.h, limb.f, limb.p, &att.local_offset);
2401 let xf = DynSpriteTransform {
2402 pos: p,
2403 right: s,
2404 up: h,
2405 forward: f,
2406 };
2407 match att.target {
2408 MeshRef::Static(mi) => {
2409 if let Some(&mid) = model_ids.get(mi) {
2410 let inst = self.add_sprite_instance_posed(mid, xf);
2411 attaches.push(AttachInst {
2412 bone: bi,
2413 local_offset: att.local_offset,
2414 inst,
2415 clip: None,
2416 });
2417 }
2418 }
2419 MeshRef::Clip(ci) => {
2420 if let Some(Some((cid, durations, loop_mode))) = clip_regs.get(ci) {
2421 let inst = self.add_clip_instance_posed(*cid, xf);
2422 attaches.push(AttachInst {
2423 bone: bi,
2424 local_offset: att.local_offset,
2425 inst,
2426 clip: Some(ClipClock {
2427 durations: durations.clone(),
2428 loop_mode: *loop_mode,
2429 speed_q8: att.playback.speed_q8,
2430 clock_ms: f64::from(att.playback.start_phase_ms),
2431 }),
2432 });
2433 }
2434 }
2435 }
2436 }
2437 }
2438 let clips: Vec<VoxelClipId> = clip_regs
2439 .iter()
2440 .filter_map(|r| r.as_ref().map(|(cid, _, _)| *cid))
2441 .collect();
2442 let idx = self.char_instances.len();
2443 self.char_instances.push(CharInstance {
2444 skeleton,
2445 attaches,
2446 models: model_ids,
2447 clips,
2448 });
2449 self.char_map.alloc(idx as u32)
2450 }
2451
2452 /// Advance a character by `dt` seconds: tick its skeletal animation +
2453 /// each clip attachment's clock, then re-pose every attachment
2454 /// (bone × local_offset) and select each clip's current frame. No-op on
2455 /// a stale id.
2456 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
2457 pub fn advance_character(&mut self, id: CharacterId, dt: f64) {
2458 let Some(idx) = self.char_map.index(id) else {
2459 return;
2460 };
2461 // Phase 1: solve the skeleton + compute each attachment's update,
2462 // borrowing only `char_instances[idx]`.
2463 let updates: Vec<(SpriteInstanceId, DynSpriteTransform, Option<u32>)> = {
2464 let CharInstance {
2465 skeleton, attaches, ..
2466 } = &mut self.char_instances[idx];
2467 skeleton.animsprite((dt * 1000.0) as i32);
2468 solve_kfa_limbs(skeleton);
2469 attaches
2470 .iter_mut()
2471 .map(|a| {
2472 let limb = &skeleton.limbs[a.bone];
2473 let (s, h, f, p) =
2474 compose_attachment(limb.s, limb.h, limb.f, limb.p, &a.local_offset);
2475 let xf = DynSpriteTransform {
2476 pos: p,
2477 right: s,
2478 up: h,
2479 forward: f,
2480 };
2481 let frame = a.clip.as_mut().map(|c| c.tick(dt));
2482 (a.inst, xf, frame)
2483 })
2484 .collect()
2485 };
2486 // Phase 2: apply via the facade primitives (disjoint from
2487 // `char_instances`).
2488 for (inst, xf, frame) in updates {
2489 self.set_sprite_instance_transform(inst, xf);
2490 if let Some(f) = frame {
2491 self.set_clip_instance_frame(inst, f);
2492 }
2493 }
2494 }
2495
2496 /// Move/re-orient a character to a new world transform `xf` (the root
2497 /// limb's world pose) **without** ticking its animation or clip clocks —
2498 /// a teleport that holds the current animation frame (e.g. dragging a
2499 /// paused character in an editor). Re-solves the skeleton from the new
2500 /// root + re-poses every attachment; clip frames are left as-is. No-op on
2501 /// a stale id.
2502 pub fn set_character_world_transform(&mut self, id: CharacterId, xf: DynSpriteTransform) {
2503 let Some(idx) = self.char_map.index(id) else {
2504 return;
2505 };
2506 // Phase 1: set the root pose + re-solve (no animsprite), then compute
2507 // each attachment's new transform — borrowing only `char_instances`.
2508 let updates: Vec<(SpriteInstanceId, DynSpriteTransform)> = {
2509 let CharInstance {
2510 skeleton, attaches, ..
2511 } = &mut self.char_instances[idx];
2512 skeleton.p = xf.pos;
2513 skeleton.s = xf.right;
2514 skeleton.h = xf.up;
2515 skeleton.f = xf.forward;
2516 solve_kfa_limbs(skeleton);
2517 attaches
2518 .iter()
2519 .map(|a| {
2520 let limb = &skeleton.limbs[a.bone];
2521 let (s, h, f, p) =
2522 compose_attachment(limb.s, limb.h, limb.f, limb.p, &a.local_offset);
2523 (
2524 a.inst,
2525 DynSpriteTransform {
2526 pos: p,
2527 right: s,
2528 up: h,
2529 forward: f,
2530 },
2531 )
2532 })
2533 .collect()
2534 };
2535 // Phase 2: apply (clip frames untouched — clocks didn't tick).
2536 for (inst, t) in updates {
2537 self.set_sprite_instance_transform(inst, t);
2538 }
2539 }
2540
2541 /// Remove a character, dropping all its attachment instances **and**
2542 /// freeing the sprite models + voxel clips it registered. Returns
2543 /// `false` if `id` is stale.
2544 pub fn remove_character(&mut self, id: CharacterId) -> bool {
2545 let Some(idx) = self.char_map.index(id) else {
2546 return false;
2547 };
2548 let insts: Vec<SpriteInstanceId> = self.char_instances[idx]
2549 .attaches
2550 .iter()
2551 .map(|a| a.inst)
2552 .collect();
2553 for inst in insts {
2554 self.remove_sprite_instance(inst);
2555 }
2556 self.char_instances[idx].attaches.clear();
2557 // Free the models + clips this character registered (else they leak
2558 // until a `set_sprites` — costly for an editor hot-swapping all
2559 // session). `mem::take` so the per-id frees can borrow `self`.
2560 let models = std::mem::take(&mut self.char_instances[idx].models);
2561 let clips = std::mem::take(&mut self.char_instances[idx].clips);
2562 for model in models {
2563 self.remove_sprite_model(model);
2564 }
2565 for clip in clips {
2566 self.remove_voxel_clip(clip);
2567 }
2568 self.char_map.remove(id)
2569 }
2570
2571 /// Register animated KFA sprites (one or more bone hierarchies).
2572 /// The GPU backend uploads each limb's kv6 as an instanced model
2573 /// **once** (appended to the sprite registry) and seeds the limb
2574 /// instances at their current pose; the CPU backend caches the
2575 /// posed limbs for drawing. Call once at setup, after
2576 /// [`set_sprites`](Self::set_sprites), then drive motion per frame
2577 /// with [`update_kfa_poses`](Self::update_kfa_poses).
2578 ///
2579 /// Limbs are posed from the sprites' current
2580 /// [`kfaval`](roxlap_formats::kfa::KfaSprite::kfaval) (advance
2581 /// [`animsprite`](roxlap_formats::kfa::KfaSprite::animsprite) first
2582 /// if using a baked curve), so `kfas` is taken `&mut`.
2583 pub fn set_kfa_sprites(&mut self, kfas: &mut [KfaSprite]) {
2584 match &mut self.inner {
2585 BackendImpl::Cpu(c) => c.set_kfa_sprites(kfas),
2586 BackendImpl::Gpu(g) => g.set_kfa_sprites(kfas),
2587 }
2588 }
2589
2590 /// Re-pose the registered KFA sprites from their current
2591 /// `kfaval[]`. Call each frame after advancing the animation
2592 /// (`kfa.animsprite(dt_ms)` or poking `kfaval[]`). The GPU backend
2593 /// takes the cheap transform-only update (no model-volume
2594 /// re-upload); the CPU backend re-solves limb transforms for the
2595 /// next [`render`](Self::render). Must follow a
2596 /// [`set_kfa_sprites`](Self::set_kfa_sprites) with the same sprites.
2597 pub fn update_kfa_poses(&mut self, kfas: &mut [KfaSprite]) {
2598 match &mut self.inner {
2599 BackendImpl::Cpu(c) => c.update_kfa_poses(kfas),
2600 BackendImpl::Gpu(g) => g.update_kfa_poses(kfas),
2601 }
2602 }
2603
2604 /// Carve the next z-layer off the [`SpriteSet::carve_model`] and
2605 /// re-upload (the demo's `G` hotkey + GPU.12 copy-on-modify). GPU
2606 /// only; a no-op on the CPU backend. Returns the voxels removed.
2607 pub fn carve_active_sprite(&mut self) -> u32 {
2608 match &mut self.inner {
2609 BackendImpl::Cpu(_) => 0,
2610 BackendImpl::Gpu(g) => g.carve_active_sprite(),
2611 }
2612 }
2613
2614 /// Request that the next [`render`](Self::render) capture its
2615 /// framebuffer for [`take_capture`](Self::take_capture). CPU only
2616 /// (the GPU swapchain isn't read back) — a no-op on GPU.
2617 pub fn request_capture(&mut self) {
2618 if let BackendImpl::Cpu(c) = &mut self.inner {
2619 c.request_capture();
2620 }
2621 }
2622
2623 /// Take the most recently captured frame as packed `0x00RRGGBB`
2624 /// pixels + dimensions, or `None` if no capture is ready / GPU.
2625 pub fn take_capture(&mut self) -> Option<(Vec<u32>, u32, u32)> {
2626 match &mut self.inner {
2627 BackendImpl::Cpu(c) => c.take_capture(),
2628 BackendImpl::Gpu(_) => None,
2629 }
2630 }
2631
2632 /// Screen→world picking input: the world-space hit distance `t` at
2633 /// window pixel `(x, y)` from the **last rendered frame**, or `None`
2634 /// for out-of-bounds pixels and sky / no-hit. The host reconstructs
2635 /// the world hit point as `cam.pos + t * normalize(ray_dir)`, where
2636 /// `ray_dir` is the same per-pixel ray the frame was rendered with
2637 /// (see the backend's projection).
2638 ///
2639 /// `t` is the distance to the nearest **scene-grid** surface
2640 /// (terrain + grids); sprites do not occlude it (the sprite pass
2641 /// reads depth read-only), so a cursor sprite under the pointer is
2642 /// transparent to the pick.
2643 ///
2644 /// Cost: the CPU backend reads its in-memory z-buffer (free); the
2645 /// GPU backend stages the depth buffer and blocks on a device poll
2646 /// (cheap at click time — do not call every frame). The GPU path
2647 /// only has depth when the last frame drew sprites (`write_depth`).
2648 #[must_use]
2649 pub fn pick_depth(&self, x: u32, y: u32) -> Option<f32> {
2650 match &self.inner {
2651 BackendImpl::Cpu(c) => c.pick_depth(x, y),
2652 BackendImpl::Gpu(g) => g.pick_depth(x, y),
2653 }
2654 }
2655
2656 /// World-space view-ray direction (un-normalised) for window pixel
2657 /// `(x, y)`, under the projection the **last frame** rendered with.
2658 /// The backends differ (CPU `setcamera` vs GPU vertical-FOV
2659 /// pinhole), so this hides which one is active. `None` before the
2660 /// first frame. Intersect it with a plane for tile picking, or feed
2661 /// it to [`Self::pick`] for a voxel.
2662 #[must_use]
2663 pub fn pixel_ray(&self, camera: &Camera, x: f64, y: f64) -> Option<[f64; 3]> {
2664 match &self.inner {
2665 BackendImpl::Cpu(c) => c.pixel_ray(camera, x, y),
2666 BackendImpl::Gpu(g) => g.pixel_ray(camera, x, y),
2667 }
2668 }
2669
2670 /// Canonical screen→world unproject: the full view [`Ray`]
2671 /// (`camera.pos` origin + unit direction) for window pixel
2672 /// `(x, y)`, under whichever projection the last frame used. The
2673 /// one entry point both backends honour — hosts never reconstruct
2674 /// the projection. `None` before the first frame or for a
2675 /// degenerate ray.
2676 ///
2677 /// Compose with [`roxlap_scene::Scene::raycast`] for depth-free
2678 /// picking that's identical on CPU and GPU:
2679 /// `renderer.view_ray(cam, x, y).and_then(|r| scene.raycast(r.origin, r.dir, max))`.
2680 #[must_use]
2681 pub fn view_ray(&self, camera: &Camera, x: f64, y: f64) -> Option<Ray> {
2682 let d = self.pixel_ray(camera, x, y)?;
2683 let len = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt();
2684 if len < 1e-12 {
2685 return None;
2686 }
2687 Some(Ray {
2688 origin: glam::DVec3::from_array([camera.pos[0], camera.pos[1], camera.pos[2]]),
2689 dir: glam::DVec3::new(d[0] / len, d[1] / len, d[2] / len),
2690 })
2691 }
2692
2693 /// One-call screen→world voxel pick: unproject pixel `(x, y)` with
2694 /// the active backend's projection, read the last frame's depth
2695 /// there, reconstruct the world hit, and resolve it to the owning
2696 /// grid + grid-local voxel via [`Scene::resolve_voxel`]. `None` on
2697 /// sky / no-hit, or when no grid claims the surface.
2698 ///
2699 /// `scene` and `camera` must be the ones the last frame rendered;
2700 /// the projection (size + FOV / `hx,hy,hz`) is taken from that
2701 /// frame. Cheap on CPU (in-memory z-buffer); on GPU it stages the
2702 /// depth buffer (a click-time device poll — not per frame).
2703 #[must_use]
2704 pub fn pick(&self, scene: &Scene, camera: &Camera, x: u32, y: u32) -> Option<PickHit> {
2705 let dir = self.pixel_ray(camera, f64::from(x), f64::from(y))?;
2706 let t = f64::from(self.pick_depth(x, y)?);
2707 let len = (dir[0] * dir[0] + dir[1] * dir[1] + dir[2] * dir[2]).sqrt();
2708 if len < 1e-9 {
2709 return None;
2710 }
2711 let s = t / len; // world = cam.pos + t · (dir / |dir|)
2712 let world = glam::DVec3::new(
2713 camera.pos[0] + dir[0] * s,
2714 camera.pos[1] + dir[1] * s,
2715 camera.pos[2] + dir[2] * s,
2716 );
2717 let (grid, voxel) = scene.resolve_voxel(world, glam::DVec3::from_array(dir))?;
2718 #[allow(clippy::cast_possible_truncation)]
2719 let world_f32 = [world.x as f32, world.y as f32, world.z as f32];
2720 Some(PickHit {
2721 world: world_f32,
2722 grid,
2723 voxel,
2724 })
2725 }
2726}
2727
2728#[cfg(test)]
2729mod tests {
2730 use super::*;
2731
2732 /// The handle map must survive the backends' swap-remove indexing:
2733 /// drive a model `DynInstanceMap` against a `Vec` "backend" that
2734 /// swap-removes, and check every live handle keeps resolving to its
2735 /// own payload through a sequence of adds + removes.
2736 #[test]
2737 fn dyn_instance_map_survives_swap_removes() {
2738 let mut map = DynInstanceMap::default();
2739 // The "backend": payload per dynamic index; swap_remove mirrors
2740 // both backends' remove_dyn_instance.
2741 let mut backend: Vec<u32> = Vec::new();
2742 // Our bookkeeping: handle -> the payload we expect it to address.
2743 let mut expect: Vec<(SpriteInstanceId, u32)> = Vec::new();
2744
2745 let add = |map: &mut DynInstanceMap,
2746 backend: &mut Vec<u32>,
2747 expect: &mut Vec<(SpriteInstanceId, u32)>,
2748 payload: u32| {
2749 let dyn_index = backend.len() as u32;
2750 backend.push(payload);
2751 let id = map.alloc(dyn_index);
2752 expect.push((id, payload));
2753 };
2754
2755 for p in 0..6 {
2756 add(&mut map, &mut backend, &mut expect, p);
2757 }
2758
2759 // Remove a middle handle (payload 2) and a later one (payload 4),
2760 // plus the current last — covering swap and no-swap paths.
2761 for victim_payload in [2u32, 4, 5] {
2762 let pos = expect
2763 .iter()
2764 .position(|&(_, p)| p == victim_payload)
2765 .unwrap();
2766 let (id, _) = expect.remove(pos);
2767 let dyn_index = map.dyn_index(id).expect("live handle resolves");
2768 // Backend swap-remove + report moved index (old last), exactly
2769 // like remove_dyn_instance on both backends.
2770 let last = backend.len() - 1;
2771 backend.swap_remove(dyn_index as usize);
2772 let moved = (dyn_index as usize != last).then_some(last as u32);
2773 map.remove(id, dyn_index, moved);
2774 // The removed handle is now stale.
2775 assert!(map.dyn_index(id).is_none(), "removed handle is stale");
2776 }
2777
2778 // Every surviving handle still resolves to its own payload.
2779 for &(id, payload) in &expect {
2780 let idx = map.dyn_index(id).expect("survivor resolves");
2781 assert_eq!(
2782 backend[idx as usize], payload,
2783 "handle addresses its payload"
2784 );
2785 }
2786 assert_eq!(map.order.len(), backend.len());
2787 assert_eq!(backend.len(), expect.len());
2788 }
2789
2790 /// The model slotmap mints stable ids, resolves only live handles,
2791 /// and never reuses a slot — so a removed model's id stays dead and
2792 /// every other id survives the remove.
2793 #[test]
2794 fn dyn_model_map_lifecycle() {
2795 let mut map = DynModelMap::default();
2796 // `set_sprites(3 models)` seeds ids 0..3, all live.
2797 map.reset(3);
2798 let ids: Vec<SpriteModelId> = (0..3).map(|s| SpriteModelId { slot: s, gen: 0 }).collect();
2799 for (i, &id) in ids.iter().enumerate() {
2800 assert_eq!(map.model_index(id), Some(i));
2801 }
2802
2803 // Incrementally add a fourth model.
2804 let extra = map.alloc(3);
2805 assert_eq!(extra, SpriteModelId { slot: 3, gen: 0 });
2806 assert_eq!(map.model_index(extra), Some(3));
2807
2808 // Remove model 1: its handle goes stale, the rest stay valid.
2809 assert!(map.remove(ids[1]));
2810 assert_eq!(map.model_index(ids[1]), None);
2811 assert_eq!(map.model_index(ids[0]), Some(0));
2812 assert_eq!(map.model_index(ids[2]), Some(2));
2813 assert_eq!(map.model_index(extra), Some(3));
2814
2815 // Double remove / stale removal is a no-op returning false.
2816 assert!(!map.remove(ids[1]));
2817
2818 // A bogus / out-of-range handle resolves to nothing, no panic.
2819 let bogus = SpriteModelId { slot: 999, gen: 0 };
2820 assert_eq!(map.model_index(bogus), None);
2821 assert!(!map.remove(bogus));
2822
2823 // A handle with a mismatched generation never resolves (guards a
2824 // future compacting registry).
2825 let wrong_gen = SpriteModelId { slot: 0, gen: 7 };
2826 assert_eq!(map.model_index(wrong_gen), None);
2827 }
2828
2829 /// The voxel-clip slotmap (VCL.4) mints stable ids, resolves only live
2830 /// handles, tombstones in place, and `reset` clears it — mirroring the
2831 /// model slotmap, since clips register append-only too.
2832 #[test]
2833 fn dyn_clip_map_lifecycle() {
2834 let mut map = DynClipMap::default();
2835 // Two clips registered incrementally (indices 0, 1).
2836 let c0 = map.alloc(0);
2837 let c1 = map.alloc(1);
2838 assert_eq!(c0, VoxelClipId { slot: 0, gen: 0 });
2839 assert_eq!(map.clip_index(c0), Some(0));
2840 assert_eq!(map.clip_index(c1), Some(1));
2841
2842 // Remove clip 0: stale handle, clip 1 stays valid; slot not reused.
2843 assert!(map.remove(c0));
2844 assert_eq!(map.clip_index(c0), None);
2845 assert_eq!(map.clip_index(c1), Some(1));
2846 // Double / stale / out-of-range removes are false, no panic.
2847 assert!(!map.remove(c0));
2848 assert!(!map.remove(VoxelClipId { slot: 99, gen: 0 }));
2849 // Mismatched generation never resolves.
2850 assert_eq!(map.clip_index(VoxelClipId { slot: 1, gen: 5 }), None);
2851
2852 // `set_sprites` resets the clip layer → ids restart at slot 0, but
2853 // the epoch bumps so old handles don't alias the new clips.
2854 map.reset();
2855 assert_eq!(map.clip_index(c1), None, "reset invalidates old handles");
2856 let again = map.alloc(0); // re-takes slot 0 under the new epoch
2857 assert_eq!(again, VoxelClipId { slot: 0, gen: 1 });
2858 assert_eq!(map.clip_index(again), Some(0));
2859 // The footgun fix: c0 (slot 0, old epoch) must NOT resolve to the new
2860 // clip now occupying slot 0.
2861 assert_eq!(
2862 map.clip_index(c0),
2863 None,
2864 "a pre-reset handle must not alias a new clip on the same slot"
2865 );
2866 }
2867
2868 /// The character slotmap (VCL.6) mints stable ids, resolves only live
2869 /// handles, tombstones in place, and `reset` clears it.
2870 #[test]
2871 fn char_map_lifecycle() {
2872 let mut map = CharMap::default();
2873 let a = map.alloc(0);
2874 let b = map.alloc(1);
2875 assert_eq!(a, CharacterId { slot: 0, gen: 0 });
2876 assert_eq!(map.index(a), Some(0));
2877 assert_eq!(map.index(b), Some(1));
2878
2879 assert!(map.remove(a));
2880 assert_eq!(map.index(a), None);
2881 assert_eq!(map.index(b), Some(1));
2882 assert!(!map.remove(a)); // double remove is a no-op
2883 assert!(!map.remove(CharacterId { slot: 9, gen: 0 }));
2884 assert_eq!(map.index(CharacterId { slot: 1, gen: 7 }), None);
2885
2886 map.reset();
2887 assert_eq!(map.index(b), None);
2888 assert_eq!(map.alloc(0), CharacterId { slot: 0, gen: 1 });
2889 assert_eq!(map.index(a), None, "pre-reset handle must not alias slot 0");
2890 }
2891
2892 /// The streaming-clip slotmap (#3) mints stable ids, resolves only live
2893 /// handles, tombstones in place, and `reset` clears it.
2894 #[test]
2895 fn streaming_clip_map_lifecycle() {
2896 let mut map = StreamingClipMap::default();
2897 let a = map.alloc(0);
2898 let b = map.alloc(1);
2899 assert_eq!(a, StreamingClipId { slot: 0, gen: 0 });
2900 assert_eq!(map.index(a), Some(0));
2901 assert_eq!(map.index(b), Some(1));
2902
2903 assert!(map.remove(a));
2904 assert_eq!(map.index(a), None);
2905 assert_eq!(map.index(b), Some(1));
2906 assert!(!map.remove(a)); // double remove is a no-op
2907 assert!(!map.remove(StreamingClipId { slot: 9, gen: 0 }));
2908 assert_eq!(map.index(StreamingClipId { slot: 1, gen: 7 }), None);
2909
2910 map.reset();
2911 assert_eq!(map.index(b), None);
2912 assert_eq!(map.alloc(0), StreamingClipId { slot: 0, gen: 1 });
2913 assert_eq!(map.index(a), None, "pre-reset handle must not alias slot 0");
2914 }
2915
2916 /// The shared clip-playback clock (#6 / VCL.6): `tick` accumulates time
2917 /// at its Q8 speed, resolves the frame, honours `start_phase`, and reads
2918 /// a rewound (negative) clock as frame 0.
2919 #[test]
2920 fn clip_clock_tick_advances_and_resolves_frames() {
2921 // 3 frames, 100 ms each → total 300 ms, looping.
2922 let mut c = ClipClock {
2923 durations: vec![100, 100, 100],
2924 loop_mode: LoopMode::Loop,
2925 speed_q8: 256, // 1×
2926 clock_ms: 0.0,
2927 };
2928 assert_eq!(c.tick(0.0), 0); // t=0 → frame 0
2929 assert_eq!(c.tick(0.10), 1); // t=100 → frame 1 (100 is not < 100)
2930 assert_eq!(c.clock_ms as u32, 100);
2931 assert_eq!(c.tick(0.15), 2); // t=250 → frame 2
2932 assert_eq!(c.tick(0.10), 0); // t=350 → 350%300=50 → frame 0
2933 // 0.5× speed advances half as fast.
2934 let mut slow = ClipClock {
2935 durations: vec![100, 100],
2936 loop_mode: LoopMode::Once,
2937 speed_q8: 128, // 0.5×
2938 clock_ms: 0.0,
2939 };
2940 assert_eq!(slow.tick(0.20), 1); // 200ms wall → 100ms clock → frame 1
2941 assert!((slow.clock_ms - 100.0).abs() < 1e-6);
2942 // start_phase seeds the clock; negative clock reads as frame 0.
2943 let mut phased = ClipClock {
2944 durations: vec![50, 50, 50],
2945 loop_mode: LoopMode::Loop,
2946 speed_q8: -256, // rewind
2947 clock_ms: 50.0, // start mid frame 1
2948 };
2949 assert_eq!(phased.tick(0.10), 0); // 50 - 100 = -50 → max(0)=0 → frame 0
2950 assert!(phased.clock_ms < 0.0); // kept signed
2951 }
2952
2953 #[test]
2954 fn dyn_sprite_transform_default_is_identity_and_applies() {
2955 let xf = DynSpriteTransform::default();
2956 assert_eq!(xf.pos, [0.0, 0.0, 0.0]);
2957 assert_eq!(xf.right, [1.0, 0.0, 0.0]);
2958 assert_eq!(xf.up, [0.0, 1.0, 0.0]);
2959 assert_eq!(xf.forward, [0.0, 0.0, 1.0]);
2960
2961 let mut s = Sprite::axis_aligned(
2962 roxlap_formats::kv6::Kv6::solid_cube(2, 0x80_FF_FF_FF),
2963 [9.0, 9.0, 9.0],
2964 );
2965 let posed = DynSpriteTransform {
2966 pos: [1.0, 2.0, 3.0],
2967 right: [0.0, 0.0, 1.0],
2968 up: [0.0, 1.0, 0.0],
2969 forward: [1.0, 0.0, 0.0],
2970 };
2971 posed.apply_to(&mut s);
2972 assert_eq!(s.p, [1.0, 2.0, 3.0]);
2973 assert_eq!(s.s, [0.0, 0.0, 1.0]);
2974 assert_eq!(s.h, [0.0, 1.0, 0.0]);
2975 assert_eq!(s.f, [1.0, 0.0, 0.0]);
2976 }
2977
2978 #[test]
2979 fn options_default_is_cpu_intent() {
2980 let o = RenderOptions::default();
2981 assert!(!o.want_gpu);
2982 assert_eq!(o.clear_sky & 0xFF00_0000, 0, "clear_sky is 0x00RRGGBB");
2983 }
2984
2985 /// A camera at the origin looking down +Y (voxlap z-down world): right
2986 /// = +X, down = +Z, forward = +Y. Handedness `right × down == forward`.
2987 fn cam_looking_y() -> Camera {
2988 Camera {
2989 pos: [0.0, 0.0, 0.0],
2990 right: [1.0, 0.0, 0.0],
2991 down: [0.0, 0.0, 1.0],
2992 forward: [0.0, 1.0, 0.0],
2993 }
2994 }
2995
2996 #[test]
2997 fn world_quad_corner_layout() {
2998 // Top-left at (-5, 10, -5); u = +X (width), v = +Z (down). A
2999 // 10×10 quad facing the camera (its +Y normal points back at us).
3000 let sprite = ImageSprite {
3001 image: ImageId(0),
3002 origin: [-5.0, 10.0, -5.0],
3003 facing: ImageFacing::World {
3004 u: [1.0, 0.0, 0.0],
3005 v: [0.0, 0.0, 1.0],
3006 },
3007 size: [10.0, 10.0],
3008 tint: 0xFFFF_FFFF,
3009 alpha_cutoff: 0.0,
3010 depth_test: true,
3011 double_sided: true,
3012 };
3013 let q = resolve_quad(&sprite, &cam_looking_y()).expect("front-facing");
3014 assert_eq!(q.corners[0], [-5.0, 10.0, -5.0], "TL = origin");
3015 assert_eq!(q.corners[1], [5.0, 10.0, -5.0], "TR = origin + u·size");
3016 assert_eq!(q.corners[2], [-5.0, 10.0, 5.0], "BL = origin + v·size");
3017 assert_eq!(q.corners[3], [5.0, 10.0, 5.0], "BR = origin + u + v");
3018 }
3019
3020 #[test]
3021 fn world_quad_backface_culls_when_single_sided() {
3022 // Same plane but spanned so its normal (u × v) points *away* from
3023 // the camera: swap u/v so the winding flips.
3024 let sprite = ImageSprite {
3025 image: ImageId(0),
3026 origin: [-5.0, 10.0, -5.0],
3027 facing: ImageFacing::World {
3028 u: [0.0, 0.0, 1.0], // v-ish
3029 v: [1.0, 0.0, 0.0], // u-ish → normal flips to -Y... toward camera?
3030 },
3031 size: [10.0, 10.0],
3032 tint: 0xFFFF_FFFF,
3033 alpha_cutoff: 0.0,
3034 depth_test: true,
3035 double_sided: false,
3036 };
3037 // With double_sided=false one of the two windings must cull; the
3038 // opposite winding must draw. Exactly one of the two resolves.
3039 let a = resolve_quad(&sprite, &cam_looking_y()).is_some();
3040 let mut flipped = sprite;
3041 flipped.facing = ImageFacing::World {
3042 u: [1.0, 0.0, 0.0],
3043 v: [0.0, 0.0, 1.0],
3044 };
3045 let b = resolve_quad(&flipped, &cam_looking_y()).is_some();
3046 assert!(a ^ b, "exactly one winding is front-facing");
3047 }
3048
3049 #[test]
3050 fn double_sided_never_culls() {
3051 let mut sprite = ImageSprite {
3052 image: ImageId(0),
3053 origin: [-5.0, 10.0, -5.0],
3054 facing: ImageFacing::World {
3055 u: [0.0, 0.0, 1.0],
3056 v: [1.0, 0.0, 0.0],
3057 },
3058 size: [10.0, 10.0],
3059 tint: 0xFFFF_FFFF,
3060 alpha_cutoff: 0.0,
3061 depth_test: true,
3062 double_sided: true,
3063 };
3064 assert!(resolve_quad(&sprite, &cam_looking_y()).is_some());
3065 sprite.facing = ImageFacing::World {
3066 u: [1.0, 0.0, 0.0],
3067 v: [0.0, 0.0, 1.0],
3068 };
3069 assert!(resolve_quad(&sprite, &cam_looking_y()).is_some());
3070 }
3071
3072 #[test]
3073 fn ray_quad_uv_center_and_corners() {
3074 // 10×10 quad on the y=10 plane: TL(-5,10,-5) u=+X v=+Z. Camera at
3075 // origin looking +Y. A ray straight at the quad centre → uv (.5,.5).
3076 let corners = [
3077 [-5.0, 10.0, -5.0], // TL
3078 [5.0, 10.0, -5.0], // TR
3079 [-5.0, 10.0, 5.0], // BL
3080 [5.0, 10.0, 5.0], // BR
3081 ];
3082 let (uv, t) = ray_quad_uv([0.0, 0.0, 0.0], [0.0, 1.0, 0.0], &corners).expect("center hit");
3083 assert!(
3084 (uv[0] - 0.5).abs() < 1e-5 && (uv[1] - 0.5).abs() < 1e-5,
3085 "centre → (.5,.5)"
3086 );
3087 assert!((t - 10.0).abs() < 1e-4, "t = plane distance");
3088 // Ray toward the TL corner texel region (−x, +y, −z) → uv near (0,0).
3089 let (uv_tl, _) = ray_quad_uv([0.0, 0.0, 0.0], [-4.0, 10.0, -4.0], &corners).unwrap();
3090 assert!(uv_tl[0] < 0.2 && uv_tl[1] < 0.2, "toward TL → small uv");
3091 }
3092
3093 #[test]
3094 fn ray_quad_uv_misses_outside_and_behind() {
3095 let corners = [
3096 [-5.0, 10.0, -5.0],
3097 [5.0, 10.0, -5.0],
3098 [-5.0, 10.0, 5.0],
3099 [5.0, 10.0, 5.0],
3100 ];
3101 // Ray pointing away (−Y) never reaches the +Y plane in front.
3102 assert!(ray_quad_uv([0.0, 0.0, 0.0], [0.0, -1.0, 0.0], &corners).is_none());
3103 // Ray parallel to the quad plane (in +X) → no intersection.
3104 assert!(ray_quad_uv([0.0, 0.0, 0.0], [1.0, 0.0, 0.0], &corners).is_none());
3105 // Ray hitting the plane far outside the quad → outside uv.
3106 assert!(ray_quad_uv([100.0, 0.0, 0.0], [0.0, 1.0, 0.0], &corners).is_none());
3107 }
3108
3109 #[test]
3110 fn billboard_axes_orthogonal_and_top_toward_up() {
3111 // World up = -Z (z-down world). The billboard's v (top→bottom)
3112 // must point away from `up`, and u/v must be ⟂ the view direction.
3113 let up = [0.0, 0.0, -1.0];
3114 let sprite = ImageSprite {
3115 image: ImageId(0),
3116 origin: [0.0, 50.0, 0.0],
3117 facing: ImageFacing::Billboard { up },
3118 size: [4.0, 4.0],
3119 tint: 0xFFFF_FFFF,
3120 alpha_cutoff: 0.0,
3121 depth_test: false,
3122 double_sided: false, // billboards must NEVER cull
3123 };
3124 let q = resolve_quad(&sprite, &cam_looking_y()).expect("billboard always faces camera");
3125 let u = v_sub(q.corners[1], q.corners[0]); // TR - TL = u·size
3126 let v = v_sub(q.corners[2], q.corners[0]); // BL - TL = v·size
3127 let fwd = [0.0, 1.0, 0.0];
3128 assert!(v_dot(u, fwd).abs() < 1e-5, "u ⟂ view");
3129 assert!(v_dot(v, fwd).abs() < 1e-5, "v ⟂ view");
3130 assert!(v_dot(u, v).abs() < 1e-5, "u ⟂ v");
3131 assert!(
3132 v_dot(v, up) < 0.0,
3133 "rows grow away from `up` (top edge toward up)"
3134 );
3135 }
3136}