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/// Backend-agnostic sprite description. The facade builds the CPU
92/// per-instance draw list and the GPU instanced registry from the
93/// same data, so both backends show identical sprites. The host owns
94/// content (which models, where, recolouring) — building a recoloured
95/// variant is just a second [`Sprite`] model with edited `kv6.voxels`.
96pub struct SpriteSet {
97 /// Distinct voxel models (KV6 + base orientation). Instances index
98 /// into this; their position overrides the model's.
99 pub models: Vec<Sprite>,
100 pub instances: Vec<SpriteInstanceDesc>,
101 /// Model the [`SceneRenderer::carve_active_sprite`] hotkey edits
102 /// (GPU only, mirroring the demo's `G`-carve). `None` disables it.
103 pub carve_model: Option<usize>,
104}
105
106/// Per-frame inputs both backends consume. The host builds the
107/// [`OpticastSettings`] (it owns scan distance etc.); the facade does
108/// everything else (pool config, sky fill, render, present).
109pub struct FrameParams<'a> {
110 /// CPU opticast settings (scan distance, mip ladder, framebuffer
111 /// geometry). Ignored by the GPU backend.
112 pub settings: &'a OpticastSettings,
113 /// Packed engine sky colour: the CPU sky-miss fill + skycast, and
114 /// the clear colour if no scene renders.
115 pub sky_color: u32,
116 /// Optional sky panorama for the CPU rasterizer's sky sampling.
117 pub sky: Option<&'a Sky>,
118 /// CPU fog: packed colour + max scan distance (voxels). `0` scan
119 /// distance disables CPU fog.
120 pub fog_color: u32,
121 pub fog_max_scan_dist: i32,
122 /// CPU: treat z=255 as air (avoids the S1.X bedrock path for
123 /// out-of-bounds cameras).
124 pub treat_z_max_as_air: bool,
125 /// GPU scene-grid LOD scan distance (world units); see GPU.11.1.
126 /// Ignored by the CPU backend.
127 pub gpu_mip_scan_dist: f32,
128 /// GPU outer-DDA step budget (chunks). Ignored by the CPU backend.
129 pub gpu_max_outer_steps: u32,
130 /// GPU vertical field of view (radians). Ignored by the CPU
131 /// backend (it derives projection from [`OpticastSettings`]).
132 pub gpu_fov_y_rad: f32,
133 /// CPU sprite shading (built by the host from its engine). Required
134 /// for the CPU backend to draw sprites; ignored by the GPU backend
135 /// (its sprite pass shades from the uploaded model colours). `None`
136 /// skips CPU sprite drawing.
137 pub sprite_lighting: Option<&'a SpriteLighting<'a>>,
138 /// Per-face directional shading for the voxel grids — voxlap's
139 /// `setsideshades(top, bot, left, right, up, down)`, the grid-scan
140 /// analogue of [`sprite_lighting`](Self::sprite_lighting). Each
141 /// entry darkens the faces pointing that way; the host typically
142 /// passes its engine's `side_shades()`. The default `[0; 6]` keeps
143 /// `sideshademode` off (no per-side shading), so existing hosts and
144 /// the oracle goldens are unaffected. Applied each frame by **both**
145 /// backends: the CPU rasteriser via `gcsub`, and the GPU scene-DDA
146 /// pass by darkening a hit voxel's brightness by the hit face's
147 /// shade (the face taken from the DDA's last-stepped axis).
148 pub side_shades: [i8; 6],
149}
150
151/// Result of [`SceneRenderer::pick`] — a resolved screen→world voxel
152/// hit. `world` is the surface point (`cam.pos + t · normalize(ray)`);
153/// `grid` + `voxel` are the owning grid and its **grid-local** voxel
154/// (transform-correct for rotated / translated grids).
155#[derive(Clone, Copy, PartialEq, Debug)]
156pub struct PickHit {
157 pub world: [f32; 3],
158 pub grid: roxlap_scene::GridId,
159 pub voxel: glam::IVec3,
160}
161
162/// A world-space view ray: the canonical unproject output of
163/// [`SceneRenderer::view_ray`]. `dir` is unit-length. Feed it straight
164/// to [`roxlap_scene::Scene::raycast`] for depth-free, backend-agnostic
165/// voxel picking (`scene.raycast(ray.origin, ray.dir, max_dist)`), or
166/// intersect it with a plane for tile selection.
167#[derive(Clone, Copy, PartialEq, Debug)]
168pub struct Ray {
169 pub origin: glam::DVec3,
170 pub dir: glam::DVec3,
171}
172
173/// A world-space line segment to draw over a rendered frame via
174/// [`SceneRenderer::draw_lines`] — editor gizmos (bounding boxes, floor
175/// grids, axes, hover wireframes), debug paths, etc.
176#[derive(Clone, Copy, PartialEq, Debug)]
177pub struct Line3 {
178 /// World-space endpoints (voxel units), in the same frame the
179 /// rendered scene + `camera` use.
180 pub a: [f64; 3],
181 pub b: [f64; 3],
182 /// `0xAARRGGBB` — the high byte is an alpha blend factor (`0xFF`
183 /// opaque, `0x00` invisible), the low 24 bits the RGB colour.
184 pub color: u32,
185 /// Screen-space thickness in pixels (`<= 1.0` draws a 1px line).
186 pub width_px: f32,
187 /// `true`: the segment is occluded by nearer rendered geometry
188 /// (depth-tested against the frame's z-buffer). `false`: always on
189 /// top (e.g. a hover highlight that should show through the model).
190 pub depth_test: bool,
191}
192
193/// A handle to an uploaded image-sprite texture, returned by
194/// [`SceneRenderer::upload_image`]. Positional (like [`SpriteModelId`]):
195/// it indexes the backend's texture store. Pass it in an [`ImageSprite`]
196/// for [`SceneRenderer::draw_images`], or to
197/// [`drop_image`](SceneRenderer::drop_image) to release it. Opaque on
198/// purpose — there's no arithmetic to do on it.
199#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
200pub struct ImageId(pub(crate) usize);
201
202/// How an [`ImageSprite`]'s quad is oriented in the world.
203#[derive(Clone, Copy, PartialEq, Debug)]
204pub enum ImageFacing {
205 /// Fixed in world space: the quad lies in the plane spanned by `u`
206 /// (the image's +column / width direction) and `v` (its +row /
207 /// height direction). Both are world-space directions; their length
208 /// is ignored (the quad is sized by [`ImageSprite::size`]), so pass
209 /// the plane's axes directly. Row 0 of the image is the `origin`
210 /// edge and rows grow along `v`.
211 World { u: [f32; 3], v: [f32; 3] },
212 /// Always faces the camera (billboard); `up` is the world direction
213 /// the image's top edge points toward (e.g. world `-Z` for the
214 /// scene-demo's z-down world, or any "up" the host prefers).
215 Billboard { up: [f32; 3] },
216}
217
218/// One placed 2D image sprite for the current frame: a flat textured
219/// quad in world space, composited over the rendered scene with the
220/// frame's depth buffer (so the voxel model can occlude it). Built per
221/// frame and passed to [`SceneRenderer::draw_images`], mirroring
222/// [`Line3`] / [`SceneRenderer::draw_lines`]. The texture is uploaded
223/// once via [`SceneRenderer::upload_image`] and referenced by [`image`].
224///
225/// [`image`]: ImageSprite::image
226#[derive(Clone, Copy, PartialEq, Debug)]
227pub struct ImageSprite {
228 /// The uploaded texture to draw (from [`SceneRenderer::upload_image`]).
229 pub image: ImageId,
230 /// World position of the quad's **top-left** corner — the image's
231 /// `(column 0, row 0)` texel. The quad extends `size[0]` along the
232 /// facing's `u` and `size[1]` along its `v`.
233 pub origin: [f32; 3],
234 /// World orientation of the quad — fixed in world or camera-facing.
235 pub facing: ImageFacing,
236 /// World size of the quad along `u` and `v`. For pixel-art traced at
237 /// 1 texel = 1 voxel, pass `[width as f32, height as f32]`.
238 pub size: [f32; 2],
239 /// Multiplied into every sampled texel (tint + opacity), `0xAARRGGBB`.
240 /// `0xFFFFFFFF` draws the texture unchanged; the high byte scales
241 /// the texel alpha (e.g. `0x80FFFFFF` = 50 % opacity).
242 pub tint: u32,
243 /// `true`: occluded by nearer rendered geometry (depth-tested against
244 /// the frame's depth buffer, with a bias so a quad resting on a
245 /// coincident voxel face doesn't z-fight). `false`: always on top.
246 pub depth_test: bool,
247 /// `true`: draw regardless of which way the quad faces (no backface
248 /// cull) — what reference images usually want. `false`: cull when the
249 /// quad faces away from the camera. Ignored for
250 /// [`ImageFacing::Billboard`] (it always faces the camera).
251 pub double_sided: bool,
252}
253
254/// Backend-agnostic resolved quad: four world corners (`TL, TR, BL, BR`,
255/// with UVs `(0,0) (1,0) (0,1) (1,1)`) + the texture to map. The facade
256/// resolves [`ImageSprite::facing`] into corners and culls back-facing
257/// quads once, so both backends draw from the same geometry.
258#[derive(Clone, Copy, Debug)]
259pub(crate) struct QuadDraw {
260 pub corners: [[f32; 3]; 4],
261 pub image: ImageId,
262 pub tint: u32,
263 pub depth_test: bool,
264}
265
266/// Which renderer a [`SceneRenderer`] resolved to at construction.
267#[derive(Clone, Copy, PartialEq, Eq, Debug)]
268pub enum Backend {
269 /// `roxlap-core` opticast, presented via `softbuffer`.
270 Cpu,
271 /// `roxlap-gpu` compute marcher, presented via wgpu.
272 Gpu,
273}
274
275/// Construction-time options for [`SceneRenderer::new`].
276pub struct RenderOptions {
277 /// Try the GPU backend first. When `false`, or when GPU init
278 /// fails, the renderer uses the CPU backend.
279 pub want_gpu: bool,
280 /// Settings forwarded to [`roxlap_gpu::GpuRenderer`] when the GPU
281 /// backend is selected.
282 pub gpu: GpuRendererSettings,
283 /// Packed `0x00RRGGBB` (alpha ignored) the empty/clear frame fills
284 /// with until a scene render lands. Also the CPU sky-miss colour
285 /// default if a frame supplies none.
286 pub clear_sky: u32,
287 /// CPU [`ScratchPool`](roxlap_core::rasterizer::ScratchPool) `lastx`
288 /// sizing — the largest combined grid `vsid` the CPU rasterizer
289 /// will see. Pre-sizing keeps later frames allocation-free.
290 pub cpu_max_grid_vsid: u32,
291 /// CPU strip-parallel render thread count (capped to the rayon
292 /// pool). One [`ScratchPool`](roxlap_core::rasterizer::ScratchPool)
293 /// slot per thread.
294 pub cpu_render_threads: usize,
295}
296
297impl Default for RenderOptions {
298 fn default() -> Self {
299 Self {
300 want_gpu: false,
301 gpu: GpuRendererSettings::default(),
302 clear_sky: 0x0099_b3d9,
303 // 32 chunks × CHUNK_SIZE_XY — the scene-demo's widest
304 // combined ground grid.
305 cpu_max_grid_vsid: 32 * roxlap_scene::CHUNK_SIZE_XY,
306 cpu_render_threads: 4,
307 }
308 }
309}
310
311// --- image-sprite geometry helpers (shared by both backends) ---
312
313fn v_sub(a: [f32; 3], b: [f32; 3]) -> [f32; 3] {
314 [a[0] - b[0], a[1] - b[1], a[2] - b[2]]
315}
316fn v_add(a: [f32; 3], b: [f32; 3]) -> [f32; 3] {
317 [a[0] + b[0], a[1] + b[1], a[2] + b[2]]
318}
319fn v_scale(a: [f32; 3], s: f32) -> [f32; 3] {
320 [a[0] * s, a[1] * s, a[2] * s]
321}
322fn v_dot(a: [f32; 3], b: [f32; 3]) -> f32 {
323 a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
324}
325fn v_cross(a: [f32; 3], b: [f32; 3]) -> [f32; 3] {
326 [
327 a[1] * b[2] - a[2] * b[1],
328 a[2] * b[0] - a[0] * b[2],
329 a[0] * b[1] - a[1] * b[0],
330 ]
331}
332fn v_norm(a: [f32; 3]) -> [f32; 3] {
333 let len = v_dot(a, a).sqrt();
334 if len < 1e-12 {
335 a
336 } else {
337 v_scale(a, 1.0 / len)
338 }
339}
340
341/// Resolve an [`ImageSprite`] into its four world corners (`TL, TR, BL,
342/// BR`), or `None` when a `double_sided == false` world quad faces away
343/// from the camera (back-face cull) or its plane is degenerate. The
344/// camera basis is used only for [`ImageFacing::Billboard`] and the cull
345/// test.
346fn resolve_quad(sprite: &ImageSprite, camera: &Camera) -> Option<QuadDraw> {
347 let cam_pos = [
348 camera.pos[0] as f32,
349 camera.pos[1] as f32,
350 camera.pos[2] as f32,
351 ];
352 let cam_fwd = v_norm([
353 camera.forward[0] as f32,
354 camera.forward[1] as f32,
355 camera.forward[2] as f32,
356 ]);
357
358 let (u_hat, v_hat) = match sprite.facing {
359 ImageFacing::World { u, v } => (v_norm(u), v_norm(v)),
360 ImageFacing::Billboard { up } => {
361 // Horizontal axis ⟂ both the view direction and `up`; fall
362 // back to the camera right when `up` is parallel to the view.
363 let mut u_hat = v_norm(v_cross(up, cam_fwd));
364 if v_dot(u_hat, u_hat) < 1e-12 {
365 u_hat = v_norm([
366 camera.right[0] as f32,
367 camera.right[1] as f32,
368 camera.right[2] as f32,
369 ]);
370 }
371 // Vertical axis ⟂ both, pointing *down* (rows grow downward)
372 // so the top edge ends up toward `up`.
373 let mut v_hat = v_norm(v_cross(cam_fwd, u_hat));
374 if v_dot(v_hat, up) > 0.0 {
375 v_hat = v_scale(v_hat, -1.0);
376 }
377 (u_hat, v_hat)
378 }
379 };
380
381 let du = v_scale(u_hat, sprite.size[0]);
382 let dv = v_scale(v_hat, sprite.size[1]);
383 let tl = sprite.origin;
384 let tr = v_add(tl, du);
385 let bl = v_add(tl, dv);
386 let br = v_add(tr, dv);
387
388 // Back-face cull for fixed world quads (billboards always face us).
389 if !sprite.double_sided {
390 if let ImageFacing::World { .. } = sprite.facing {
391 let normal = v_cross(du, dv);
392 // Front-facing when the quad normal points toward the camera.
393 if v_dot(normal, v_sub(cam_pos, tl)) <= 0.0 {
394 return None;
395 }
396 }
397 }
398
399 Some(QuadDraw {
400 corners: [tl, tr, bl, br],
401 image: sprite.image,
402 tint: sprite.tint,
403 depth_test: sprite.depth_test,
404 })
405}
406
407/// Renderer-internal backend; never exposes wgpu or softbuffer types.
408/// The GPU variant owns the whole wgpu device/queue/pipelines, so
409/// it's boxed to keep the enum small.
410enum BackendImpl {
411 // Both variants boxed so the enum stays small regardless of which
412 // backend's state is larger (clippy::large_enum_variant).
413 Cpu(Box<CpuBackend>),
414 Gpu(Box<GpuBackend>),
415}
416
417/// Unified renderer over the CPU and GPU paths. See the crate docs.
418pub struct SceneRenderer {
419 inner: BackendImpl,
420}
421
422impl SceneRenderer {
423 /// Build a renderer for `window` — any [`raw-window-handle`]
424 /// provider (winit, SDL, GLFW, …) in an `Arc`. `size` is the
425 /// window's initial physical framebuffer size in pixels; thereafter
426 /// the host reports changes via [`Self::resize`]. Passing the size
427 /// explicitly keeps the facade decoupled from any one windowing
428 /// library's size API.
429 ///
430 /// Selects the GPU backend when `opts.want_gpu` and WGPU
431 /// initialises; otherwise the CPU backend. **Never fails** — a
432 /// missing/incompatible GPU silently yields the CPU path (the
433 /// message is logged to stderr).
434 ///
435 /// [`raw-window-handle`]: raw_window_handle
436 #[cfg(not(target_arch = "wasm32"))]
437 #[must_use]
438 pub fn new<W>(window: Arc<W>, size: (u32, u32), opts: &RenderOptions) -> Self
439 where
440 W: HasWindowHandle + HasDisplayHandle + Send + Sync + 'static,
441 {
442 if opts.want_gpu {
443 match GpuBackend::new(window.clone(), size, opts) {
444 Ok(g) => {
445 return Self {
446 inner: BackendImpl::Gpu(Box::new(g)),
447 };
448 }
449 Err(e) => {
450 eprintln!(
451 "roxlap-render: GPU init failed ({e}); falling back to the CPU renderer",
452 );
453 }
454 }
455 }
456 Self {
457 inner: BackendImpl::Cpu(Box::new(CpuBackend::new(window, size, opts))),
458 }
459 }
460
461 /// wasm/WebGPU build-time entry: build a renderer over an HTML
462 /// `canvas`. `size` is the canvas's initial framebuffer size in
463 /// pixels; the host reports later changes via [`Self::resize`].
464 ///
465 /// Async because the browser drives wgpu's adapter/device requests
466 /// through its event loop — `await` it inside a
467 /// `wasm_bindgen_futures::spawn_local` task. Selects the GPU
468 /// (WebGPU) backend when `opts.want_gpu` and WebGPU is available;
469 /// otherwise (no WebGPU, or init failed) it falls back to the CPU
470 /// opticast path presented through a WebGL2 blit on the same canvas.
471 /// **Never fails** — the message is logged to the browser console.
472 #[cfg(target_arch = "wasm32")]
473 pub async fn new_from_canvas_async(
474 canvas: web_sys::HtmlCanvasElement,
475 size: (u32, u32),
476 opts: &RenderOptions,
477 ) -> Self {
478 if opts.want_gpu {
479 // `SurfaceTarget::Canvas` moves the canvas into wgpu, so the
480 // GPU attempt gets a clone — the CPU fallback keeps the
481 // original if WebGPU init fails.
482 match GpuBackend::new_async(canvas.clone(), size, opts).await {
483 Ok(g) => {
484 return Self {
485 inner: BackendImpl::Gpu(Box::new(g)),
486 };
487 }
488 Err(e) => {
489 web_sys::console::warn_1(
490 &format!("roxlap-render: WebGPU init failed ({e}); using the CPU renderer")
491 .into(),
492 );
493 }
494 }
495 }
496 Self {
497 inner: BackendImpl::Cpu(Box::new(CpuBackend::new_from_canvas(canvas, size, opts))),
498 }
499 }
500
501 /// Which backend was selected.
502 #[must_use]
503 pub fn backend(&self) -> Backend {
504 match self.inner {
505 BackendImpl::Cpu(_) => Backend::Cpu,
506 BackendImpl::Gpu(_) => Backend::Gpu,
507 }
508 }
509
510 /// The GPU adapter description when on the GPU backend, else
511 /// `None`.
512 #[must_use]
513 pub fn adapter_info(&self) -> Option<&str> {
514 match &self.inner {
515 BackendImpl::Gpu(g) => Some(g.adapter_info()),
516 BackendImpl::Cpu(_) => None,
517 }
518 }
519
520 /// Upload an equirectangular sky panorama (RGBA8, `w×h`) for the
521 /// GPU marcher's sky sampling. No-op on the CPU backend, which
522 /// samples the [`Sky`] passed in each [`FrameParams`] instead.
523 pub fn set_sky_panorama(&mut self, rgba: &[u8], w: u32, h: u32) {
524 if let BackendImpl::Gpu(g) = &mut self.inner {
525 g.set_sky_panorama(rgba, w, h);
526 }
527 }
528
529 /// Follow a window resize. CPU resizes its framebuffer lazily, so
530 /// this only matters to the GPU swapchain — but it's safe to call
531 /// for both.
532 pub fn resize(&mut self, width: u32, height: u32) {
533 match &mut self.inner {
534 BackendImpl::Cpu(c) => c.resize(width, height),
535 BackendImpl::Gpu(g) => g.resize(width, height),
536 }
537 }
538
539 /// Composite `scene` from `camera` with `frame` params into the
540 /// backend's frame buffer — **without presenting**. The CPU backend
541 /// fills sky + runs the opticast compositor into an owned buffer;
542 /// the GPU backend uploads/refreshes the scene, runs the compute
543 /// marcher + sprite pass, and acquires (but does not present) the
544 /// swapchain frame.
545 ///
546 /// Finish the frame with exactly one of [`present`](Self::present)
547 /// (no overlay) or [`paint_egui`](Self::paint_egui) (UI overlay).
548 /// Calling `render` again without finishing drops the pending frame.
549 pub fn render(&mut self, scene: &mut Scene, camera: &Camera, frame: &FrameParams) {
550 match &mut self.inner {
551 BackendImpl::Cpu(c) => c.render(scene, camera, frame),
552 BackendImpl::Gpu(g) => g.render(scene, camera, frame),
553 }
554 }
555
556 /// Draw world-space [`Line3`] segments over the frame
557 /// [`render`](Self::render) composited, using that frame's camera +
558 /// projection + depth buffer. Call **after** [`render`](Self::render)
559 /// and **before** [`present`](Self::present) /
560 /// [`paint_egui`](Self::paint_egui) — the lines land in the
561 /// framebuffer, so a subsequent `paint_egui` still draws its panels
562 /// on top.
563 ///
564 /// `camera` must be the one the last frame rendered with (the
565 /// projection is taken from that frame). Depth-tested segments
566 /// (`Line3::depth_test`) are occluded by nearer rendered geometry;
567 /// always-on-top segments ignore depth. See [`Line3`] for colour /
568 /// width / blend semantics.
569 pub fn draw_lines(&mut self, camera: &Camera, lines: &[Line3]) {
570 match &mut self.inner {
571 BackendImpl::Cpu(c) => c.draw_lines(camera, lines),
572 BackendImpl::Gpu(g) => g.draw_lines(camera, lines),
573 }
574 }
575
576 /// Upload (or replace) an RGBA8 image and return a stable [`ImageId`]
577 /// to reference it in [`draw_images`](Self::draw_images). `rgba` is
578 /// row-major, `width * height * 4` bytes, **straight** (un-premultiplied)
579 /// alpha. The texture is retained until [`drop_image`](Self::drop_image),
580 /// so the per-frame draw call stays cheap. Sampling is
581 /// nearest-neighbour (pixel-art friendly — no blurring).
582 ///
583 /// Returns `ImageId(0)` for malformed input (wrong byte count or a
584 /// zero dimension); such an id draws nothing.
585 pub fn upload_image(&mut self, rgba: &[u8], width: u32, height: u32) -> ImageId {
586 match &mut self.inner {
587 BackendImpl::Cpu(c) => c.upload_image(rgba, width, height),
588 BackendImpl::Gpu(g) => g.upload_image(rgba, width, height),
589 }
590 }
591
592 /// Release a texture uploaded with [`upload_image`](Self::upload_image).
593 /// The id must not be reused afterwards (a later `upload_image` may
594 /// hand the slot back out under a fresh id).
595 pub fn drop_image(&mut self, id: ImageId) {
596 match &mut self.inner {
597 BackendImpl::Cpu(c) => c.drop_image(id),
598 BackendImpl::Gpu(g) => g.drop_image(id),
599 }
600 }
601
602 /// Draw 2D [`ImageSprite`]s over the frame [`render`](Self::render)
603 /// composited — flat textured quads placed in world space, using that
604 /// frame's camera + projection + depth buffer. Same contract as
605 /// [`draw_lines`](Self::draw_lines): call **after** [`render`](Self::render)
606 /// and **before** [`present`](Self::present) / [`paint_egui`](Self::paint_egui).
607 ///
608 /// UVs are perspective-correct (no affine warp on an obliquely-viewed
609 /// quad). Depth-tested sprites are occluded by nearer rendered
610 /// geometry (with a bias to avoid z-fighting on a coincident face);
611 /// the texture's straight alpha + the [`ImageSprite::tint`] composite
612 /// over the scene. `camera` must be the one the last frame rendered.
613 pub fn draw_images(&mut self, camera: &Camera, images: &[ImageSprite]) {
614 if images.is_empty() {
615 return;
616 }
617 let quads: Vec<QuadDraw> = images
618 .iter()
619 .filter_map(|s| resolve_quad(s, camera))
620 .collect();
621 if quads.is_empty() {
622 return;
623 }
624 match &mut self.inner {
625 BackendImpl::Cpu(c) => c.draw_images(camera, &quads),
626 BackendImpl::Gpu(g) => g.draw_images(camera, &quads),
627 }
628 }
629
630 /// Project a world point to window pixel coordinates `(x, y)` under
631 /// the projection the **last frame** rendered with — the backend-correct
632 /// `world → screen` inverse of [`view_ray`](Self::view_ray). `None`
633 /// before the first frame or for a point at/behind the camera near
634 /// plane.
635 ///
636 /// Both backends honour their own projection (CPU `setcamera`
637 /// `hx/hy/hz`, GPU vertical-FOV pinhole), so hosts never reconstruct
638 /// it themselves. The returned `(x, y)` may fall outside `[0, w) ×
639 /// [0, h)` for points off-screen but in front of the camera.
640 #[must_use]
641 pub fn project_point(&self, camera: &Camera, world: [f32; 3]) -> Option<(f32, f32)> {
642 match &self.inner {
643 BackendImpl::Cpu(c) => c.project_point(camera, world),
644 BackendImpl::Gpu(g) => g.project_point(camera, world),
645 }
646 }
647
648 /// Present the frame [`render`](Self::render) composited, with no UI
649 /// overlay. Pairs with `render`; use [`paint_egui`](Self::paint_egui)
650 /// instead to overlay an egui UI before presenting.
651 pub fn present(&mut self) {
652 match &mut self.inner {
653 BackendImpl::Cpu(c) => c.present(),
654 BackendImpl::Gpu(g) => g.present(),
655 }
656 }
657
658 /// Overlay an egui UI on the frame [`render`](Self::render)
659 /// composited, then present it (`hud` feature). The host runs egui
660 /// itself (e.g. `egui` + `egui-winit`) and passes the tessellated
661 /// `jobs` ([`egui::Context::tessellate`]) and the per-frame
662 /// `textures` delta from [`egui::FullOutput`]; `pixels_per_point` is
663 /// the UI scale (`ctx.pixels_per_point()`).
664 ///
665 /// The GPU backend paints via `egui-wgpu`; the CPU backend
666 /// software-rasterises the tessellation into its framebuffer. Use
667 /// this **instead of** [`present`](Self::present) — both finish the
668 /// frame.
669 #[cfg(feature = "hud")]
670 pub fn paint_egui(
671 &mut self,
672 jobs: &[egui::ClippedPrimitive],
673 textures: &egui::TexturesDelta,
674 pixels_per_point: f32,
675 ) {
676 match &mut self.inner {
677 BackendImpl::Cpu(c) => c.paint_egui(jobs, textures, pixels_per_point),
678 BackendImpl::Gpu(g) => g.paint_egui(jobs, textures, pixels_per_point),
679 }
680 }
681
682 /// Register sprite models + instances. The CPU backend builds a
683 /// per-instance draw list; the GPU backend builds an instanced
684 /// model registry. Call once at setup (or again to replace).
685 pub fn set_sprites(&mut self, set: &SpriteSet) -> Vec<SpriteModelId> {
686 match &mut self.inner {
687 BackendImpl::Cpu(c) => c.set_sprites(set),
688 BackendImpl::Gpu(g) => g.set_sprites(set),
689 }
690 // Handles are positional by construction (model index = chain id
691 // on both backends), so the facade hands them out directly —
692 // callers keep the handle instead of re-deriving the index.
693 (0..set.models.len()).map(SpriteModelId).collect()
694 }
695
696 /// Re-register one sprite model's geometry after you've edited its
697 /// content (a carve or recolour of its `kv6`). `model` is the
698 /// [`SpriteModelId`] handed back by [`set_sprites`](Self::set_sprites);
699 /// `kv6` is the model's **new** geometry — the caller owns the source
700 /// of truth (e.g. a dense carve grid the surface-only `kv6` can't
701 /// represent) and supplies the refreshed mesh here.
702 ///
703 /// This is a **backend-agnostic content refresh**, not a GPU upload:
704 /// the renderer brings its stored model up to date however its active
705 /// backend needs to. The instance set is left untouched (an edit never
706 /// moves or adds an instance), so on the GPU backend only that one
707 /// model's voxel data is re-uploaded — through a slack-backed
708 /// suballocator, one model's bytes rather than the whole registry —
709 /// while the CPU backend swaps the cached `kv6` into each instance of
710 /// the model. Use [`set_sprites`](Self::set_sprites) to add/remove
711 /// models or change the instance set.
712 pub fn refresh_sprite_model(&mut self, model: SpriteModelId, kv6: &Kv6) {
713 match &mut self.inner {
714 BackendImpl::Cpu(c) => c.update_sprite_model(model.0, kv6),
715 BackendImpl::Gpu(g) => g.update_sprite_model(model.0, kv6),
716 }
717 }
718
719 /// Register animated KFA sprites (one or more bone hierarchies).
720 /// The GPU backend uploads each limb's kv6 as an instanced model
721 /// **once** (appended to the sprite registry) and seeds the limb
722 /// instances at their current pose; the CPU backend caches the
723 /// posed limbs for drawing. Call once at setup, after
724 /// [`set_sprites`](Self::set_sprites), then drive motion per frame
725 /// with [`update_kfa_poses`](Self::update_kfa_poses).
726 ///
727 /// Limbs are posed from the sprites' current
728 /// [`kfaval`](roxlap_formats::kfa::KfaSprite::kfaval) (advance
729 /// [`animsprite`](roxlap_formats::kfa::KfaSprite::animsprite) first
730 /// if using a baked curve), so `kfas` is taken `&mut`.
731 pub fn set_kfa_sprites(&mut self, kfas: &mut [KfaSprite]) {
732 match &mut self.inner {
733 BackendImpl::Cpu(c) => c.set_kfa_sprites(kfas),
734 BackendImpl::Gpu(g) => g.set_kfa_sprites(kfas),
735 }
736 }
737
738 /// Re-pose the registered KFA sprites from their current
739 /// `kfaval[]`. Call each frame after advancing the animation
740 /// (`kfa.animsprite(dt_ms)` or poking `kfaval[]`). The GPU backend
741 /// takes the cheap transform-only update (no model-volume
742 /// re-upload); the CPU backend re-solves limb transforms for the
743 /// next [`render`](Self::render). Must follow a
744 /// [`set_kfa_sprites`](Self::set_kfa_sprites) with the same sprites.
745 pub fn update_kfa_poses(&mut self, kfas: &mut [KfaSprite]) {
746 match &mut self.inner {
747 BackendImpl::Cpu(c) => c.update_kfa_poses(kfas),
748 BackendImpl::Gpu(g) => g.update_kfa_poses(kfas),
749 }
750 }
751
752 /// Carve the next z-layer off the [`SpriteSet::carve_model`] and
753 /// re-upload (the demo's `G` hotkey + GPU.12 copy-on-modify). GPU
754 /// only; a no-op on the CPU backend. Returns the voxels removed.
755 pub fn carve_active_sprite(&mut self) -> u32 {
756 match &mut self.inner {
757 BackendImpl::Cpu(_) => 0,
758 BackendImpl::Gpu(g) => g.carve_active_sprite(),
759 }
760 }
761
762 /// Request that the next [`render`](Self::render) capture its
763 /// framebuffer for [`take_capture`](Self::take_capture). CPU only
764 /// (the GPU swapchain isn't read back) — a no-op on GPU.
765 pub fn request_capture(&mut self) {
766 if let BackendImpl::Cpu(c) = &mut self.inner {
767 c.request_capture();
768 }
769 }
770
771 /// Take the most recently captured frame as packed `0x00RRGGBB`
772 /// pixels + dimensions, or `None` if no capture is ready / GPU.
773 pub fn take_capture(&mut self) -> Option<(Vec<u32>, u32, u32)> {
774 match &mut self.inner {
775 BackendImpl::Cpu(c) => c.take_capture(),
776 BackendImpl::Gpu(_) => None,
777 }
778 }
779
780 /// Screen→world picking input: the world-space hit distance `t` at
781 /// window pixel `(x, y)` from the **last rendered frame**, or `None`
782 /// for out-of-bounds pixels and sky / no-hit. The host reconstructs
783 /// the world hit point as `cam.pos + t * normalize(ray_dir)`, where
784 /// `ray_dir` is the same per-pixel ray the frame was rendered with
785 /// (see the backend's projection).
786 ///
787 /// `t` is the distance to the nearest **scene-grid** surface
788 /// (terrain + grids); sprites do not occlude it (the sprite pass
789 /// reads depth read-only), so a cursor sprite under the pointer is
790 /// transparent to the pick.
791 ///
792 /// Cost: the CPU backend reads its in-memory z-buffer (free); the
793 /// GPU backend stages the depth buffer and blocks on a device poll
794 /// (cheap at click time — do not call every frame). The GPU path
795 /// only has depth when the last frame drew sprites (`write_depth`).
796 #[must_use]
797 pub fn pick_depth(&self, x: u32, y: u32) -> Option<f32> {
798 match &self.inner {
799 BackendImpl::Cpu(c) => c.pick_depth(x, y),
800 BackendImpl::Gpu(g) => g.pick_depth(x, y),
801 }
802 }
803
804 /// World-space view-ray direction (un-normalised) for window pixel
805 /// `(x, y)`, under the projection the **last frame** rendered with.
806 /// The backends differ (CPU `setcamera` vs GPU vertical-FOV
807 /// pinhole), so this hides which one is active. `None` before the
808 /// first frame. Intersect it with a plane for tile picking, or feed
809 /// it to [`Self::pick`] for a voxel.
810 #[must_use]
811 pub fn pixel_ray(&self, camera: &Camera, x: f64, y: f64) -> Option<[f64; 3]> {
812 match &self.inner {
813 BackendImpl::Cpu(c) => c.pixel_ray(camera, x, y),
814 BackendImpl::Gpu(g) => g.pixel_ray(camera, x, y),
815 }
816 }
817
818 /// Canonical screen→world unproject: the full view [`Ray`]
819 /// (`camera.pos` origin + unit direction) for window pixel
820 /// `(x, y)`, under whichever projection the last frame used. The
821 /// one entry point both backends honour — hosts never reconstruct
822 /// the projection. `None` before the first frame or for a
823 /// degenerate ray.
824 ///
825 /// Compose with [`roxlap_scene::Scene::raycast`] for depth-free
826 /// picking that's identical on CPU and GPU:
827 /// `renderer.view_ray(cam, x, y).and_then(|r| scene.raycast(r.origin, r.dir, max))`.
828 #[must_use]
829 pub fn view_ray(&self, camera: &Camera, x: f64, y: f64) -> Option<Ray> {
830 let d = self.pixel_ray(camera, x, y)?;
831 let len = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt();
832 if len < 1e-12 {
833 return None;
834 }
835 Some(Ray {
836 origin: glam::DVec3::from_array([camera.pos[0], camera.pos[1], camera.pos[2]]),
837 dir: glam::DVec3::new(d[0] / len, d[1] / len, d[2] / len),
838 })
839 }
840
841 /// One-call screen→world voxel pick: unproject pixel `(x, y)` with
842 /// the active backend's projection, read the last frame's depth
843 /// there, reconstruct the world hit, and resolve it to the owning
844 /// grid + grid-local voxel via [`Scene::resolve_voxel`]. `None` on
845 /// sky / no-hit, or when no grid claims the surface.
846 ///
847 /// `scene` and `camera` must be the ones the last frame rendered;
848 /// the projection (size + FOV / `hx,hy,hz`) is taken from that
849 /// frame. Cheap on CPU (in-memory z-buffer); on GPU it stages the
850 /// depth buffer (a click-time device poll — not per frame).
851 #[must_use]
852 pub fn pick(&self, scene: &Scene, camera: &Camera, x: u32, y: u32) -> Option<PickHit> {
853 let dir = self.pixel_ray(camera, f64::from(x), f64::from(y))?;
854 let t = f64::from(self.pick_depth(x, y)?);
855 let len = (dir[0] * dir[0] + dir[1] * dir[1] + dir[2] * dir[2]).sqrt();
856 if len < 1e-9 {
857 return None;
858 }
859 let s = t / len; // world = cam.pos + t · (dir / |dir|)
860 let world = glam::DVec3::new(
861 camera.pos[0] + dir[0] * s,
862 camera.pos[1] + dir[1] * s,
863 camera.pos[2] + dir[2] * s,
864 );
865 let (grid, voxel) = scene.resolve_voxel(world, glam::DVec3::from_array(dir))?;
866 #[allow(clippy::cast_possible_truncation)]
867 let world_f32 = [world.x as f32, world.y as f32, world.z as f32];
868 Some(PickHit {
869 world: world_f32,
870 grid,
871 voxel,
872 })
873 }
874}
875
876#[cfg(test)]
877mod tests {
878 use super::*;
879
880 #[test]
881 fn options_default_is_cpu_intent() {
882 let o = RenderOptions::default();
883 assert!(!o.want_gpu);
884 assert_eq!(o.clear_sky & 0xFF00_0000, 0, "clear_sky is 0x00RRGGBB");
885 }
886
887 /// A camera at the origin looking down +Y (voxlap z-down world): right
888 /// = +X, down = +Z, forward = +Y. Handedness `right × down == forward`.
889 fn cam_looking_y() -> Camera {
890 Camera {
891 pos: [0.0, 0.0, 0.0],
892 right: [1.0, 0.0, 0.0],
893 down: [0.0, 0.0, 1.0],
894 forward: [0.0, 1.0, 0.0],
895 }
896 }
897
898 #[test]
899 fn world_quad_corner_layout() {
900 // Top-left at (-5, 10, -5); u = +X (width), v = +Z (down). A
901 // 10×10 quad facing the camera (its +Y normal points back at us).
902 let sprite = ImageSprite {
903 image: ImageId(0),
904 origin: [-5.0, 10.0, -5.0],
905 facing: ImageFacing::World {
906 u: [1.0, 0.0, 0.0],
907 v: [0.0, 0.0, 1.0],
908 },
909 size: [10.0, 10.0],
910 tint: 0xFFFF_FFFF,
911 depth_test: true,
912 double_sided: true,
913 };
914 let q = resolve_quad(&sprite, &cam_looking_y()).expect("front-facing");
915 assert_eq!(q.corners[0], [-5.0, 10.0, -5.0], "TL = origin");
916 assert_eq!(q.corners[1], [5.0, 10.0, -5.0], "TR = origin + u·size");
917 assert_eq!(q.corners[2], [-5.0, 10.0, 5.0], "BL = origin + v·size");
918 assert_eq!(q.corners[3], [5.0, 10.0, 5.0], "BR = origin + u + v");
919 }
920
921 #[test]
922 fn world_quad_backface_culls_when_single_sided() {
923 // Same plane but spanned so its normal (u × v) points *away* from
924 // the camera: swap u/v so the winding flips.
925 let sprite = ImageSprite {
926 image: ImageId(0),
927 origin: [-5.0, 10.0, -5.0],
928 facing: ImageFacing::World {
929 u: [0.0, 0.0, 1.0], // v-ish
930 v: [1.0, 0.0, 0.0], // u-ish → normal flips to -Y... toward camera?
931 },
932 size: [10.0, 10.0],
933 tint: 0xFFFF_FFFF,
934 depth_test: true,
935 double_sided: false,
936 };
937 // With double_sided=false one of the two windings must cull; the
938 // opposite winding must draw. Exactly one of the two resolves.
939 let a = resolve_quad(&sprite, &cam_looking_y()).is_some();
940 let mut flipped = sprite;
941 flipped.facing = ImageFacing::World {
942 u: [1.0, 0.0, 0.0],
943 v: [0.0, 0.0, 1.0],
944 };
945 let b = resolve_quad(&flipped, &cam_looking_y()).is_some();
946 assert!(a ^ b, "exactly one winding is front-facing");
947 }
948
949 #[test]
950 fn double_sided_never_culls() {
951 let mut sprite = ImageSprite {
952 image: ImageId(0),
953 origin: [-5.0, 10.0, -5.0],
954 facing: ImageFacing::World {
955 u: [0.0, 0.0, 1.0],
956 v: [1.0, 0.0, 0.0],
957 },
958 size: [10.0, 10.0],
959 tint: 0xFFFF_FFFF,
960 depth_test: true,
961 double_sided: true,
962 };
963 assert!(resolve_quad(&sprite, &cam_looking_y()).is_some());
964 sprite.facing = ImageFacing::World {
965 u: [1.0, 0.0, 0.0],
966 v: [0.0, 0.0, 1.0],
967 };
968 assert!(resolve_quad(&sprite, &cam_looking_y()).is_some());
969 }
970
971 #[test]
972 fn billboard_axes_orthogonal_and_top_toward_up() {
973 // World up = -Z (z-down world). The billboard's v (top→bottom)
974 // must point away from `up`, and u/v must be ⟂ the view direction.
975 let up = [0.0, 0.0, -1.0];
976 let sprite = ImageSprite {
977 image: ImageId(0),
978 origin: [0.0, 50.0, 0.0],
979 facing: ImageFacing::Billboard { up },
980 size: [4.0, 4.0],
981 tint: 0xFFFF_FFFF,
982 depth_test: false,
983 double_sided: false, // billboards must NEVER cull
984 };
985 let q = resolve_quad(&sprite, &cam_looking_y()).expect("billboard always faces camera");
986 let u = v_sub(q.corners[1], q.corners[0]); // TR - TL = u·size
987 let v = v_sub(q.corners[2], q.corners[0]); // BL - TL = v·size
988 let fwd = [0.0, 1.0, 0.0];
989 assert!(v_dot(u, fwd).abs() < 1e-5, "u ⟂ view");
990 assert!(v_dot(v, fwd).abs() < 1e-5, "v ⟂ view");
991 assert!(v_dot(u, v).abs() < 1e-5, "u ⟂ v");
992 assert!(
993 v_dot(v, up) < 0.0,
994 "rows grow away from `up` (top edge toward up)"
995 );
996 }
997}