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}