Skip to main content

roxlap_render/
lib.rs

1//! roxlap-render — unified CPU/GPU renderer facade.
2//!
3//! One [`SceneRenderer`] hides the choice between the CPU opticast
4//! path (`roxlap-core` / `roxlap-scene`, presented via `softbuffer`)
5//! and the GPU compute-shader path (`roxlap-gpu`, presented via its
6//! own wgpu surface). Construction picks the GPU backend when asked
7//! and able, and **falls back to CPU automatically** when WGPU init
8//! fails — so a host never has to branch on GPU availability or carry
9//! the `Scene`→GPU upload/refresh/transform glue itself.
10//!
11//! Hosts stay thin: build a `Scene`, advance it from input, then call
12//! [`SceneRenderer::render`] each frame. The facade owns the window
13//! surface, the framebuffer/z-buffer (CPU) or the resident scene +
14//! dirty-chunk tracking (GPU), and presentation.
15//!
16//! The per-frame flow is `render` → *(optional overlays)* → finish.
17//! Between [`SceneRenderer::render`] and the finishing
18//! [`SceneRenderer::present`] / [`SceneRenderer::paint_egui`] call, a
19//! host may overlay depth-tested world-space lines with
20//! [`SceneRenderer::draw_lines`] (editor gizmos, debug geometry — see
21//! [`Line3`]); they land in the framebuffer, occluded by the rendered
22//! scene, with egui still painting panels on top.
23//!
24//! This is the RF.0 skeleton: backend selection + fallback + a
25//! clear-to-sky frame. RF.1/RF.2 fill in the real CPU/GPU scene
26//! render; RF.3 adds sprites; RF.4 adds framebuffer capture.
27
28#![forbid(unsafe_code)]
29
30mod cpu;
31/// WebGL2 framebuffer presenter for the CPU backend on wasm (the
32/// browser has no `softbuffer`).
33#[cfg(target_arch = "wasm32")]
34mod cpu_blit;
35#[cfg(feature = "hud")]
36mod cpu_egui;
37mod gpu;
38
39#[cfg(not(target_arch = "wasm32"))]
40use std::sync::Arc;
41
42use roxlap_core::opticast::OpticastSettings;
43use roxlap_core::sky::Sky;
44use roxlap_core::sprite::SpriteLighting;
45use roxlap_core::Camera;
46use roxlap_scene::Scene;
47
48pub use roxlap_formats::kfa::KfaSprite;
49pub use roxlap_formats::kv6::Kv6;
50pub use roxlap_formats::sprite::Sprite;
51pub use roxlap_gpu::{GpuInitError, GpuRendererSettings, PowerPreference};
52// Re-exported so hosts can name the [`SceneRenderer::new`] bounds
53// without adding a direct `raw-window-handle` dependency of their own.
54pub use raw_window_handle::{HasDisplayHandle, HasWindowHandle};
55// Re-exported so hosts feed [`SceneRenderer::paint_egui`] from the exact
56// egui version the renderer was built against (`hud` feature).
57#[cfg(feature = "hud")]
58pub use egui;
59
60use crate::cpu::CpuBackend;
61use crate::gpu::GpuBackend;
62
63/// Type-erased display handle stored by the CPU backend's softbuffer
64/// surface. `raw-window-handle` implements `HasDisplayHandle` for
65/// `Arc<H>` (`H: ?Sized`), and the bare trait object implements its
66/// own object-safe trait — so `Arc<W>` coerces to `Arc<DynDisplay>`
67/// for any provider `W`.
68#[cfg(not(target_arch = "wasm32"))]
69pub(crate) type DynDisplay = dyn HasDisplayHandle + Send + Sync + 'static;
70/// Type-erased window handle counterpart to [`DynDisplay`].
71#[cfg(not(target_arch = "wasm32"))]
72pub(crate) type DynWindow = dyn HasWindowHandle + Send + Sync + 'static;
73
74/// One placed sprite instance: which [`SpriteSet::models`] entry and
75/// where in the world.
76pub struct SpriteInstanceDesc {
77    pub model: usize,
78    pub pos: [f32; 3],
79}
80
81/// Stable handle to a registered sprite model, returned (one per
82/// [`SpriteSet::models`] entry, in order) by
83/// [`SceneRenderer::set_sprites`]. Pass it to
84/// [`refresh_sprite_model`](SceneRenderer::refresh_sprite_model) to
85/// re-register that model's geometry after a content edit — so callers
86/// never track the positional `usize` index themselves. Opaque on
87/// purpose: there is no arithmetic to do on it.
88#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
89pub struct SpriteModelId(pub(crate) usize);
90
91/// 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}