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