Skip to main content

roxlap_render/
lib.rs

1//! roxlap-render — unified CPU/GPU renderer facade.
2//!
3//! One [`SceneRenderer`] hides the choice between the CPU opticast
4//! path (`roxlap-core` / `roxlap-scene`, presented via `softbuffer`)
5//! and the GPU compute-shader path (`roxlap-gpu`, presented via its
6//! own wgpu surface). Construction picks the GPU backend when asked
7//! and able, and **falls back to CPU automatically** when WGPU init
8//! fails — so a host never has to branch on GPU availability or carry
9//! the `Scene`→GPU upload/refresh/transform glue itself.
10//!
11//! Hosts stay thin: build a `Scene`, advance it from input, then call
12//! [`SceneRenderer::render`] each frame. The facade owns the window
13//! surface, the framebuffer/z-buffer (CPU) or the resident scene +
14//! dirty-chunk tracking (GPU), and presentation.
15//!
16//! The per-frame flow is `render` → *(optional overlays)* → finish.
17//! Between [`SceneRenderer::render`] and the finishing
18//! [`SceneRenderer::present`] / [`SceneRenderer::paint_egui`] call, a
19//! host may overlay depth-tested world-space lines with
20//! [`SceneRenderer::draw_lines`] (editor gizmos, debug geometry — see
21//! [`Line3`]); they land in the framebuffer, occluded by the rendered
22//! scene, with egui still painting panels on top.
23//!
24//! 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::opticast::OpticastSettings;
43use roxlap_core::sky::Sky;
44use roxlap_core::sprite::SpriteLighting;
45use roxlap_core::Camera;
46use roxlap_scene::Scene;
47
48pub use roxlap_formats::kfa::KfaSprite;
49pub use roxlap_formats::kv6::Kv6;
50pub use roxlap_formats::sprite::Sprite;
51pub use roxlap_gpu::{GpuInitError, GpuRendererSettings, PowerPreference};
52// Re-exported so hosts can name the [`SceneRenderer::new`] bounds
53// without adding a direct `raw-window-handle` dependency of their own.
54pub use raw_window_handle::{HasDisplayHandle, HasWindowHandle};
55// Re-exported so hosts feed [`SceneRenderer::paint_egui`] from the exact
56// egui version the renderer was built against (`hud` feature).
57#[cfg(feature = "hud")]
58pub use egui;
59
60use crate::cpu::CpuBackend;
61use crate::gpu::GpuBackend;
62
63/// Type-erased display handle stored by the CPU backend's softbuffer
64/// surface. `raw-window-handle` implements `HasDisplayHandle` for
65/// `Arc<H>` (`H: ?Sized`), and the bare trait object implements its
66/// own object-safe trait — so `Arc<W>` coerces to `Arc<DynDisplay>`
67/// for any provider `W`.
68#[cfg(not(target_arch = "wasm32"))]
69pub(crate) type DynDisplay = dyn HasDisplayHandle + Send + Sync + 'static;
70/// Type-erased window handle counterpart to [`DynDisplay`].
71#[cfg(not(target_arch = "wasm32"))]
72pub(crate) type DynWindow = dyn HasWindowHandle + Send + Sync + 'static;
73
74/// One placed sprite instance: which [`SpriteSet::models`] entry and
75/// where in the world.
76pub struct SpriteInstanceDesc {
77    pub model: usize,
78    pub pos: [f32; 3],
79}
80
81/// Stable handle to a registered sprite model, returned (one per
82/// [`SpriteSet::models`] entry, in order) by
83/// [`SceneRenderer::set_sprites`]. Pass it to
84/// [`refresh_sprite_model`](SceneRenderer::refresh_sprite_model) to
85/// re-register that model's geometry after a content edit — so callers
86/// never track the positional `usize` index themselves. Opaque on
87/// purpose: there is no arithmetic to do on it.
88#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
89pub struct SpriteModelId(pub(crate) usize);
90
91/// Stable handle to a **dynamically added** sprite instance — the result
92/// of [`SceneRenderer::add_sprite_instance`], passed to
93/// [`remove_sprite_instance`](SceneRenderer::remove_sprite_instance).
94///
95/// Backends remove instances by swap (O(1)), which moves another instance
96/// into the freed slot; this handle survives that because the facade keeps
97/// the id↔slot mapping up to date. The generation guards against a stale
98/// handle aliasing a recycled slot.
99#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
100pub struct SpriteInstanceId {
101    slot: u32,
102    gen: u32,
103}
104
105/// Facade-side slotmap that turns the backends' swap-remove indexing into
106/// stable [`SpriteInstanceId`] handles. Both backends keep their dynamic
107/// instances as a tail sublist indexed `0..n`; `order[dyn_index]` is the
108/// owning slot, and a removal fixes up the one slot whose instance was
109/// swapped into the hole.
110#[derive(Default)]
111struct DynInstanceMap {
112    /// Per slot: `(generation, Some(dyn_index) while live)`.
113    slots: Vec<(u32, Option<u32>)>,
114    /// Per live `dyn_index`: the owning slot. Parallel to the backends'
115    /// dynamic sublist (so `order.len()` == the dynamic instance count).
116    order: Vec<u32>,
117    free: Vec<u32>,
118}
119
120impl DynInstanceMap {
121    /// Register a freshly appended instance (always at `dyn_index ==
122    /// order.len()`); returns its stable handle.
123    fn alloc(&mut self, dyn_index: u32) -> SpriteInstanceId {
124        debug_assert_eq!(self.order.len() as u32, dyn_index);
125        let slot = self.free.pop().unwrap_or_else(|| {
126            self.slots.push((0, None));
127            (self.slots.len() - 1) as u32
128        });
129        let gen = self.slots[slot as usize].0;
130        self.slots[slot as usize].1 = Some(dyn_index);
131        self.order.push(slot);
132        SpriteInstanceId { slot, gen }
133    }
134
135    /// Resolve a handle to its current backend `dyn_index`, or `None` if
136    /// it's stale / already removed.
137    fn dyn_index(&self, id: SpriteInstanceId) -> Option<u32> {
138        let (gen, idx) = *self.slots.get(id.slot as usize)?;
139        (gen == id.gen).then_some(idx).flatten()
140    }
141
142    /// Apply a removal: the backend swap-removed `removed` and reported
143    /// `moved` (the old-last `dyn_index` that slid into `removed`, or
144    /// `None` if `removed` was itself the last).
145    fn remove(&mut self, id: SpriteInstanceId, removed: u32, moved: Option<u32>) {
146        self.slots[id.slot as usize].1 = None;
147        self.slots[id.slot as usize].0 += 1; // bump generation
148        self.free.push(id.slot);
149        if let Some(last) = moved {
150            let moved_slot = self.order[last as usize];
151            self.slots[moved_slot as usize].1 = Some(removed);
152            self.order[removed as usize] = moved_slot;
153        }
154        self.order.pop();
155    }
156}
157
158/// Backend-agnostic sprite description. The facade builds the CPU
159/// per-instance draw list and the GPU instanced registry from the
160/// same data, so both backends show identical sprites. The host owns
161/// content (which models, where, recolouring) — building a recoloured
162/// variant is just a second [`Sprite`] model with edited `kv6.voxels`.
163pub struct SpriteSet {
164    /// Distinct voxel models (KV6 + base orientation). Instances index
165    /// into this; their position overrides the model's.
166    pub models: Vec<Sprite>,
167    pub instances: Vec<SpriteInstanceDesc>,
168    /// Model the [`SceneRenderer::carve_active_sprite`] hotkey edits
169    /// (GPU only, mirroring the demo's `G`-carve). `None` disables it.
170    pub carve_model: Option<usize>,
171}
172
173/// Per-frame inputs both backends consume. The host builds the
174/// [`OpticastSettings`] (it owns scan distance etc.); the facade does
175/// everything else (pool config, sky fill, render, present).
176pub struct FrameParams<'a> {
177    /// CPU opticast settings (scan distance, mip ladder, framebuffer
178    /// geometry). Ignored by the GPU backend.
179    pub settings: &'a OpticastSettings,
180    /// Packed engine sky colour: the CPU sky-miss fill + skycast, and
181    /// the clear colour if no scene renders.
182    pub sky_color: u32,
183    /// Optional sky panorama for the CPU rasterizer's sky sampling.
184    pub sky: Option<&'a Sky>,
185    /// CPU fog: packed colour + max scan distance (voxels). `0` scan
186    /// distance disables CPU fog.
187    pub fog_color: u32,
188    pub fog_max_scan_dist: i32,
189    /// CPU: treat z=255 as air (avoids the S1.X bedrock path for
190    /// out-of-bounds cameras).
191    pub treat_z_max_as_air: bool,
192    /// GPU scene-grid LOD scan distance (world units); see GPU.11.1.
193    /// Ignored by the CPU backend.
194    pub gpu_mip_scan_dist: f32,
195    /// GPU outer-DDA step budget (chunks). Ignored by the CPU backend.
196    pub gpu_max_outer_steps: u32,
197    /// GPU vertical field of view (radians). Ignored by the CPU
198    /// backend (it derives projection from [`OpticastSettings`]).
199    pub gpu_fov_y_rad: f32,
200    /// CPU sprite shading (built by the host from its engine). Required
201    /// for the CPU backend to draw sprites; ignored by the GPU backend
202    /// (its sprite pass shades from the uploaded model colours). `None`
203    /// skips CPU sprite drawing.
204    pub sprite_lighting: Option<&'a SpriteLighting<'a>>,
205    /// Per-face directional shading for the voxel grids — voxlap's
206    /// `setsideshades(top, bot, left, right, up, down)`, the grid-scan
207    /// analogue of [`sprite_lighting`](Self::sprite_lighting). Each
208    /// entry darkens the faces pointing that way; the host typically
209    /// passes its engine's `side_shades()`. The default `[0; 6]` keeps
210    /// `sideshademode` off (no per-side shading), so existing hosts and
211    /// the oracle goldens are unaffected. Applied each frame by **both**
212    /// backends: the CPU rasteriser via `gcsub`, and the GPU scene-DDA
213    /// pass by darkening a hit voxel's brightness by the hit face's
214    /// shade (the face taken from the DDA's last-stepped axis).
215    pub side_shades: [i8; 6],
216}
217
218/// Result of [`SceneRenderer::pick`] — a resolved screen→world voxel
219/// hit. `world` is the surface point (`cam.pos + t · normalize(ray)`);
220/// `grid` + `voxel` are the owning grid and its **grid-local** voxel
221/// (transform-correct for rotated / translated grids).
222#[derive(Clone, Copy, PartialEq, Debug)]
223pub struct PickHit {
224    pub world: [f32; 3],
225    pub grid: roxlap_scene::GridId,
226    pub voxel: glam::IVec3,
227}
228
229/// A world-space view ray: the canonical unproject output of
230/// [`SceneRenderer::view_ray`]. `dir` is unit-length. Feed it straight
231/// to [`roxlap_scene::Scene::raycast`] for depth-free, backend-agnostic
232/// voxel picking (`scene.raycast(ray.origin, ray.dir, max_dist)`), or
233/// intersect it with a plane for tile selection.
234#[derive(Clone, Copy, PartialEq, Debug)]
235pub struct Ray {
236    pub origin: glam::DVec3,
237    pub dir: glam::DVec3,
238}
239
240/// A world-space line segment to draw over a rendered frame via
241/// [`SceneRenderer::draw_lines`] — editor gizmos (bounding boxes, floor
242/// grids, axes, hover wireframes), debug paths, etc.
243#[derive(Clone, Copy, PartialEq, Debug)]
244pub struct Line3 {
245    /// World-space endpoints (voxel units), in the same frame the
246    /// rendered scene + `camera` use.
247    pub a: [f64; 3],
248    pub b: [f64; 3],
249    /// `0xAARRGGBB` — the high byte is an alpha blend factor (`0xFF`
250    /// opaque, `0x00` invisible), the low 24 bits the RGB colour.
251    pub color: u32,
252    /// Screen-space thickness in pixels (`<= 1.0` draws a 1px line).
253    pub width_px: f32,
254    /// `true`: the segment is occluded by nearer rendered geometry
255    /// (depth-tested against the frame's z-buffer). `false`: always on
256    /// top (e.g. a hover highlight that should show through the model).
257    pub depth_test: bool,
258}
259
260/// A handle to an uploaded image-sprite texture, returned by
261/// [`SceneRenderer::upload_image`]. Positional (like [`SpriteModelId`]):
262/// it indexes the backend's texture store. Pass it in an [`ImageSprite`]
263/// for [`SceneRenderer::draw_images`], or to
264/// [`drop_image`](SceneRenderer::drop_image) to release it. Opaque on
265/// purpose — there's no arithmetic to do on it.
266#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
267pub struct ImageId(pub(crate) usize);
268
269/// How an [`ImageSprite`]'s quad is oriented in the world.
270#[derive(Clone, Copy, PartialEq, Debug)]
271pub enum ImageFacing {
272    /// Fixed in world space: the quad lies in the plane spanned by `u`
273    /// (the image's +column / width direction) and `v` (its +row /
274    /// height direction). Both are world-space directions; their length
275    /// is ignored (the quad is sized by [`ImageSprite::size`]), so pass
276    /// the plane's axes directly. Row 0 of the image is the `origin`
277    /// edge and rows grow along `v`.
278    World { u: [f32; 3], v: [f32; 3] },
279    /// Always faces the camera (billboard); `up` is the world direction
280    /// the image's top edge points toward (e.g. world `-Z` for the
281    /// scene-demo's z-down world, or any "up" the host prefers).
282    Billboard { up: [f32; 3] },
283}
284
285/// One placed 2D image sprite for the current frame: a flat textured
286/// quad in world space, composited over the rendered scene with the
287/// frame's depth buffer (so the voxel model can occlude it). Built per
288/// frame and passed to [`SceneRenderer::draw_images`], mirroring
289/// [`Line3`] / [`SceneRenderer::draw_lines`]. The texture is uploaded
290/// once via [`SceneRenderer::upload_image`] and referenced by [`image`].
291///
292/// [`image`]: ImageSprite::image
293#[derive(Clone, Copy, PartialEq, Debug)]
294pub struct ImageSprite {
295    /// The uploaded texture to draw (from [`SceneRenderer::upload_image`]).
296    pub image: ImageId,
297    /// World position of the quad's **top-left** corner — the image's
298    /// `(column 0, row 0)` texel. The quad extends `size[0]` along the
299    /// facing's `u` and `size[1]` along its `v`.
300    pub origin: [f32; 3],
301    /// World orientation of the quad — fixed in world or camera-facing.
302    pub facing: ImageFacing,
303    /// World size of the quad along `u` and `v`. For pixel-art traced at
304    /// 1 texel = 1 voxel, pass `[width as f32, height as f32]`.
305    pub size: [f32; 2],
306    /// Multiplied into every sampled texel (tint + opacity), `0xAARRGGBB`.
307    /// `0xFFFFFFFF` draws the texture unchanged; the high byte scales
308    /// the texel alpha (e.g. `0x80FFFFFF` = 50 % opacity).
309    pub tint: u32,
310    /// Alpha cutoff in `0.0..=1.0`. Texels whose **own** alpha is below
311    /// this are discarded outright (not blended) — crisp pixel-art edges
312    /// instead of a semi-transparent haze, and the same threshold decides
313    /// what [`SceneRenderer::pick_image`] treats as solid. `0.0` keeps the
314    /// plain straight-alpha over-blend (every non-zero texel draws).
315    pub alpha_cutoff: f32,
316    /// `true`: occluded by nearer rendered geometry (depth-tested against
317    /// the frame's depth buffer, with a bias so a quad resting on a
318    /// coincident voxel face doesn't z-fight). `false`: always on top.
319    pub depth_test: bool,
320    /// `true`: draw regardless of which way the quad faces (no backface
321    /// cull) — what reference images usually want. `false`: cull when the
322    /// quad faces away from the camera. Ignored for
323    /// [`ImageFacing::Billboard`] (it always faces the camera).
324    pub double_sided: bool,
325}
326
327/// Backend-agnostic resolved quad: four world corners (`TL, TR, BL, BR`,
328/// with UVs `(0,0) (1,0) (0,1) (1,1)`) + the texture to map. The facade
329/// resolves [`ImageSprite::facing`] into corners and culls back-facing
330/// quads once, so both backends draw from the same geometry.
331#[derive(Clone, Copy, Debug)]
332pub(crate) struct QuadDraw {
333    pub corners: [[f32; 3]; 4],
334    pub image: ImageId,
335    pub tint: u32,
336    pub depth_test: bool,
337    pub alpha_cutoff: f32,
338}
339
340/// Result of [`SceneRenderer::pick_image`] — a resolved screen→sprite hit.
341/// `uv` is the normalised position within the quad (`(0,0)` = top-left
342/// corner); `texel` is the matching source-image pixel; `world` is the
343/// hit point; `t` is its euclidean distance from the camera.
344#[derive(Clone, Copy, PartialEq, Debug)]
345pub struct ImagePickHit {
346    pub image: ImageId,
347    pub uv: [f32; 2],
348    pub texel: (u32, u32),
349    pub world: [f32; 3],
350    pub t: f32,
351}
352
353/// Which renderer a [`SceneRenderer`] resolved to at construction.
354#[derive(Clone, Copy, PartialEq, Eq, Debug)]
355pub enum Backend {
356    /// `roxlap-core` opticast, presented via `softbuffer`.
357    Cpu,
358    /// `roxlap-gpu` compute marcher, presented via wgpu.
359    Gpu,
360}
361
362/// Construction-time options for [`SceneRenderer::new`].
363pub struct RenderOptions {
364    /// Try the GPU backend first. When `false`, or when GPU init
365    /// fails, the renderer uses the CPU backend.
366    pub want_gpu: bool,
367    /// Settings forwarded to [`roxlap_gpu::GpuRenderer`] when the GPU
368    /// backend is selected.
369    pub gpu: GpuRendererSettings,
370    /// Packed `0x00RRGGBB` (alpha ignored) the empty/clear frame fills
371    /// with until a scene render lands. Also the CPU sky-miss colour
372    /// default if a frame supplies none.
373    pub clear_sky: u32,
374    /// CPU [`ScratchPool`](roxlap_core::rasterizer::ScratchPool) `lastx`
375    /// sizing — the largest combined grid `vsid` the CPU rasterizer
376    /// will see. Pre-sizing keeps later frames allocation-free.
377    pub cpu_max_grid_vsid: u32,
378    /// CPU strip-parallel render thread count (capped to the rayon
379    /// pool). One [`ScratchPool`](roxlap_core::rasterizer::ScratchPool)
380    /// slot per thread.
381    pub cpu_render_threads: usize,
382}
383
384impl Default for RenderOptions {
385    fn default() -> Self {
386        Self {
387            want_gpu: false,
388            gpu: GpuRendererSettings::default(),
389            clear_sky: 0x0099_b3d9,
390            // 32 chunks × CHUNK_SIZE_XY — the scene-demo's widest
391            // combined ground grid.
392            cpu_max_grid_vsid: 32 * roxlap_scene::CHUNK_SIZE_XY,
393            cpu_render_threads: 4,
394        }
395    }
396}
397
398/// Depth-test slack (same spirit as the backends' `DEPTH_BIAS`) so a
399/// [`SceneRenderer::pick_image`] hit on a sprite resting on a coincident
400/// voxel face isn't rejected as "occluded".
401const PICK_DEPTH_BIAS: f32 = 0.5;
402
403// --- image-sprite geometry helpers (shared by both backends) ---
404
405fn v_sub(a: [f32; 3], b: [f32; 3]) -> [f32; 3] {
406    [a[0] - b[0], a[1] - b[1], a[2] - b[2]]
407}
408fn v_add(a: [f32; 3], b: [f32; 3]) -> [f32; 3] {
409    [a[0] + b[0], a[1] + b[1], a[2] + b[2]]
410}
411fn v_scale(a: [f32; 3], s: f32) -> [f32; 3] {
412    [a[0] * s, a[1] * s, a[2] * s]
413}
414fn v_dot(a: [f32; 3], b: [f32; 3]) -> f32 {
415    a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
416}
417fn v_cross(a: [f32; 3], b: [f32; 3]) -> [f32; 3] {
418    [
419        a[1] * b[2] - a[2] * b[1],
420        a[2] * b[0] - a[0] * b[2],
421        a[0] * b[1] - a[1] * b[0],
422    ]
423}
424fn v_norm(a: [f32; 3]) -> [f32; 3] {
425    let len = v_dot(a, a).sqrt();
426    if len < 1e-12 {
427        a
428    } else {
429        v_scale(a, 1.0 / len)
430    }
431}
432
433/// Intersect a ray (`origin` + `dir`, `dir` un-normalised) with a quad
434/// `[TL, TR, BL, BR]` and return `(uv, t)` for a front/back hit inside
435/// the quad — `uv` in `0..=1` (`(0,0)` = `TL`), `t` the ray parameter
436/// (`hit = origin + dir·t`). `None` for a parallel ray, a hit behind the
437/// origin, a degenerate quad, or a hit outside the `u`/`v` span. Solves
438/// affine coords exactly for a (possibly skew) parallelogram. Standalone
439/// so the geometry is unit-testable without a renderer.
440fn ray_quad_uv(
441    origin: [f32; 3],
442    dir: [f32; 3],
443    corners: &[[f32; 3]; 4],
444) -> Option<([f32; 2], f32)> {
445    let [tl, tr, bl, _br] = *corners;
446    let ue = v_sub(tr, tl); // +u edge (width)
447    let ve = v_sub(bl, tl); // +v edge (height)
448    let n = v_cross(ue, ve);
449    let denom = v_dot(dir, n);
450    if denom.abs() < 1e-12 {
451        return None; // ray parallel to the quad's plane
452    }
453    let t = v_dot(v_sub(tl, origin), n) / denom;
454    if t <= 1e-6 {
455        return None; // behind / at the origin
456    }
457    let p = v_add(origin, v_scale(dir, t));
458    let rel = v_sub(p, tl);
459    let guu = v_dot(ue, ue);
460    let guv = v_dot(ue, ve);
461    let gvv = v_dot(ve, ve);
462    let det = guu * gvv - guv * guv;
463    if det.abs() < 1e-12 {
464        return None; // degenerate quad
465    }
466    let wu = v_dot(rel, ue);
467    let wv = v_dot(rel, ve);
468    let a = (gvv * wu - guv * wv) / det;
469    let b = (guu * wv - guv * wu) / det;
470    if !(0.0..=1.0).contains(&a) || !(0.0..=1.0).contains(&b) {
471        return None; // outside the quad
472    }
473    Some(([a, b], t))
474}
475
476/// Resolve an [`ImageSprite`] into its four world corners (`TL, TR, BL,
477/// BR`), or `None` when a `double_sided == false` world quad faces away
478/// from the camera (back-face cull) or its plane is degenerate. The
479/// camera basis is used only for [`ImageFacing::Billboard`] and the cull
480/// test.
481fn resolve_quad(sprite: &ImageSprite, camera: &Camera) -> Option<QuadDraw> {
482    let cam_pos = [
483        camera.pos[0] as f32,
484        camera.pos[1] as f32,
485        camera.pos[2] as f32,
486    ];
487    let cam_fwd = v_norm([
488        camera.forward[0] as f32,
489        camera.forward[1] as f32,
490        camera.forward[2] as f32,
491    ]);
492
493    let (u_hat, v_hat) = match sprite.facing {
494        ImageFacing::World { u, v } => (v_norm(u), v_norm(v)),
495        ImageFacing::Billboard { up } => {
496            // Horizontal axis ⟂ both the view direction and `up`; fall
497            // back to the camera right when `up` is parallel to the view.
498            let mut u_hat = v_norm(v_cross(up, cam_fwd));
499            if v_dot(u_hat, u_hat) < 1e-12 {
500                u_hat = v_norm([
501                    camera.right[0] as f32,
502                    camera.right[1] as f32,
503                    camera.right[2] as f32,
504                ]);
505            }
506            // Vertical axis ⟂ both, pointing *down* (rows grow downward)
507            // so the top edge ends up toward `up`.
508            let mut v_hat = v_norm(v_cross(cam_fwd, u_hat));
509            if v_dot(v_hat, up) > 0.0 {
510                v_hat = v_scale(v_hat, -1.0);
511            }
512            (u_hat, v_hat)
513        }
514    };
515
516    let du = v_scale(u_hat, sprite.size[0]);
517    let dv = v_scale(v_hat, sprite.size[1]);
518    let tl = sprite.origin;
519    let tr = v_add(tl, du);
520    let bl = v_add(tl, dv);
521    let br = v_add(tr, dv);
522
523    // Back-face cull for fixed world quads (billboards always face us).
524    if !sprite.double_sided {
525        if let ImageFacing::World { .. } = sprite.facing {
526            let normal = v_cross(du, dv);
527            // Front-facing when the quad normal points toward the camera.
528            if v_dot(normal, v_sub(cam_pos, tl)) <= 0.0 {
529                return None;
530            }
531        }
532    }
533
534    Some(QuadDraw {
535        corners: [tl, tr, bl, br],
536        image: sprite.image,
537        tint: sprite.tint,
538        depth_test: sprite.depth_test,
539        alpha_cutoff: sprite.alpha_cutoff,
540    })
541}
542
543/// Renderer-internal backend; never exposes wgpu or softbuffer types.
544/// The GPU variant owns the whole wgpu device/queue/pipelines, so
545/// it's boxed to keep the enum small.
546enum BackendImpl {
547    // Both variants boxed so the enum stays small regardless of which
548    // backend's state is larger (clippy::large_enum_variant).
549    Cpu(Box<CpuBackend>),
550    Gpu(Box<GpuBackend>),
551}
552
553/// Unified renderer over the CPU and GPU paths. See the crate docs.
554pub struct SceneRenderer {
555    inner: BackendImpl,
556    /// Handles for dynamically added sprite instances (see
557    /// [`Self::add_sprite_instance`]). Reset by [`Self::set_sprites`].
558    dyn_map: DynInstanceMap,
559}
560
561impl SceneRenderer {
562    /// Build a renderer for `window` — any [`raw-window-handle`]
563    /// provider (winit, SDL, GLFW, …) in an `Arc`. `size` is the
564    /// window's initial physical framebuffer size in pixels; thereafter
565    /// the host reports changes via [`Self::resize`]. Passing the size
566    /// explicitly keeps the facade decoupled from any one windowing
567    /// library's size API.
568    ///
569    /// Selects the GPU backend when `opts.want_gpu` and WGPU
570    /// initialises; otherwise the CPU backend. **Never fails** — a
571    /// missing/incompatible GPU silently yields the CPU path (the
572    /// message is logged to stderr).
573    ///
574    /// [`raw-window-handle`]: raw_window_handle
575    #[cfg(not(target_arch = "wasm32"))]
576    #[must_use]
577    pub fn new<W>(window: Arc<W>, size: (u32, u32), opts: &RenderOptions) -> Self
578    where
579        W: HasWindowHandle + HasDisplayHandle + Send + Sync + 'static,
580    {
581        if opts.want_gpu {
582            match GpuBackend::new(window.clone(), size, opts) {
583                Ok(g) => {
584                    return Self {
585                        inner: BackendImpl::Gpu(Box::new(g)),
586                        dyn_map: DynInstanceMap::default(),
587                    };
588                }
589                Err(e) => {
590                    eprintln!(
591                        "roxlap-render: GPU init failed ({e}); falling back to the CPU renderer",
592                    );
593                }
594            }
595        }
596        Self {
597            inner: BackendImpl::Cpu(Box::new(CpuBackend::new(window, size, opts))),
598            dyn_map: DynInstanceMap::default(),
599        }
600    }
601
602    /// wasm/WebGPU build-time entry: build a renderer over an HTML
603    /// `canvas`. `size` is the canvas's initial framebuffer size in
604    /// pixels; the host reports later changes via [`Self::resize`].
605    ///
606    /// Async because the browser drives wgpu's adapter/device requests
607    /// through its event loop — `await` it inside a
608    /// `wasm_bindgen_futures::spawn_local` task. Selects the GPU
609    /// (WebGPU) backend when `opts.want_gpu` and WebGPU is available;
610    /// otherwise (no WebGPU, or init failed) it falls back to the CPU
611    /// opticast path presented through a WebGL2 blit on the same canvas.
612    /// **Never fails** — the message is logged to the browser console.
613    #[cfg(target_arch = "wasm32")]
614    pub async fn new_from_canvas_async(
615        canvas: web_sys::HtmlCanvasElement,
616        size: (u32, u32),
617        opts: &RenderOptions,
618    ) -> Self {
619        if opts.want_gpu {
620            // `SurfaceTarget::Canvas` moves the canvas into wgpu, so the
621            // GPU attempt gets a clone — the CPU fallback keeps the
622            // original if WebGPU init fails.
623            match GpuBackend::new_async(canvas.clone(), size, opts).await {
624                Ok(g) => {
625                    return Self {
626                        inner: BackendImpl::Gpu(Box::new(g)),
627                        dyn_map: DynInstanceMap::default(),
628                    };
629                }
630                Err(e) => {
631                    web_sys::console::warn_1(
632                        &format!("roxlap-render: WebGPU init failed ({e}); using the CPU renderer")
633                            .into(),
634                    );
635                }
636            }
637        }
638        Self {
639            inner: BackendImpl::Cpu(Box::new(CpuBackend::new_from_canvas(canvas, size, opts))),
640            dyn_map: DynInstanceMap::default(),
641        }
642    }
643
644    /// Which backend was selected.
645    #[must_use]
646    pub fn backend(&self) -> Backend {
647        match self.inner {
648            BackendImpl::Cpu(_) => Backend::Cpu,
649            BackendImpl::Gpu(_) => Backend::Gpu,
650        }
651    }
652
653    /// The GPU adapter description when on the GPU backend, else
654    /// `None`.
655    #[must_use]
656    pub fn adapter_info(&self) -> Option<&str> {
657        match &self.inner {
658            BackendImpl::Gpu(g) => Some(g.adapter_info()),
659            BackendImpl::Cpu(_) => None,
660        }
661    }
662
663    /// Upload an equirectangular sky panorama (RGBA8, `w×h`) for the
664    /// GPU marcher's sky sampling. No-op on the CPU backend, which
665    /// samples the [`Sky`] passed in each [`FrameParams`] instead.
666    pub fn set_sky_panorama(&mut self, rgba: &[u8], w: u32, h: u32) {
667        if let BackendImpl::Gpu(g) = &mut self.inner {
668            g.set_sky_panorama(rgba, w, h);
669        }
670    }
671
672    /// Follow a window resize. CPU resizes its framebuffer lazily, so
673    /// this only matters to the GPU swapchain — but it's safe to call
674    /// for both.
675    pub fn resize(&mut self, width: u32, height: u32) {
676        match &mut self.inner {
677            BackendImpl::Cpu(c) => c.resize(width, height),
678            BackendImpl::Gpu(g) => g.resize(width, height),
679        }
680    }
681
682    /// Composite `scene` from `camera` with `frame` params into the
683    /// backend's frame buffer — **without presenting**. The CPU backend
684    /// fills sky + runs the opticast compositor into an owned buffer;
685    /// the GPU backend uploads/refreshes the scene, runs the compute
686    /// marcher + sprite pass, and acquires (but does not present) the
687    /// swapchain frame.
688    ///
689    /// Finish the frame with exactly one of [`present`](Self::present)
690    /// (no overlay) or [`paint_egui`](Self::paint_egui) (UI overlay).
691    /// Calling `render` again without finishing drops the pending frame.
692    pub fn render(&mut self, scene: &mut Scene, camera: &Camera, frame: &FrameParams) {
693        match &mut self.inner {
694            BackendImpl::Cpu(c) => c.render(scene, camera, frame),
695            BackendImpl::Gpu(g) => g.render(scene, camera, frame),
696        }
697    }
698
699    /// Draw world-space [`Line3`] segments over the frame
700    /// [`render`](Self::render) composited, using that frame's camera +
701    /// projection + depth buffer. Call **after** [`render`](Self::render)
702    /// and **before** [`present`](Self::present) /
703    /// [`paint_egui`](Self::paint_egui) — the lines land in the
704    /// framebuffer, so a subsequent `paint_egui` still draws its panels
705    /// on top.
706    ///
707    /// `camera` must be the one the last frame rendered with (the
708    /// projection is taken from that frame). Depth-tested segments
709    /// (`Line3::depth_test`) are occluded by nearer rendered geometry;
710    /// always-on-top segments ignore depth. See [`Line3`] for colour /
711    /// width / blend semantics.
712    pub fn draw_lines(&mut self, camera: &Camera, lines: &[Line3]) {
713        match &mut self.inner {
714            BackendImpl::Cpu(c) => c.draw_lines(camera, lines),
715            BackendImpl::Gpu(g) => g.draw_lines(camera, lines),
716        }
717    }
718
719    /// Upload (or replace) an RGBA8 image and return a stable [`ImageId`]
720    /// to reference it in [`draw_images`](Self::draw_images). `rgba` is
721    /// row-major, `width * height * 4` bytes, **straight** (un-premultiplied)
722    /// alpha. The texture is retained until [`drop_image`](Self::drop_image),
723    /// so the per-frame draw call stays cheap. Sampling is
724    /// nearest-neighbour (pixel-art friendly — no blurring).
725    ///
726    /// Returns `ImageId(0)` for malformed input (wrong byte count or a
727    /// zero dimension); such an id draws nothing.
728    pub fn upload_image(&mut self, rgba: &[u8], width: u32, height: u32) -> ImageId {
729        match &mut self.inner {
730            BackendImpl::Cpu(c) => c.upload_image(rgba, width, height),
731            BackendImpl::Gpu(g) => g.upload_image(rgba, width, height),
732        }
733    }
734
735    /// Release a texture uploaded with [`upload_image`](Self::upload_image).
736    /// The id must not be reused afterwards (a later `upload_image` may
737    /// hand the slot back out under a fresh id).
738    pub fn drop_image(&mut self, id: ImageId) {
739        match &mut self.inner {
740            BackendImpl::Cpu(c) => c.drop_image(id),
741            BackendImpl::Gpu(g) => g.drop_image(id),
742        }
743    }
744
745    /// Draw 2D [`ImageSprite`]s over the frame [`render`](Self::render)
746    /// composited — flat textured quads placed in world space, using that
747    /// frame's camera + projection + depth buffer. Same contract as
748    /// [`draw_lines`](Self::draw_lines): call **after** [`render`](Self::render)
749    /// and **before** [`present`](Self::present) / [`paint_egui`](Self::paint_egui).
750    ///
751    /// UVs are perspective-correct (no affine warp on an obliquely-viewed
752    /// quad). Depth-tested sprites are occluded by nearer rendered
753    /// geometry (with a bias to avoid z-fighting on a coincident face);
754    /// the texture's straight alpha + the [`ImageSprite::tint`] composite
755    /// over the scene. `camera` must be the one the last frame rendered.
756    pub fn draw_images(&mut self, camera: &Camera, images: &[ImageSprite]) {
757        if images.is_empty() {
758            return;
759        }
760        let quads: Vec<QuadDraw> = images
761            .iter()
762            .filter_map(|s| resolve_quad(s, camera))
763            .collect();
764        if quads.is_empty() {
765            return;
766        }
767        match &mut self.inner {
768            BackendImpl::Cpu(c) => c.draw_images(camera, &quads),
769            BackendImpl::Gpu(g) => g.draw_images(camera, &quads),
770        }
771    }
772
773    /// Project a world point to window pixel coordinates `(x, y)` under
774    /// the projection the **last frame** rendered with — the backend-correct
775    /// `world → screen` inverse of [`view_ray`](Self::view_ray). `None`
776    /// before the first frame or for a point at/behind the camera near
777    /// plane.
778    ///
779    /// Both backends honour their own projection (CPU `setcamera`
780    /// `hx/hy/hz`, GPU vertical-FOV pinhole), so hosts never reconstruct
781    /// it themselves. The returned `(x, y)` may fall outside `[0, w) ×
782    /// [0, h)` for points off-screen but in front of the camera.
783    #[must_use]
784    pub fn project_point(&self, camera: &Camera, world: [f32; 3]) -> Option<(f32, f32)> {
785        match &self.inner {
786            BackendImpl::Cpu(c) => c.project_point(camera, world),
787            BackendImpl::Gpu(g) => g.project_point(camera, world),
788        }
789    }
790
791    /// Screen→sprite pick: the nearest [`ImageSprite`] hit under window
792    /// pixel `(x, y)`, resolving which texel was clicked. `sprites` is the
793    /// same list passed to [`draw_images`](Self::draw_images) (image
794    /// sprites are immediate-mode, so the caller owns the set). `None` for
795    /// a miss.
796    ///
797    /// The ray is intersected with each quad's plane and mapped to its
798    /// `uv` / source texel. A texel whose alpha is below the sprite's
799    /// [`ImageSprite::alpha_cutoff`] (and any fully-transparent texel) is
800    /// **see-through** — the pick passes through it to a sprite behind.
801    /// For [`depth_test`](ImageSprite::depth_test) sprites the hit is
802    /// rejected when nearer scene geometry occludes that pixel (shares the
803    /// depth convention + bias of [`pick`](Self::pick); on the GPU backend
804    /// the occlusion test costs a click-time depth readback).
805    #[must_use]
806    pub fn pick_image(
807        &self,
808        camera: &Camera,
809        x: f64,
810        y: f64,
811        sprites: &[ImageSprite],
812    ) -> Option<ImagePickHit> {
813        if sprites.is_empty() {
814            return None;
815        }
816        let dir = self.pixel_ray(camera, x, y)?;
817        let dir = [dir[0] as f32, dir[1] as f32, dir[2] as f32];
818        let dir_len = v_dot(dir, dir).sqrt();
819        if dir_len < 1e-9 {
820            return None;
821        }
822        let origin = [
823            camera.pos[0] as f32,
824            camera.pos[1] as f32,
825            camera.pos[2] as f32,
826        ];
827        // Scene surface distance under this pixel (sky / no-hit → None);
828        // used to occlude depth-tested sprites. Same metric as `pick`.
829        let scene_t = self.pick_depth(x as u32, y as u32);
830
831        let mut best: Option<ImagePickHit> = None;
832        for sprite in sprites {
833            // Reuse the render-path resolve (back-face cull included), so
834            // a single-sided quad that isn't drawn also can't be picked.
835            let Some(q) = resolve_quad(sprite, camera) else {
836                continue;
837            };
838            let Some(([a, b], t)) = ray_quad_uv(origin, dir, &q.corners) else {
839                continue; // miss / parallel / behind
840            };
841            let d_eucl = t * dir_len;
842            if best.is_some_and(|cur| d_eucl >= cur.t) {
843                continue; // a nearer sprite already won
844            }
845            let p = v_add(origin, v_scale(dir, t));
846
847            let Some((iw, ih)) = self.image_dims(sprite.image) else {
848                continue; // dropped / unknown image
849            };
850            let tx = ((a * iw as f32) as i32).clamp(0, iw as i32 - 1) as u32;
851            let ty = ((b * ih as f32) as i32).clamp(0, ih as i32 - 1) as u32;
852
853            // See-through test: a texel is solid when its alpha clears the
854            // cutoff (and a fully-transparent texel is never solid).
855            let cutoff_u8 = (sprite.alpha_cutoff.clamp(0.0, 1.0) * 255.0) as u32;
856            let solid_thresh = cutoff_u8.max(1);
857            if u32::from(self.image_alpha_at(sprite.image, tx, ty)) < solid_thresh {
858                continue;
859            }
860
861            // Occlusion: a depth-tested sprite behind nearer geometry loses.
862            if sprite.depth_test {
863                if let Some(st) = scene_t {
864                    if d_eucl > st + PICK_DEPTH_BIAS {
865                        continue;
866                    }
867                }
868            }
869
870            best = Some(ImagePickHit {
871                image: sprite.image,
872                uv: [a, b],
873                texel: (tx, ty),
874                world: p,
875                t: d_eucl,
876            });
877        }
878        best
879    }
880
881    /// Source dimensions of an uploaded image, or `None` if the id was
882    /// dropped / never uploaded. Internal helper for [`Self::pick_image`].
883    fn image_dims(&self, id: ImageId) -> Option<(u32, u32)> {
884        match &self.inner {
885            BackendImpl::Cpu(c) => c.image_dims(id),
886            BackendImpl::Gpu(g) => g.image_dims(id),
887        }
888    }
889
890    /// Alpha byte of texel `(tx, ty)` in an uploaded image (`0` for an
891    /// unknown id / out-of-range texel). Internal helper for
892    /// [`Self::pick_image`].
893    fn image_alpha_at(&self, id: ImageId, tx: u32, ty: u32) -> u8 {
894        match &self.inner {
895            BackendImpl::Cpu(c) => c.image_alpha_at(id, tx, ty),
896            BackendImpl::Gpu(g) => g.image_alpha_at(id, tx, ty),
897        }
898    }
899
900    /// Mirror the rendered 3D scene horizontally before display. The flip is
901    /// applied *before* any egui overlay, so the UI stays upright while the
902    /// viewport un-mirrors — a fix for the engine's left-handed render.
903    /// Supported on both backends (CPU reverses the framebuffer rows; GPU
904    /// mirrors the scene blit + line/image overlays). Picking/projection are
905    /// unchanged, so a host that flips must mirror its cursor X (`width - x`)
906    /// for ray casts.
907    pub fn set_flip_x(&mut self, flip: bool) {
908        match &mut self.inner {
909            BackendImpl::Cpu(c) => c.set_flip_x(flip),
910            BackendImpl::Gpu(g) => g.set_flip_x(flip),
911        }
912    }
913
914    /// Present the frame [`render`](Self::render) composited, with no UI
915    /// overlay. Pairs with `render`; use [`paint_egui`](Self::paint_egui)
916    /// instead to overlay an egui UI before presenting.
917    pub fn present(&mut self) {
918        match &mut self.inner {
919            BackendImpl::Cpu(c) => c.present(),
920            BackendImpl::Gpu(g) => g.present(),
921        }
922    }
923
924    /// Overlay an egui UI on the frame [`render`](Self::render)
925    /// composited, then present it (`hud` feature). The host runs egui
926    /// itself (e.g. `egui` + `egui-winit`) and passes the tessellated
927    /// `jobs` ([`egui::Context::tessellate`]) and the per-frame
928    /// `textures` delta from [`egui::FullOutput`]; `pixels_per_point` is
929    /// the UI scale (`ctx.pixels_per_point()`).
930    ///
931    /// The GPU backend paints via `egui-wgpu`; the CPU backend
932    /// software-rasterises the tessellation into its framebuffer. Use
933    /// this **instead of** [`present`](Self::present) — both finish the
934    /// frame.
935    #[cfg(feature = "hud")]
936    pub fn paint_egui(
937        &mut self,
938        jobs: &[egui::ClippedPrimitive],
939        textures: &egui::TexturesDelta,
940        pixels_per_point: f32,
941    ) {
942        match &mut self.inner {
943            BackendImpl::Cpu(c) => c.paint_egui(jobs, textures, pixels_per_point),
944            BackendImpl::Gpu(g) => g.paint_egui(jobs, textures, pixels_per_point),
945        }
946    }
947
948    /// Register sprite models + instances. The CPU backend builds a
949    /// per-instance draw list; the GPU backend builds an instanced
950    /// model registry. Call once at setup (or again to replace).
951    pub fn set_sprites(&mut self, set: &SpriteSet) -> Vec<SpriteModelId> {
952        match &mut self.inner {
953            BackendImpl::Cpu(c) => c.set_sprites(set),
954            BackendImpl::Gpu(g) => g.set_sprites(set),
955        }
956        // A fresh sprite set replaces the instance world, so any
957        // previously added dynamic instances are gone — drop their handles.
958        self.dyn_map = DynInstanceMap::default();
959        // Handles are positional by construction (model index = chain id
960        // on both backends), so the facade hands them out directly —
961        // callers keep the handle instead of re-deriving the index.
962        (0..set.models.len()).map(SpriteModelId).collect()
963    }
964
965    /// Re-register one sprite model's geometry after you've edited its
966    /// content (a carve or recolour of its `kv6`). `model` is the
967    /// [`SpriteModelId`] handed back by [`set_sprites`](Self::set_sprites);
968    /// `kv6` is the model's **new** geometry — the caller owns the source
969    /// of truth (e.g. a dense carve grid the surface-only `kv6` can't
970    /// represent) and supplies the refreshed mesh here.
971    ///
972    /// This is a **backend-agnostic content refresh**, not a GPU upload:
973    /// the renderer brings its stored model up to date however its active
974    /// backend needs to. The instance set is left untouched (an edit never
975    /// moves or adds an instance), so on the GPU backend only that one
976    /// model's voxel data is re-uploaded — through a slack-backed
977    /// suballocator, one model's bytes rather than the whole registry —
978    /// while the CPU backend swaps the cached `kv6` into each instance of
979    /// the model. Use [`set_sprites`](Self::set_sprites) to add/remove
980    /// models or change the instance set.
981    pub fn refresh_sprite_model(&mut self, model: SpriteModelId, kv6: &Kv6) {
982        match &mut self.inner {
983            BackendImpl::Cpu(c) => c.update_sprite_model(model.0, kv6),
984            BackendImpl::Gpu(g) => g.update_sprite_model(model.0, kv6),
985        }
986    }
987
988    /// Add one sprite instance of an already-registered `model` at world
989    /// `pos`, **incrementally** — the cheap streaming-spawn path that both
990    /// backends now share (GPU: append to the instance buffer, growing by
991    /// powers of two; CPU: push one pre-posed [`Sprite`]). Returns a
992    /// stable [`SpriteInstanceId`] for later removal.
993    ///
994    /// `model` must be a [`SpriteModelId`] from the current
995    /// [`set_sprites`](Self::set_sprites) (a model registered there, even
996    /// with zero initial instances). Dynamic instances live *after* the
997    /// static set + any KFA limbs, so register those first.
998    pub fn add_sprite_instance(&mut self, model: SpriteModelId, pos: [f32; 3]) -> SpriteInstanceId {
999        let dyn_index = match &mut self.inner {
1000            BackendImpl::Cpu(c) => c.add_dyn_instance(model.0, pos),
1001            BackendImpl::Gpu(g) => g.add_dyn_instance(model.0, pos),
1002        };
1003        self.dyn_map.alloc(dyn_index as u32)
1004    }
1005
1006    /// Remove a dynamic sprite instance added by
1007    /// [`add_sprite_instance`](Self::add_sprite_instance). O(1) on both
1008    /// backends (swap-remove); other dynamic handles stay valid. Returns
1009    /// `false` if the handle is stale / already removed.
1010    pub fn remove_sprite_instance(&mut self, id: SpriteInstanceId) -> bool {
1011        let Some(dyn_index) = self.dyn_map.dyn_index(id) else {
1012            return false;
1013        };
1014        let moved = match &mut self.inner {
1015            BackendImpl::Cpu(c) => c.remove_dyn_instance(dyn_index as usize),
1016            BackendImpl::Gpu(g) => g.remove_dyn_instance(dyn_index as usize),
1017        };
1018        self.dyn_map.remove(id, dyn_index, moved.map(|m| m as u32));
1019        true
1020    }
1021
1022    /// Number of live dynamic sprite instances (those added via
1023    /// [`add_sprite_instance`](Self::add_sprite_instance)).
1024    #[must_use]
1025    pub fn dynamic_sprite_count(&self) -> usize {
1026        self.dyn_map.order.len()
1027    }
1028
1029    /// Register animated KFA sprites (one or more bone hierarchies).
1030    /// The GPU backend uploads each limb's kv6 as an instanced model
1031    /// **once** (appended to the sprite registry) and seeds the limb
1032    /// instances at their current pose; the CPU backend caches the
1033    /// posed limbs for drawing. Call once at setup, after
1034    /// [`set_sprites`](Self::set_sprites), then drive motion per frame
1035    /// with [`update_kfa_poses`](Self::update_kfa_poses).
1036    ///
1037    /// Limbs are posed from the sprites' current
1038    /// [`kfaval`](roxlap_formats::kfa::KfaSprite::kfaval) (advance
1039    /// [`animsprite`](roxlap_formats::kfa::KfaSprite::animsprite) first
1040    /// if using a baked curve), so `kfas` is taken `&mut`.
1041    pub fn set_kfa_sprites(&mut self, kfas: &mut [KfaSprite]) {
1042        match &mut self.inner {
1043            BackendImpl::Cpu(c) => c.set_kfa_sprites(kfas),
1044            BackendImpl::Gpu(g) => g.set_kfa_sprites(kfas),
1045        }
1046    }
1047
1048    /// Re-pose the registered KFA sprites from their current
1049    /// `kfaval[]`. Call each frame after advancing the animation
1050    /// (`kfa.animsprite(dt_ms)` or poking `kfaval[]`). The GPU backend
1051    /// takes the cheap transform-only update (no model-volume
1052    /// re-upload); the CPU backend re-solves limb transforms for the
1053    /// next [`render`](Self::render). Must follow a
1054    /// [`set_kfa_sprites`](Self::set_kfa_sprites) with the same sprites.
1055    pub fn update_kfa_poses(&mut self, kfas: &mut [KfaSprite]) {
1056        match &mut self.inner {
1057            BackendImpl::Cpu(c) => c.update_kfa_poses(kfas),
1058            BackendImpl::Gpu(g) => g.update_kfa_poses(kfas),
1059        }
1060    }
1061
1062    /// Carve the next z-layer off the [`SpriteSet::carve_model`] and
1063    /// re-upload (the demo's `G` hotkey + GPU.12 copy-on-modify). GPU
1064    /// only; a no-op on the CPU backend. Returns the voxels removed.
1065    pub fn carve_active_sprite(&mut self) -> u32 {
1066        match &mut self.inner {
1067            BackendImpl::Cpu(_) => 0,
1068            BackendImpl::Gpu(g) => g.carve_active_sprite(),
1069        }
1070    }
1071
1072    /// Request that the next [`render`](Self::render) capture its
1073    /// framebuffer for [`take_capture`](Self::take_capture). CPU only
1074    /// (the GPU swapchain isn't read back) — a no-op on GPU.
1075    pub fn request_capture(&mut self) {
1076        if let BackendImpl::Cpu(c) = &mut self.inner {
1077            c.request_capture();
1078        }
1079    }
1080
1081    /// Take the most recently captured frame as packed `0x00RRGGBB`
1082    /// pixels + dimensions, or `None` if no capture is ready / GPU.
1083    pub fn take_capture(&mut self) -> Option<(Vec<u32>, u32, u32)> {
1084        match &mut self.inner {
1085            BackendImpl::Cpu(c) => c.take_capture(),
1086            BackendImpl::Gpu(_) => None,
1087        }
1088    }
1089
1090    /// Screen→world picking input: the world-space hit distance `t` at
1091    /// window pixel `(x, y)` from the **last rendered frame**, or `None`
1092    /// for out-of-bounds pixels and sky / no-hit. The host reconstructs
1093    /// the world hit point as `cam.pos + t * normalize(ray_dir)`, where
1094    /// `ray_dir` is the same per-pixel ray the frame was rendered with
1095    /// (see the backend's projection).
1096    ///
1097    /// `t` is the distance to the nearest **scene-grid** surface
1098    /// (terrain + grids); sprites do not occlude it (the sprite pass
1099    /// reads depth read-only), so a cursor sprite under the pointer is
1100    /// transparent to the pick.
1101    ///
1102    /// Cost: the CPU backend reads its in-memory z-buffer (free); the
1103    /// GPU backend stages the depth buffer and blocks on a device poll
1104    /// (cheap at click time — do not call every frame). The GPU path
1105    /// only has depth when the last frame drew sprites (`write_depth`).
1106    #[must_use]
1107    pub fn pick_depth(&self, x: u32, y: u32) -> Option<f32> {
1108        match &self.inner {
1109            BackendImpl::Cpu(c) => c.pick_depth(x, y),
1110            BackendImpl::Gpu(g) => g.pick_depth(x, y),
1111        }
1112    }
1113
1114    /// World-space view-ray direction (un-normalised) for window pixel
1115    /// `(x, y)`, under the projection the **last frame** rendered with.
1116    /// The backends differ (CPU `setcamera` vs GPU vertical-FOV
1117    /// pinhole), so this hides which one is active. `None` before the
1118    /// first frame. Intersect it with a plane for tile picking, or feed
1119    /// it to [`Self::pick`] for a voxel.
1120    #[must_use]
1121    pub fn pixel_ray(&self, camera: &Camera, x: f64, y: f64) -> Option<[f64; 3]> {
1122        match &self.inner {
1123            BackendImpl::Cpu(c) => c.pixel_ray(camera, x, y),
1124            BackendImpl::Gpu(g) => g.pixel_ray(camera, x, y),
1125        }
1126    }
1127
1128    /// Canonical screen→world unproject: the full view [`Ray`]
1129    /// (`camera.pos` origin + unit direction) for window pixel
1130    /// `(x, y)`, under whichever projection the last frame used. The
1131    /// one entry point both backends honour — hosts never reconstruct
1132    /// the projection. `None` before the first frame or for a
1133    /// degenerate ray.
1134    ///
1135    /// Compose with [`roxlap_scene::Scene::raycast`] for depth-free
1136    /// picking that's identical on CPU and GPU:
1137    /// `renderer.view_ray(cam, x, y).and_then(|r| scene.raycast(r.origin, r.dir, max))`.
1138    #[must_use]
1139    pub fn view_ray(&self, camera: &Camera, x: f64, y: f64) -> Option<Ray> {
1140        let d = self.pixel_ray(camera, x, y)?;
1141        let len = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt();
1142        if len < 1e-12 {
1143            return None;
1144        }
1145        Some(Ray {
1146            origin: glam::DVec3::from_array([camera.pos[0], camera.pos[1], camera.pos[2]]),
1147            dir: glam::DVec3::new(d[0] / len, d[1] / len, d[2] / len),
1148        })
1149    }
1150
1151    /// One-call screen→world voxel pick: unproject pixel `(x, y)` with
1152    /// the active backend's projection, read the last frame's depth
1153    /// there, reconstruct the world hit, and resolve it to the owning
1154    /// grid + grid-local voxel via [`Scene::resolve_voxel`]. `None` on
1155    /// sky / no-hit, or when no grid claims the surface.
1156    ///
1157    /// `scene` and `camera` must be the ones the last frame rendered;
1158    /// the projection (size + FOV / `hx,hy,hz`) is taken from that
1159    /// frame. Cheap on CPU (in-memory z-buffer); on GPU it stages the
1160    /// depth buffer (a click-time device poll — not per frame).
1161    #[must_use]
1162    pub fn pick(&self, scene: &Scene, camera: &Camera, x: u32, y: u32) -> Option<PickHit> {
1163        let dir = self.pixel_ray(camera, f64::from(x), f64::from(y))?;
1164        let t = f64::from(self.pick_depth(x, y)?);
1165        let len = (dir[0] * dir[0] + dir[1] * dir[1] + dir[2] * dir[2]).sqrt();
1166        if len < 1e-9 {
1167            return None;
1168        }
1169        let s = t / len; // world = cam.pos + t · (dir / |dir|)
1170        let world = glam::DVec3::new(
1171            camera.pos[0] + dir[0] * s,
1172            camera.pos[1] + dir[1] * s,
1173            camera.pos[2] + dir[2] * s,
1174        );
1175        let (grid, voxel) = scene.resolve_voxel(world, glam::DVec3::from_array(dir))?;
1176        #[allow(clippy::cast_possible_truncation)]
1177        let world_f32 = [world.x as f32, world.y as f32, world.z as f32];
1178        Some(PickHit {
1179            world: world_f32,
1180            grid,
1181            voxel,
1182        })
1183    }
1184}
1185
1186#[cfg(test)]
1187mod tests {
1188    use super::*;
1189
1190    /// The handle map must survive the backends' swap-remove indexing:
1191    /// drive a model `DynInstanceMap` against a `Vec` "backend" that
1192    /// swap-removes, and check every live handle keeps resolving to its
1193    /// own payload through a sequence of adds + removes.
1194    #[test]
1195    fn dyn_instance_map_survives_swap_removes() {
1196        let mut map = DynInstanceMap::default();
1197        // The "backend": payload per dynamic index; swap_remove mirrors
1198        // both backends' remove_dyn_instance.
1199        let mut backend: Vec<u32> = Vec::new();
1200        // Our bookkeeping: handle -> the payload we expect it to address.
1201        let mut expect: Vec<(SpriteInstanceId, u32)> = Vec::new();
1202
1203        let add = |map: &mut DynInstanceMap,
1204                   backend: &mut Vec<u32>,
1205                   expect: &mut Vec<(SpriteInstanceId, u32)>,
1206                   payload: u32| {
1207            let dyn_index = backend.len() as u32;
1208            backend.push(payload);
1209            let id = map.alloc(dyn_index);
1210            expect.push((id, payload));
1211        };
1212
1213        for p in 0..6 {
1214            add(&mut map, &mut backend, &mut expect, p);
1215        }
1216
1217        // Remove a middle handle (payload 2) and a later one (payload 4),
1218        // plus the current last — covering swap and no-swap paths.
1219        for victim_payload in [2u32, 4, 5] {
1220            let pos = expect
1221                .iter()
1222                .position(|&(_, p)| p == victim_payload)
1223                .unwrap();
1224            let (id, _) = expect.remove(pos);
1225            let dyn_index = map.dyn_index(id).expect("live handle resolves");
1226            // Backend swap-remove + report moved index (old last), exactly
1227            // like remove_dyn_instance on both backends.
1228            let last = backend.len() - 1;
1229            backend.swap_remove(dyn_index as usize);
1230            let moved = (dyn_index as usize != last).then_some(last as u32);
1231            map.remove(id, dyn_index, moved);
1232            // The removed handle is now stale.
1233            assert!(map.dyn_index(id).is_none(), "removed handle is stale");
1234        }
1235
1236        // Every surviving handle still resolves to its own payload.
1237        for &(id, payload) in &expect {
1238            let idx = map.dyn_index(id).expect("survivor resolves");
1239            assert_eq!(
1240                backend[idx as usize], payload,
1241                "handle addresses its payload"
1242            );
1243        }
1244        assert_eq!(map.order.len(), backend.len());
1245        assert_eq!(backend.len(), expect.len());
1246    }
1247
1248    #[test]
1249    fn options_default_is_cpu_intent() {
1250        let o = RenderOptions::default();
1251        assert!(!o.want_gpu);
1252        assert_eq!(o.clear_sky & 0xFF00_0000, 0, "clear_sky is 0x00RRGGBB");
1253    }
1254
1255    /// A camera at the origin looking down +Y (voxlap z-down world): right
1256    /// = +X, down = +Z, forward = +Y. Handedness `right × down == forward`.
1257    fn cam_looking_y() -> Camera {
1258        Camera {
1259            pos: [0.0, 0.0, 0.0],
1260            right: [1.0, 0.0, 0.0],
1261            down: [0.0, 0.0, 1.0],
1262            forward: [0.0, 1.0, 0.0],
1263        }
1264    }
1265
1266    #[test]
1267    fn world_quad_corner_layout() {
1268        // Top-left at (-5, 10, -5); u = +X (width), v = +Z (down). A
1269        // 10×10 quad facing the camera (its +Y normal points back at us).
1270        let sprite = ImageSprite {
1271            image: ImageId(0),
1272            origin: [-5.0, 10.0, -5.0],
1273            facing: ImageFacing::World {
1274                u: [1.0, 0.0, 0.0],
1275                v: [0.0, 0.0, 1.0],
1276            },
1277            size: [10.0, 10.0],
1278            tint: 0xFFFF_FFFF,
1279            alpha_cutoff: 0.0,
1280            depth_test: true,
1281            double_sided: true,
1282        };
1283        let q = resolve_quad(&sprite, &cam_looking_y()).expect("front-facing");
1284        assert_eq!(q.corners[0], [-5.0, 10.0, -5.0], "TL = origin");
1285        assert_eq!(q.corners[1], [5.0, 10.0, -5.0], "TR = origin + u·size");
1286        assert_eq!(q.corners[2], [-5.0, 10.0, 5.0], "BL = origin + v·size");
1287        assert_eq!(q.corners[3], [5.0, 10.0, 5.0], "BR = origin + u + v");
1288    }
1289
1290    #[test]
1291    fn world_quad_backface_culls_when_single_sided() {
1292        // Same plane but spanned so its normal (u × v) points *away* from
1293        // the camera: swap u/v so the winding flips.
1294        let sprite = ImageSprite {
1295            image: ImageId(0),
1296            origin: [-5.0, 10.0, -5.0],
1297            facing: ImageFacing::World {
1298                u: [0.0, 0.0, 1.0], // v-ish
1299                v: [1.0, 0.0, 0.0], // u-ish → normal flips to -Y... toward camera?
1300            },
1301            size: [10.0, 10.0],
1302            tint: 0xFFFF_FFFF,
1303            alpha_cutoff: 0.0,
1304            depth_test: true,
1305            double_sided: false,
1306        };
1307        // With double_sided=false one of the two windings must cull; the
1308        // opposite winding must draw. Exactly one of the two resolves.
1309        let a = resolve_quad(&sprite, &cam_looking_y()).is_some();
1310        let mut flipped = sprite;
1311        flipped.facing = ImageFacing::World {
1312            u: [1.0, 0.0, 0.0],
1313            v: [0.0, 0.0, 1.0],
1314        };
1315        let b = resolve_quad(&flipped, &cam_looking_y()).is_some();
1316        assert!(a ^ b, "exactly one winding is front-facing");
1317    }
1318
1319    #[test]
1320    fn double_sided_never_culls() {
1321        let mut sprite = ImageSprite {
1322            image: ImageId(0),
1323            origin: [-5.0, 10.0, -5.0],
1324            facing: ImageFacing::World {
1325                u: [0.0, 0.0, 1.0],
1326                v: [1.0, 0.0, 0.0],
1327            },
1328            size: [10.0, 10.0],
1329            tint: 0xFFFF_FFFF,
1330            alpha_cutoff: 0.0,
1331            depth_test: true,
1332            double_sided: true,
1333        };
1334        assert!(resolve_quad(&sprite, &cam_looking_y()).is_some());
1335        sprite.facing = ImageFacing::World {
1336            u: [1.0, 0.0, 0.0],
1337            v: [0.0, 0.0, 1.0],
1338        };
1339        assert!(resolve_quad(&sprite, &cam_looking_y()).is_some());
1340    }
1341
1342    #[test]
1343    fn ray_quad_uv_center_and_corners() {
1344        // 10×10 quad on the y=10 plane: TL(-5,10,-5) u=+X v=+Z. Camera at
1345        // origin looking +Y. A ray straight at the quad centre → uv (.5,.5).
1346        let corners = [
1347            [-5.0, 10.0, -5.0], // TL
1348            [5.0, 10.0, -5.0],  // TR
1349            [-5.0, 10.0, 5.0],  // BL
1350            [5.0, 10.0, 5.0],   // BR
1351        ];
1352        let (uv, t) = ray_quad_uv([0.0, 0.0, 0.0], [0.0, 1.0, 0.0], &corners).expect("center hit");
1353        assert!(
1354            (uv[0] - 0.5).abs() < 1e-5 && (uv[1] - 0.5).abs() < 1e-5,
1355            "centre → (.5,.5)"
1356        );
1357        assert!((t - 10.0).abs() < 1e-4, "t = plane distance");
1358        // Ray toward the TL corner texel region (−x, +y, −z) → uv near (0,0).
1359        let (uv_tl, _) = ray_quad_uv([0.0, 0.0, 0.0], [-4.0, 10.0, -4.0], &corners).unwrap();
1360        assert!(uv_tl[0] < 0.2 && uv_tl[1] < 0.2, "toward TL → small uv");
1361    }
1362
1363    #[test]
1364    fn ray_quad_uv_misses_outside_and_behind() {
1365        let corners = [
1366            [-5.0, 10.0, -5.0],
1367            [5.0, 10.0, -5.0],
1368            [-5.0, 10.0, 5.0],
1369            [5.0, 10.0, 5.0],
1370        ];
1371        // Ray pointing away (−Y) never reaches the +Y plane in front.
1372        assert!(ray_quad_uv([0.0, 0.0, 0.0], [0.0, -1.0, 0.0], &corners).is_none());
1373        // Ray parallel to the quad plane (in +X) → no intersection.
1374        assert!(ray_quad_uv([0.0, 0.0, 0.0], [1.0, 0.0, 0.0], &corners).is_none());
1375        // Ray hitting the plane far outside the quad → outside uv.
1376        assert!(ray_quad_uv([100.0, 0.0, 0.0], [0.0, 1.0, 0.0], &corners).is_none());
1377    }
1378
1379    #[test]
1380    fn billboard_axes_orthogonal_and_top_toward_up() {
1381        // World up = -Z (z-down world). The billboard's v (top→bottom)
1382        // must point away from `up`, and u/v must be ⟂ the view direction.
1383        let up = [0.0, 0.0, -1.0];
1384        let sprite = ImageSprite {
1385            image: ImageId(0),
1386            origin: [0.0, 50.0, 0.0],
1387            facing: ImageFacing::Billboard { up },
1388            size: [4.0, 4.0],
1389            tint: 0xFFFF_FFFF,
1390            alpha_cutoff: 0.0,
1391            depth_test: false,
1392            double_sided: false, // billboards must NEVER cull
1393        };
1394        let q = resolve_quad(&sprite, &cam_looking_y()).expect("billboard always faces camera");
1395        let u = v_sub(q.corners[1], q.corners[0]); // TR - TL = u·size
1396        let v = v_sub(q.corners[2], q.corners[0]); // BL - TL = v·size
1397        let fwd = [0.0, 1.0, 0.0];
1398        assert!(v_dot(u, fwd).abs() < 1e-5, "u ⟂ view");
1399        assert!(v_dot(v, fwd).abs() < 1e-5, "v ⟂ view");
1400        assert!(v_dot(u, v).abs() < 1e-5, "u ⟂ v");
1401        assert!(
1402            v_dot(v, up) < 0.0,
1403            "rows grow away from `up` (top edge toward up)"
1404        );
1405    }
1406}