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//! This is the RF.0 skeleton: backend selection + fallback + a
17//! clear-to-sky frame. RF.1/RF.2 fill in the real CPU/GPU scene
18//! render; RF.3 adds sprites; RF.4 adds framebuffer capture.
19
20#![forbid(unsafe_code)]
21
22mod cpu;
23#[cfg(feature = "hud")]
24mod cpu_egui;
25mod gpu;
26
27use std::sync::Arc;
28
29use roxlap_core::opticast::OpticastSettings;
30use roxlap_core::sky::Sky;
31use roxlap_core::sprite::SpriteLighting;
32use roxlap_core::Camera;
33use roxlap_scene::Scene;
34
35pub use roxlap_formats::sprite::Sprite;
36pub use roxlap_gpu::{GpuInitError, GpuRendererSettings};
37// Re-exported so hosts can name the [`SceneRenderer::new`] bounds
38// without adding a direct `raw-window-handle` dependency of their own.
39pub use raw_window_handle::{HasDisplayHandle, HasWindowHandle};
40// Re-exported so hosts feed [`SceneRenderer::paint_egui`] from the exact
41// egui version the renderer was built against (`hud` feature).
42#[cfg(feature = "hud")]
43pub use egui;
44
45use crate::cpu::CpuBackend;
46use crate::gpu::GpuBackend;
47
48/// Type-erased display handle stored by the CPU backend's softbuffer
49/// surface. `raw-window-handle` implements `HasDisplayHandle` for
50/// `Arc<H>` (`H: ?Sized`), and the bare trait object implements its
51/// own object-safe trait — so `Arc<W>` coerces to `Arc<DynDisplay>`
52/// for any provider `W`.
53pub(crate) type DynDisplay = dyn HasDisplayHandle + Send + Sync + 'static;
54/// Type-erased window handle counterpart to [`DynDisplay`].
55pub(crate) type DynWindow = dyn HasWindowHandle + Send + Sync + 'static;
56
57/// One placed sprite instance: which [`SpriteSet::models`] entry and
58/// where in the world.
59pub struct SpriteInstanceDesc {
60    pub model: usize,
61    pub pos: [f32; 3],
62}
63
64/// Backend-agnostic sprite description. The facade builds the CPU
65/// per-instance draw list and the GPU instanced registry from the
66/// same data, so both backends show identical sprites. The host owns
67/// content (which models, where, recolouring) — building a recoloured
68/// variant is just a second [`Sprite`] model with edited `kv6.voxels`.
69pub struct SpriteSet {
70    /// Distinct voxel models (KV6 + base orientation). Instances index
71    /// into this; their position overrides the model's.
72    pub models: Vec<Sprite>,
73    pub instances: Vec<SpriteInstanceDesc>,
74    /// Model the [`SceneRenderer::carve_active_sprite`] hotkey edits
75    /// (GPU only, mirroring the demo's `G`-carve). `None` disables it.
76    pub carve_model: Option<usize>,
77}
78
79/// Per-frame inputs both backends consume. The host builds the
80/// [`OpticastSettings`] (it owns scan distance etc.); the facade does
81/// everything else (pool config, sky fill, render, present).
82pub struct FrameParams<'a> {
83    /// CPU opticast settings (scan distance, mip ladder, framebuffer
84    /// geometry). Ignored by the GPU backend.
85    pub settings: &'a OpticastSettings,
86    /// Packed engine sky colour: the CPU sky-miss fill + skycast, and
87    /// the clear colour if no scene renders.
88    pub sky_color: u32,
89    /// Optional sky panorama for the CPU rasterizer's sky sampling.
90    pub sky: Option<&'a Sky>,
91    /// CPU fog: packed colour + max scan distance (voxels). `0` scan
92    /// distance disables CPU fog.
93    pub fog_color: u32,
94    pub fog_max_scan_dist: i32,
95    /// CPU: treat z=255 as air (avoids the S1.X bedrock path for
96    /// out-of-bounds cameras).
97    pub treat_z_max_as_air: bool,
98    /// GPU scene-grid LOD scan distance (world units); see GPU.11.1.
99    /// Ignored by the CPU backend.
100    pub gpu_mip_scan_dist: f32,
101    /// GPU outer-DDA step budget (chunks). Ignored by the CPU backend.
102    pub gpu_max_outer_steps: u32,
103    /// GPU vertical field of view (radians). Ignored by the CPU
104    /// backend (it derives projection from [`OpticastSettings`]).
105    pub gpu_fov_y_rad: f32,
106    /// CPU sprite shading (built by the host from its engine). Required
107    /// for the CPU backend to draw sprites; ignored by the GPU backend
108    /// (its sprite pass shades from the uploaded model colours). `None`
109    /// skips CPU sprite drawing.
110    pub sprite_lighting: Option<&'a SpriteLighting<'a>>,
111}
112
113/// Result of [`SceneRenderer::pick`] — a resolved screen→world voxel
114/// hit. `world` is the surface point (`cam.pos + t · normalize(ray)`);
115/// `grid` + `voxel` are the owning grid and its **grid-local** voxel
116/// (transform-correct for rotated / translated grids).
117#[derive(Clone, Copy, PartialEq, Debug)]
118pub struct PickHit {
119    pub world: [f32; 3],
120    pub grid: roxlap_scene::GridId,
121    pub voxel: glam::IVec3,
122}
123
124/// A world-space view ray: the canonical unproject output of
125/// [`SceneRenderer::view_ray`]. `dir` is unit-length. Feed it straight
126/// to [`roxlap_scene::Scene::raycast`] for depth-free, backend-agnostic
127/// voxel picking (`scene.raycast(ray.origin, ray.dir, max_dist)`), or
128/// intersect it with a plane for tile selection.
129#[derive(Clone, Copy, PartialEq, Debug)]
130pub struct Ray {
131    pub origin: glam::DVec3,
132    pub dir: glam::DVec3,
133}
134
135/// Which renderer a [`SceneRenderer`] resolved to at construction.
136#[derive(Clone, Copy, PartialEq, Eq, Debug)]
137pub enum Backend {
138    /// `roxlap-core` opticast, presented via `softbuffer`.
139    Cpu,
140    /// `roxlap-gpu` compute marcher, presented via wgpu.
141    Gpu,
142}
143
144/// Construction-time options for [`SceneRenderer::new`].
145pub struct RenderOptions {
146    /// Try the GPU backend first. When `false`, or when GPU init
147    /// fails, the renderer uses the CPU backend.
148    pub want_gpu: bool,
149    /// Settings forwarded to [`roxlap_gpu::GpuRenderer`] when the GPU
150    /// backend is selected.
151    pub gpu: GpuRendererSettings,
152    /// Packed `0x00RRGGBB` (alpha ignored) the empty/clear frame fills
153    /// with until a scene render lands. Also the CPU sky-miss colour
154    /// default if a frame supplies none.
155    pub clear_sky: u32,
156    /// CPU [`ScratchPool`](roxlap_core::rasterizer::ScratchPool) `lastx`
157    /// sizing — the largest combined grid `vsid` the CPU rasterizer
158    /// will see. Pre-sizing keeps later frames allocation-free.
159    pub cpu_max_grid_vsid: u32,
160    /// CPU strip-parallel render thread count (capped to the rayon
161    /// pool). One [`ScratchPool`](roxlap_core::rasterizer::ScratchPool)
162    /// slot per thread.
163    pub cpu_render_threads: usize,
164}
165
166impl Default for RenderOptions {
167    fn default() -> Self {
168        Self {
169            want_gpu: false,
170            gpu: GpuRendererSettings::default(),
171            clear_sky: 0x0099_b3d9,
172            // 32 chunks × CHUNK_SIZE_XY — the scene-demo's widest
173            // combined ground grid.
174            cpu_max_grid_vsid: 32 * roxlap_scene::CHUNK_SIZE_XY,
175            cpu_render_threads: 4,
176        }
177    }
178}
179
180/// Renderer-internal backend; never exposes wgpu or softbuffer types.
181/// The GPU variant owns the whole wgpu device/queue/pipelines, so
182/// it's boxed to keep the enum small.
183enum BackendImpl {
184    Cpu(CpuBackend),
185    Gpu(Box<GpuBackend>),
186}
187
188/// Unified renderer over the CPU and GPU paths. See the crate docs.
189pub struct SceneRenderer {
190    inner: BackendImpl,
191}
192
193impl SceneRenderer {
194    /// Build a renderer for `window` — any [`raw-window-handle`]
195    /// provider (winit, SDL, GLFW, …) in an `Arc`. `size` is the
196    /// window's initial physical framebuffer size in pixels; thereafter
197    /// the host reports changes via [`Self::resize`]. Passing the size
198    /// explicitly keeps the facade decoupled from any one windowing
199    /// library's size API.
200    ///
201    /// Selects the GPU backend when `opts.want_gpu` and WGPU
202    /// initialises; otherwise the CPU backend. **Never fails** — a
203    /// missing/incompatible GPU silently yields the CPU path (the
204    /// message is logged to stderr).
205    ///
206    /// [`raw-window-handle`]: raw_window_handle
207    #[must_use]
208    pub fn new<W>(window: Arc<W>, size: (u32, u32), opts: &RenderOptions) -> Self
209    where
210        W: HasWindowHandle + HasDisplayHandle + Send + Sync + 'static,
211    {
212        if opts.want_gpu {
213            match GpuBackend::new(window.clone(), size, opts) {
214                Ok(g) => {
215                    return Self {
216                        inner: BackendImpl::Gpu(Box::new(g)),
217                    };
218                }
219                Err(e) => {
220                    eprintln!(
221                        "roxlap-render: GPU init failed ({e}); falling back to the CPU renderer",
222                    );
223                }
224            }
225        }
226        Self {
227            inner: BackendImpl::Cpu(CpuBackend::new(window, size, opts)),
228        }
229    }
230
231    /// Which backend was selected.
232    #[must_use]
233    pub fn backend(&self) -> Backend {
234        match self.inner {
235            BackendImpl::Cpu(_) => Backend::Cpu,
236            BackendImpl::Gpu(_) => Backend::Gpu,
237        }
238    }
239
240    /// The GPU adapter description when on the GPU backend, else
241    /// `None`.
242    #[must_use]
243    pub fn adapter_info(&self) -> Option<&str> {
244        match &self.inner {
245            BackendImpl::Gpu(g) => Some(g.adapter_info()),
246            BackendImpl::Cpu(_) => None,
247        }
248    }
249
250    /// Upload an equirectangular sky panorama (RGBA8, `w×h`) for the
251    /// GPU marcher's sky sampling. No-op on the CPU backend, which
252    /// samples the [`Sky`] passed in each [`FrameParams`] instead.
253    pub fn set_sky_panorama(&mut self, rgba: &[u8], w: u32, h: u32) {
254        if let BackendImpl::Gpu(g) = &mut self.inner {
255            g.set_sky_panorama(rgba, w, h);
256        }
257    }
258
259    /// Follow a window resize. CPU resizes its framebuffer lazily, so
260    /// this only matters to the GPU swapchain — but it's safe to call
261    /// for both.
262    pub fn resize(&mut self, width: u32, height: u32) {
263        match &mut self.inner {
264            BackendImpl::Cpu(c) => c.resize(width, height),
265            BackendImpl::Gpu(g) => g.resize(width, height),
266        }
267    }
268
269    /// Composite `scene` from `camera` with `frame` params into the
270    /// backend's frame buffer — **without presenting**. The CPU backend
271    /// fills sky + runs the opticast compositor into an owned buffer;
272    /// the GPU backend uploads/refreshes the scene, runs the compute
273    /// marcher + sprite pass, and acquires (but does not present) the
274    /// swapchain frame.
275    ///
276    /// Finish the frame with exactly one of [`present`](Self::present)
277    /// (no overlay) or [`paint_egui`](Self::paint_egui) (UI overlay).
278    /// Calling `render` again without finishing drops the pending frame.
279    pub fn render(&mut self, scene: &mut Scene, camera: &Camera, frame: &FrameParams) {
280        match &mut self.inner {
281            BackendImpl::Cpu(c) => c.render(scene, camera, frame),
282            BackendImpl::Gpu(g) => g.render(scene, camera, frame),
283        }
284    }
285
286    /// Present the frame [`render`](Self::render) composited, with no UI
287    /// overlay. Pairs with `render`; use [`paint_egui`](Self::paint_egui)
288    /// instead to overlay an egui UI before presenting.
289    pub fn present(&mut self) {
290        match &mut self.inner {
291            BackendImpl::Cpu(c) => c.present(),
292            BackendImpl::Gpu(g) => g.present(),
293        }
294    }
295
296    /// Overlay an egui UI on the frame [`render`](Self::render)
297    /// composited, then present it (`hud` feature). The host runs egui
298    /// itself (e.g. `egui` + `egui-winit`) and passes the tessellated
299    /// `jobs` ([`egui::Context::tessellate`]) and the per-frame
300    /// `textures` delta from [`egui::FullOutput`]; `pixels_per_point` is
301    /// the UI scale (`ctx.pixels_per_point()`).
302    ///
303    /// The GPU backend paints via `egui-wgpu`; the CPU backend
304    /// software-rasterises the tessellation into its framebuffer. Use
305    /// this **instead of** [`present`](Self::present) — both finish the
306    /// frame.
307    #[cfg(feature = "hud")]
308    pub fn paint_egui(
309        &mut self,
310        jobs: &[egui::ClippedPrimitive],
311        textures: &egui::TexturesDelta,
312        pixels_per_point: f32,
313    ) {
314        match &mut self.inner {
315            BackendImpl::Cpu(c) => c.paint_egui(jobs, textures, pixels_per_point),
316            BackendImpl::Gpu(g) => g.paint_egui(jobs, textures, pixels_per_point),
317        }
318    }
319
320    /// Register sprite models + instances. The CPU backend builds a
321    /// per-instance draw list; the GPU backend builds an instanced
322    /// model registry. Call once at setup (or again to replace).
323    pub fn set_sprites(&mut self, set: &SpriteSet) {
324        match &mut self.inner {
325            BackendImpl::Cpu(c) => c.set_sprites(set),
326            BackendImpl::Gpu(g) => g.set_sprites(set),
327        }
328    }
329
330    /// Carve the next z-layer off the [`SpriteSet::carve_model`] and
331    /// re-upload (the demo's `G` hotkey + GPU.12 copy-on-modify). GPU
332    /// only; a no-op on the CPU backend. Returns the voxels removed.
333    pub fn carve_active_sprite(&mut self) -> u32 {
334        match &mut self.inner {
335            BackendImpl::Cpu(_) => 0,
336            BackendImpl::Gpu(g) => g.carve_active_sprite(),
337        }
338    }
339
340    /// Request that the next [`render`](Self::render) capture its
341    /// framebuffer for [`take_capture`](Self::take_capture). CPU only
342    /// (the GPU swapchain isn't read back) — a no-op on GPU.
343    pub fn request_capture(&mut self) {
344        if let BackendImpl::Cpu(c) = &mut self.inner {
345            c.request_capture();
346        }
347    }
348
349    /// Take the most recently captured frame as packed `0x00RRGGBB`
350    /// pixels + dimensions, or `None` if no capture is ready / GPU.
351    pub fn take_capture(&mut self) -> Option<(Vec<u32>, u32, u32)> {
352        match &mut self.inner {
353            BackendImpl::Cpu(c) => c.take_capture(),
354            BackendImpl::Gpu(_) => None,
355        }
356    }
357
358    /// Screen→world picking input: the world-space hit distance `t` at
359    /// window pixel `(x, y)` from the **last rendered frame**, or `None`
360    /// for out-of-bounds pixels and sky / no-hit. The host reconstructs
361    /// the world hit point as `cam.pos + t * normalize(ray_dir)`, where
362    /// `ray_dir` is the same per-pixel ray the frame was rendered with
363    /// (see the backend's projection).
364    ///
365    /// `t` is the distance to the nearest **scene-grid** surface
366    /// (terrain + grids); sprites do not occlude it (the sprite pass
367    /// reads depth read-only), so a cursor sprite under the pointer is
368    /// transparent to the pick.
369    ///
370    /// Cost: the CPU backend reads its in-memory z-buffer (free); the
371    /// GPU backend stages the depth buffer and blocks on a device poll
372    /// (cheap at click time — do not call every frame). The GPU path
373    /// only has depth when the last frame drew sprites (`write_depth`).
374    #[must_use]
375    pub fn pick_depth(&self, x: u32, y: u32) -> Option<f32> {
376        match &self.inner {
377            BackendImpl::Cpu(c) => c.pick_depth(x, y),
378            BackendImpl::Gpu(g) => g.pick_depth(x, y),
379        }
380    }
381
382    /// World-space view-ray direction (un-normalised) for window pixel
383    /// `(x, y)`, under the projection the **last frame** rendered with.
384    /// The backends differ (CPU `setcamera` vs GPU vertical-FOV
385    /// pinhole), so this hides which one is active. `None` before the
386    /// first frame. Intersect it with a plane for tile picking, or feed
387    /// it to [`Self::pick`] for a voxel.
388    #[must_use]
389    pub fn pixel_ray(&self, camera: &Camera, x: f64, y: f64) -> Option<[f64; 3]> {
390        match &self.inner {
391            BackendImpl::Cpu(c) => c.pixel_ray(camera, x, y),
392            BackendImpl::Gpu(g) => g.pixel_ray(camera, x, y),
393        }
394    }
395
396    /// Canonical screen→world unproject: the full view [`Ray`]
397    /// (`camera.pos` origin + unit direction) for window pixel
398    /// `(x, y)`, under whichever projection the last frame used. The
399    /// one entry point both backends honour — hosts never reconstruct
400    /// the projection. `None` before the first frame or for a
401    /// degenerate ray.
402    ///
403    /// Compose with [`roxlap_scene::Scene::raycast`] for depth-free
404    /// picking that's identical on CPU and GPU:
405    /// `renderer.view_ray(cam, x, y).and_then(|r| scene.raycast(r.origin, r.dir, max))`.
406    #[must_use]
407    pub fn view_ray(&self, camera: &Camera, x: f64, y: f64) -> Option<Ray> {
408        let d = self.pixel_ray(camera, x, y)?;
409        let len = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt();
410        if len < 1e-12 {
411            return None;
412        }
413        Some(Ray {
414            origin: glam::DVec3::from_array([camera.pos[0], camera.pos[1], camera.pos[2]]),
415            dir: glam::DVec3::new(d[0] / len, d[1] / len, d[2] / len),
416        })
417    }
418
419    /// One-call screen→world voxel pick: unproject pixel `(x, y)` with
420    /// the active backend's projection, read the last frame's depth
421    /// there, reconstruct the world hit, and resolve it to the owning
422    /// grid + grid-local voxel via [`Scene::resolve_voxel`]. `None` on
423    /// sky / no-hit, or when no grid claims the surface.
424    ///
425    /// `scene` and `camera` must be the ones the last frame rendered;
426    /// the projection (size + FOV / `hx,hy,hz`) is taken from that
427    /// frame. Cheap on CPU (in-memory z-buffer); on GPU it stages the
428    /// depth buffer (a click-time device poll — not per frame).
429    #[must_use]
430    pub fn pick(&self, scene: &Scene, camera: &Camera, x: u32, y: u32) -> Option<PickHit> {
431        let dir = self.pixel_ray(camera, f64::from(x), f64::from(y))?;
432        let t = f64::from(self.pick_depth(x, y)?);
433        let len = (dir[0] * dir[0] + dir[1] * dir[1] + dir[2] * dir[2]).sqrt();
434        if len < 1e-9 {
435            return None;
436        }
437        let s = t / len; // world = cam.pos + t · (dir / |dir|)
438        let world = glam::DVec3::new(
439            camera.pos[0] + dir[0] * s,
440            camera.pos[1] + dir[1] * s,
441            camera.pos[2] + dir[2] * s,
442        );
443        let (grid, voxel) = scene.resolve_voxel(world, glam::DVec3::from_array(dir))?;
444        #[allow(clippy::cast_possible_truncation)]
445        let world_f32 = [world.x as f32, world.y as f32, world.z as f32];
446        Some(PickHit {
447            world: world_f32,
448            grid,
449            voxel,
450        })
451    }
452}
453
454#[cfg(test)]
455mod tests {
456    use super::*;
457
458    #[test]
459    fn options_default_is_cpu_intent() {
460        let o = RenderOptions::default();
461        assert!(!o.want_gpu);
462        assert_eq!(o.clear_sky & 0xFF00_0000, 0, "clear_sky is 0x00RRGGBB");
463    }
464}