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