roxlap_gpu/lib.rs
1//! WGPU-backed compute-shader renderer scaffold for the roxlap
2//! voxel engine. GPU.1 in `PORTING-GPU.md`.
3//!
4//! GPU.1's job: stand up the device + surface + swapchain on a
5//! host window (any [`raw-window-handle`](raw_window_handle)
6//! provider), present a clear-to-colour frame each render call,
7//! and give the host a one-call opt-in. No voxel marching yet — the
8//! [`examples/probe.rs`](../examples/probe.rs) standalone holds
9//! the empirical FPS baseline from GPU.0.
10//!
11//! Later sub-substages flesh `GpuRenderer::render` out: GPU.2
12//! uploads voxel data, GPU.3 dispatches the inner-DDA compute
13//! shader, GPU.4 layers in chunk skipping, GPU.5 plugs the renderer
14//! into `roxlap-scene::Scene`, …
15//!
16//! ## Host integration shape (GPU.1)
17//!
18//! ```no_run
19//! use std::sync::Arc;
20//! use roxlap_gpu::{GpuRenderer, GpuRendererSettings};
21//! # use winit::window::Window;
22//! # fn pick(w: Arc<Window>, size: (u32, u32)) -> Option<GpuRenderer> {
23//! match GpuRenderer::new_blocking(w, size, GpuRendererSettings::default()) {
24//! Ok(r) => Some(r),
25//! Err(e) => {
26//! eprintln!("GPU init failed: {e}; falling back to CPU");
27//! None
28//! }
29//! }
30//! # }
31//! ```
32
33#![allow(clippy::must_use_candidate, clippy::too_many_lines)]
34
35pub mod camera;
36pub mod decompress;
37pub mod grid;
38// Headless rendering is a native-only test/bench aid: it blocks on
39// `pollster` + `device.poll(Wait)`, neither of which exists on wasm.
40#[cfg(not(target_arch = "wasm32"))]
41pub mod headless;
42pub mod resident;
43pub mod scene;
44pub mod sprite_model;
45
46mod lights;
47mod overlay;
48mod readback;
49mod shader_src;
50
51pub use camera::Camera;
52pub use decompress::{decompress_chunk, ChunkUpload, BEDROCK_RGB, CHUNK_Z};
53pub use grid::{bounding_box_of, GridUpload};
54#[cfg(not(target_arch = "wasm32"))]
55pub use headless::HeadlessGpu;
56pub use resident::GpuChunkResident;
57pub use scene::{
58 GpuSceneResident, GridRuntimeTransform, GridStaticMeta, RefreshOutcome, SceneUpload,
59};
60pub use sprite_model::{
61 build_sprite_model, build_sprite_model_with_materials, sprite_model_from_clip_frame,
62 sprite_model_from_clip_frame_with_materials, sprite_model_from_voxel_frame,
63 sprite_model_from_voxel_frame_with_materials, SpriteInstance, SpriteInstanceTransform,
64 SpriteModel, SpriteModelRegistry, SpriteRegistryResident,
65};
66
67pub use lights::{GpuLight, SceneLights, MAX_POINT_LIGHTS, MAX_SHADOW_CASTERS};
68pub use overlay::{GpuImageQuad, GpuLine, GpuLineCamera};
69pub use readback::pinhole_pixel_ray;
70
71use std::sync::Arc;
72
73use bytemuck::{Pod, Zeroable};
74use raw_window_handle::{HasDisplayHandle, HasWindowHandle};
75use roxlap_formats::color::Rgb;
76
77use lights::{inject_grid_sun_dirs, pack_scene_lights, upload_grid_point_lights, GpuPointLight};
78use overlay::{ImageResident, ImageResources, LineResources, LINE_NEAR_Z};
79use shader_src::{scene_shader_source, sprite_shader_source};
80
81/// Caller-controllable knobs for [`GpuRenderer::new`]. Defaults
82/// target "highest-performance GPU, prefer Mailbox/Immediate over
83/// vsync" — i.e. the same configuration the GPU.0 probe used to
84/// measure the FPS ceiling.
85#[derive(Debug, Clone, Copy)]
86pub struct GpuRendererSettings {
87 /// Which adapter class to request from wgpu. [`PowerPreference::High`]
88 /// (the default) picks the discrete GPU on hybrid systems;
89 /// [`PowerPreference::Low`] the integrated/software one. The
90 /// `ROXLAP_GPU_POWER=low|high` env escape hatch is resolved into
91 /// this field by the roxlap-render facade (QE-C6) — this crate
92 /// itself reads no environment.
93 pub power_preference: PowerPreference,
94 /// Initial clear colour cycled by GPU.1's empty render path.
95 /// The voxel-rendering substages overwrite this entirely.
96 pub clear_colour: [f64; 3],
97 /// Prefer mailbox/immediate when offered; falls back to FIFO if
98 /// the surface only supports it (Wayland under Mesa often does).
99 pub uncapped_present: bool,
100}
101
102/// Adapter power class requested at init — mirrors
103/// `wgpu::PowerPreference` without leaking the wgpu type into host
104/// signatures.
105#[derive(Debug, Clone, Copy)]
106pub enum PowerPreference {
107 /// Prefer the low-power adapter (integrated / software rasterizer).
108 Low,
109 /// Prefer the highest-performance adapter (discrete GPU). The default.
110 High,
111}
112
113impl Default for GpuRendererSettings {
114 fn default() -> Self {
115 Self {
116 power_preference: PowerPreference::High,
117 clear_colour: [0.06, 0.08, 0.12],
118 uncapped_present: true,
119 }
120 }
121}
122
123/// Errors `GpuRenderer::new` surfaces to the host. The host's
124/// expected flow is "try this, fall back to the CPU path on Err".
125#[derive(Debug)]
126pub enum GpuInitError {
127 /// Creating the presentation surface from the host's raw window
128 /// handle failed (headless init never returns this).
129 CreateSurface(wgpu::CreateSurfaceError),
130 /// No compatible adapter — typically no Vulkan/Metal/DX12 driver on
131 /// the system.
132 NoAdapter,
133 /// The adapter refused the device request (e.g. the required
134 /// storage-buffer limits exceed what it supports).
135 RequestDevice(wgpu::RequestDeviceError),
136}
137
138impl std::fmt::Display for GpuInitError {
139 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
140 match self {
141 Self::CreateSurface(e) => write!(f, "create_surface failed: {e}"),
142 Self::NoAdapter => write!(
143 f,
144 "no compatible adapter — does this system have a Vulkan/Metal/DX12 driver?"
145 ),
146 Self::RequestDevice(e) => write!(f, "request_device failed: {e}"),
147 }
148 }
149}
150
151impl std::error::Error for GpuInitError {
152 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
153 match self {
154 Self::CreateSurface(e) => Some(e),
155 Self::RequestDevice(e) => Some(e),
156 Self::NoAdapter => None,
157 }
158 }
159}
160
161impl From<wgpu::CreateSurfaceError> for GpuInitError {
162 fn from(value: wgpu::CreateSurfaceError) -> Self {
163 Self::CreateSurface(value)
164 }
165}
166
167impl From<wgpu::RequestDeviceError> for GpuInitError {
168 fn from(value: wgpu::RequestDeviceError) -> Self {
169 Self::RequestDevice(value)
170 }
171}
172
173/// RP.2 — flat posterize config for the resolve pass uniform. `levels[c] <= 1`
174/// leaves that channel untouched; `dither` is `0`=none, `1`=Bayer4×4,
175/// `2`=blue-noise (IGN). Mirror of `roxlap_render::PosterizeConfig`.
176#[derive(Clone, Copy, Debug)]
177pub struct PosterizeGpu {
178 /// Quantization levels per RGB channel (`[r, g, b]`). `n >= 2`
179 /// snaps that channel to `n` output values; `0` or `1` leaves the
180 /// channel untouched.
181 pub levels: [u32; 3],
182 /// Dither pattern applied before quantization: `0` = none,
183 /// `1` = ordered Bayer 4×4, `2` = blue-noise (interleaved-gradient
184 /// noise). Other values behave as `0`.
185 pub dither: u32,
186}
187
188/// RP.0 — logical render resolution policy for the scene marcher, decoupled
189/// from the swapchain size. Mirror of `roxlap_render::RenderResolution` (kept
190/// here so `roxlap-gpu` has no upward dependency). See [`GpuRenderer::render_dims`].
191#[derive(Clone, Copy, Debug, PartialEq, Default)]
192pub enum RenderResolution {
193 /// Logical == swapchain. Default; byte-identical to pre-RP rendering.
194 #[default]
195 Native,
196 /// Fixed logical grid, nearest-upscaled to the swapchain.
197 Fixed {
198 /// Logical render width in pixels (min 1; independent of the
199 /// swapchain width).
200 w: u32,
201 /// Logical render height in pixels (min 1).
202 h: u32,
203 },
204 /// Logical = `round(swapchain * factor)`, clamped to `>= 1px`.
205 Scale(f32),
206}
207
208impl RenderResolution {
209 /// Resolve to concrete logical pixels given the swapchain (native) size.
210 #[must_use]
211 fn logical_for(self, native: (u32, u32)) -> (u32, u32) {
212 let (nw, nh) = (native.0.max(1), native.1.max(1));
213 match self {
214 Self::Native => (nw, nh),
215 Self::Fixed { w, h } => (w.max(1), h.max(1)),
216 Self::Scale(f) => {
217 let s = f.max(1e-3);
218 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
219 let lw = ((nw as f32) * s).round() as u32;
220 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
221 let lh = ((nh as f32) * s).round() as u32;
222 (lw.max(1), lh.max(1))
223 }
224 }
225 }
226}
227
228/// WGPU-backed renderer bound to a host window: owns the device,
229/// queue, surface, and every lazily-built pass (multi-grid scene DDA,
230/// sprite DDA, resolve/posterize, overlays, egui HUD).
231/// [`Self::render_scene`] marches the frame; [`Self::present`] shows
232/// it. Construct with [`Self::new`] / [`Self::new_blocking`] and fall
233/// back to the CPU path on error.
234///
235/// The window handle is consumed only at construction — wgpu's
236/// `Surface<'static>` keeps its own `Arc` clone, so the renderer holds
237/// no window field of its own.
238#[allow(clippy::struct_excessive_bools)] // independent per-frame flags, not a state enum
239pub struct GpuRenderer {
240 surface: wgpu::Surface<'static>,
241 surface_config: wgpu::SurfaceConfiguration,
242 device: wgpu::Device,
243 queue: wgpu::Queue,
244 adapter_info: String,
245 /// Whether the adapter is a low-power device (integrated / software)
246 /// rather than a discrete GPU — hosts use this to pick lighter
247 /// render-resolution defaults. See [`Self::low_power`].
248 low_power: bool,
249 clear_colour: [f64; 3],
250 frame_count: u32,
251 /// Mirror the marched scene horizontally on present (the scene blit
252 /// samples `width-1-x`, and line/image overlays mirror their NDC x).
253 /// The egui pass is unaffected. See [`Self::set_flip_x`].
254 flip_x: bool,
255 /// RP.0 — logical render resolution. The scene/sprite passes march at
256 /// [`Self::render_dims`] (≤ the swapchain under a fixed value) into a
257 /// render-sized framebuffer + depth buffer; the blit nearest-upscales it
258 /// to the swapchain. `Native` keeps `render_dims == swapchain` ⇒ the
259 /// pre-RP straight blit, byte-identical.
260 render_res: RenderResolution,
261 /// RP.1 — supersampling factor. `1` = off (march at logical size). `>1`
262 /// marches at `logical × ssaa` into the framebuffer/depth and a resolve
263 /// compute pass box-downfilters back to logical before the blit.
264 ssaa: u32,
265 /// RP.2 — reduced-palette post applied in the resolve pass (at logical
266 /// resolution). `None` = off (`levels = [1,1,1]` ⇒ the RP.1 box-avg only).
267 posterize: Option<PosterizeGpu>,
268 /// Lazy-built on first [`Self::render_scene`] call. Holds the
269 /// multi-grid pipeline + per-grid camera uniforms.
270 scene_dda: Option<SceneDdaResources>,
271 /// TV.6 — global voxel-material palette mirrored to the scene pass (256
272 /// entries, default all-opaque), set via [`Self::set_scene_materials`].
273 scene_materials: Box<[MaterialGpu; 256]>,
274 /// TV.6 — terrain colour→material map (`[rgb, material_id]` rows) +
275 /// whether any mapped material is translucent (the shader gate).
276 scene_terrain_map: Vec<[u32; 2]>,
277 scene_terrain_translucent: bool,
278 /// QE.8c - the cross-frame validity/dirty flags, grouped with
279 /// their lifecycle rules in one place (see [`FrameDirty`]).
280 dirty: FrameDirty,
281 /// GPU.8 — panoramic sky texture + sampler. Created at
282 /// `new` as a 1×1 mid-grey default; [`Self::set_sky_panorama`]
283 /// replaces it. The scene-DDA bind group references this each
284 /// frame.
285 sky_texture: wgpu::Texture,
286 sky_view: wgpu::TextureView,
287 sky_sampler: wgpu::Sampler,
288 /// GPU.8 fog state. `color` is BGRA-style premultiplied (each
289 /// channel in [0, 1]); `near` is the world-t distance at which
290 /// fog starts kicking in; `far` is the distance at which it's
291 /// fully opaque. The shader does
292 /// `mix(hit, fog, smoothstep(near, far, t))`.
293 fog_color: [f32; 3],
294 fog_near: f32,
295 fog_far: f32,
296 /// GPU.10 — sprites rendered as DDA-marched voxel models (the
297 /// precise path; the GPU.9 compute splatter it replaced was
298 /// retired in 10.5). Holds the concatenated model registry + the
299 /// per-frame instance array; set via [`Self::set_sprite_instances`].
300 sprite_registry: Option<sprite_model::SpriteRegistryResident>,
301 /// Lazy-built pipeline + uniform for the model-DDA pass.
302 sprite_model_dda: Option<SpriteModelDdaResources>,
303 /// TV — global voxel-material palette mirrored to the sprite pass (256
304 /// entries, default all-opaque), set via [`Self::set_sprite_materials`].
305 /// `sprite_has_translucent` gates the shader's accumulate path;
306 /// `sprite_has_emissive` gates the opaque marcher's per-hit material
307 /// fetch (EV — emissive sprite voxels render full-bright).
308 sprite_materials: Box<[MaterialGpu; 256]>,
309 sprite_has_translucent: bool,
310 sprite_has_emissive: bool,
311 /// XS.4 — whether this device grants enough storage buffers per shader
312 /// stage for GPU sprite shadows (the cross-pass occupancy bindings push a
313 /// pass past the baseline 16). `false` ⇒ GPU sprites render unshadowed (the
314 /// pre-XS.4 path); the CPU backend always has sprite shadows. Computed once
315 /// at init from the granted device limits (see
316 /// [`SPRITE_SHADOW_MIN_STORAGE_BUFFERS`]).
317 sprite_shadows_capable: bool,
318 /// GPU.10.4 — LOD aggressiveness: step a sprite to the next mip
319 /// once a mip-0 voxel projects below this many screen pixels.
320 /// Defaults to 1.0 — the "no sub-pixel voxels" threshold, which
321 /// keeps GPU sprites visually identical to the CPU backend (QE.8:
322 /// the old 4.0 default collapsed thin/hollow translucent models
323 /// at range — glass read denser than on CPU). Tune via
324 /// [`Self::set_sprite_lod_px`].
325 sprite_lod_px: f32,
326 /// GPU.11.1 — scene-grid LOD scan distance (world units). A chunk
327 /// entered at world-t `t` is marched at the mip level
328 /// `floor(log2(max(t, msd) / msd))`, clamped to the grid's mip
329 /// ladder. `0` disables LOD (always mip-0). Tunable via
330 /// [`Self::set_scene_mip_scan_dist`] — the axis-aligned-mip-beams
331 /// mitigation (GPU.11.2) pushes it outward if banding appears.
332 scene_mip_scan_dist: f32,
333 /// Per-face grid side-shades (voxlap setsideshades), packed for the
334 /// scene-DDA uniform: `[0]=(top,bot,left,right)`, `[1]=(up,down,_,_)`.
335 /// Each is the u8 shade intensity. `[[0;4];2]` = no shading. Set via
336 /// [`Self::set_scene_side_shades`].
337 scene_side_shades: [[i32; 4]; 2],
338 /// DL — per-frame dynamic lights (sun + point lights), already
339 /// transformed into each grid's local frame by the facade. Set via
340 /// [`Self::set_scene_lights`]; [`SceneLights::default`] = no lights
341 /// (the pre-DL render). Consumed by `render_scene` each frame.
342 scene_lights: SceneLights,
343 /// PF.5 — cached results of the last `pack_scene_lights` (they feed the
344 /// per-frame uniform even on pack-skipped frames).
345 lights_sun_flags: u32,
346 lights_point_count: u32,
347 /// PF.5 — grid count the lights were last packed for (the grid-major
348 /// rows depend on it, so a grid-count change forces a re-pack).
349 lights_packed_grids: u32,
350 /// Vertical FOV (radians) the last `render_scene` marched with —
351 /// cached so [`Self::pixel_ray`] reconstructs the matching view ray
352 /// for picking. `0` until the first scene render.
353 last_fov_y_rad: f32,
354 /// The acquired-but-not-yet-presented swapchain frame from the most
355 /// recent deferred render ([`Self::render_scene`] /
356 /// [`Self::render_clear_deferred`]). [`Self::present`] shows it as
357 /// is; [`Self::paint_egui`] overlays egui first. Lets a host slot a
358 /// UI pass between the marcher and present. `None` between present
359 /// and the next render.
360 pending_frame: Option<(wgpu::SurfaceTexture, wgpu::TextureView)>,
361 /// PF.4 — persistent per-frame camera/light buffers + cached scene and
362 /// sprite bind groups. Lazily built on the first `render_scene`.
363 frame_pack: Option<FramePackBuffers>,
364 /// Lazy-built debug-line pipeline (L3.2) — built on the first
365 /// [`Self::draw_lines_deferred`] call.
366 line_resources: Option<LineResources>,
367 /// Persistent debug-line vertex buffer (L3.3) — grown on demand and
368 /// reused across frames so a per-frame overlay (hundreds of segments)
369 /// costs one `write_buffer`, not a fresh allocation. `line_vbuf_cap`
370 /// is its capacity in bytes.
371 line_vbuf: Option<wgpu::Buffer>,
372 line_vbuf_cap: u64,
373 /// PF.13 (H7-lite) — cached line-overlay bind group + the scene
374 /// depth buffer it was built against (`None` = the dummy depth).
375 /// Rebuilt only when that identity changes (resize / scene swap)
376 /// instead of every `draw_lines_deferred` call.
377 line_bg_cache: Option<(wgpu::BindGroup, Option<wgpu::Buffer>)>,
378 /// Lazy-built image-sprite pipeline — built on the first
379 /// [`Self::draw_images_deferred`] call.
380 image_resources: Option<ImageResources>,
381 /// Persistent image-sprite vertex buffer, grown on demand and reused
382 /// across frames (like [`Self::line_vbuf`]).
383 image_vbuf: Option<wgpu::Buffer>,
384 image_vbuf_cap: u64,
385 /// PF.13 (H7-lite) — image-overlay bind groups keyed by image id,
386 /// valid only while the depth-buffer identity in
387 /// [`image_bg_depth`](Self::image_bg_depth) holds. Entries are
388 /// evicted on image drop / slot re-upload; the whole map clears
389 /// when the depth buffer is swapped.
390 image_bg_cache: std::collections::HashMap<usize, wgpu::BindGroup>,
391 image_bg_depth: Option<wgpu::Buffer>,
392 /// Retained image-sprite textures, indexed by the id
393 /// [`Self::upload_image`] returns. A dropped slot is `None` and is
394 /// re-used by a later upload.
395 images: Vec<Option<ImageResident>>,
396 /// Lazy-built `egui-wgpu` paint pipeline; created on the first
397 /// [`Self::paint_egui`] call (`hud` feature).
398 #[cfg(feature = "hud")]
399 egui_renderer: Option<egui_wgpu::Renderer>,
400}
401
402struct SceneDdaResources {
403 /// RP.1 — the **march** framebuffer size (`logical × ssaa`); the scene +
404 /// sprite + depth passes run at this. Used for the rebuild check.
405 storage_size: (u32, u32),
406 /// RP.1 — the **logical** (resolved) size: `resolve_buf` + the blit src.
407 logical_size: (u32, u32),
408 /// QE.7a - retained so `read_frame_pixels` (capture) can stage it;
409 /// the resolve/blit bind groups hold their own references.
410 resolve_buf: wgpu::Buffer,
411 /// Framebuffer as a packed-`rgba8unorm` storage **buffer** (row
412 /// stride = march width), written by the scene + sprite compute passes
413 /// and read by the resolve pass. A buffer (not a storage texture) dodges
414 /// Chrome-Dawn's tiled write-texture layout (which produced a
415 /// 128×256-tiled image); linear + explicit stride is portable.
416 framebuffer: wgpu::Buffer,
417 uniform_buf: wgpu::Buffer,
418 bgl_dda: wgpu::BindGroupLayout,
419 pipeline_dda: wgpu::ComputePipeline,
420 /// RP.1/RP.2 — box-downfilter + posterize compute pass
421 /// (`scene_resolve.wgsl`): framebuffer(march) → resolve_buf(logical). The
422 /// bind group retains the resolve buffer (not stored separately).
423 pipeline_resolve: wgpu::ComputePipeline,
424 resolve_bg: wgpu::BindGroup,
425 /// Resolve uniform `[src w,h, dst w,h, ssaa, levels r,g,b, dither, pad×3]`.
426 /// Retained so the posterize fields are re-written per frame (RP.2).
427 resolve_dims: wgpu::Buffer,
428 /// Blit bind group — binds `resolve_buf` (logical) + `blit_dims`.
429 blit_bg: wgpu::BindGroup,
430 /// PF.5 (H6) — blit variant reading `framebuffer` directly, used when
431 /// the resolve pass would be an identity copy (ssaa 1, posterize off).
432 blit_bg_direct: wgpu::BindGroup,
433 pipeline_blit: wgpu::RenderPipeline,
434 /// Blit uniform `Dims`: `[src(logical) w,h, dst(swapchain) w,h, flip_x,
435 /// pad×3]`. Retained so the flip flag (offset 16) is re-written per frame.
436 blit_dims: wgpu::Buffer,
437 /// GPU.9 — per-pixel world-t depth (f32 bits as u32), sized
438 /// `width * height * 4`. The scene pass writes it when sprites
439 /// are present; the sprite model-DDA pass reads + composites
440 /// against it.
441 depth_buffer: wgpu::Buffer,
442 /// Picking — a `COPY_DST | MAP_READ` staging copy of `depth_buffer`
443 /// so the host can read back the per-pixel world-t after a frame
444 /// (e.g. click → which voxel). Same size as `depth_buffer`.
445 depth_readback: wgpu::Buffer,
446 /// TV.6 — global voxel-material palette (256 `MaterialGpu`, binding 16),
447 /// seeded from `scene_materials`, rewritten by [`GpuRenderer::set_scene_materials`].
448 materials_pal_buf: wgpu::Buffer,
449 /// TV.6 — terrain colour→material map (`[rgb, material_id]` rows, binding
450 /// 17); ≥1 element (wgpu rejects a zero-sized storage binding).
451 terrain_map_buf: wgpu::Buffer,
452 /// XS.4.3 — placeholder bound at the sprite-cast bindings (19..21) on a
453 /// capable device when no sprite registry exists (or this frame has no
454 /// sprites). `sprite_cast_count == 0` keeps the shader from indexing it.
455 /// `None` on non-capable devices (those bindings aren't in the BGL).
456 sprite_cast_dummy: Option<wgpu::Buffer>,
457}
458
459/// QE.8c — the renderer's cross-frame validity/dirty flags, grouped so
460/// their lifecycle rules live on the fields they guard instead of in
461/// comments scattered across three loose booleans (the QE review
462/// called those "discipline-only invariants").
463#[derive(Debug)]
464pub(crate) struct FrameDirty {
465 /// PF.5 — set when [`GpuRenderer::set_scene_lights`] stores a
466 /// *different* rig; the SCENE pass re-packs + re-uploads the grid
467 /// point lights only then, and clears it (a static rig costs
468 /// nothing per frame). Starts `true` so the first frame seeds.
469 pub(crate) scene_lights: bool,
470 /// PF.5 — like [`scene_lights`](Self::scene_lights) but cleared by
471 /// the SPRITE pass's world-light upload, which only runs when
472 /// sprites are visible — a lights change while no sprite is on
473 /// screen must stay dirty for the frame that finally draws one,
474 /// hence its own flag. Starts `true`.
475 pub(crate) sprite_lights: bool,
476 /// Whether the *current* deferred frame ran a scene pass that
477 /// wrote `scene_dda.depth_buffer`. `render_scene` sets it; the
478 /// color-only `render_clear_deferred` clears it. Depth-tested
479 /// overlays gate on it — without this they'd test against the
480 /// *previous* scene's stale depth and clip incorrectly.
481 pub(crate) scene_depth_valid: bool,
482}
483
484impl Default for FrameDirty {
485 fn default() -> Self {
486 Self {
487 scene_lights: true,
488 sprite_lights: true,
489 scene_depth_valid: false,
490 }
491 }
492}
493
494impl FrameDirty {
495 /// A new light rig arrived — both consumers must re-upload (each
496 /// clears only its own flag; see the field docs for why they are
497 /// separate).
498 pub(crate) fn mark_lights_changed(&mut self) {
499 self.scene_lights = true;
500 self.sprite_lights = true;
501 }
502}
503
504/// PF.4 — persistent per-frame pack state for `render_scene`: the per-grid
505/// camera + point-light storage buffers (previously `create_buffer_init`-ed
506/// EVERY frame, which also forced rebuilding the 22/23-entry bind groups
507/// every frame) plus the cached bind groups themselves.
508///
509/// Buffers are grow-only (pow2, like `line_vbuf`) with `COPY_DST`, updated
510/// via `queue.write_buffer`; wgpu zero-initialises fresh buffers, so the
511/// empty-scene "one zeroed element" padding of the old path is implicit.
512/// The shaders only index `0..grid_count` / `0..count*grid_count`, so stale
513/// bytes past the current write are never read.
514///
515/// Bind groups are cached against the exact resources they bound (wgpu 23+
516/// resources compare by identity): any regrow, scene-resident swap,
517/// `scene_dda` rebuild, sky replacement, or sprite-registry buffer growth
518/// changes some handle and misses the cache — no manual event tracking.
519struct FramePackBuffers {
520 grid_cameras: wgpu::Buffer,
521 grid_cameras_cap: u64,
522 point_lights: wgpu::Buffer,
523 point_lights_cap: u64,
524 /// World-space lights for the sprite pass (binding 15 there).
525 sprite_lights: wgpu::Buffer,
526 sprite_lights_cap: u64,
527 dda_bg: Option<CachedBindGroup>,
528 sprite_bg: Option<CachedBindGroup>,
529}
530
531/// A cached bind group plus the exact resources it bound, in binding order.
532/// Cheap to compare (identity) and to clone (refcounts).
533struct CachedBindGroup {
534 bufs: Vec<(u32, wgpu::Buffer)>,
535 views: Vec<(u32, wgpu::TextureView)>,
536 bg: wgpu::BindGroup,
537}
538
539impl FramePackBuffers {
540 fn new(device: &wgpu::Device) -> Self {
541 let mk = |label: &str, cap: u64| {
542 device.create_buffer(&wgpu::BufferDescriptor {
543 label: Some(label),
544 size: cap,
545 usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
546 mapped_at_creation: false,
547 })
548 };
549 // Seed capacities: a few grids' cameras / a few dozen lights — most
550 // scenes never regrow past these.
551 let cam_cap = 4 * 144;
552 let light_cap = 4096;
553 Self {
554 grid_cameras: mk("roxlap-gpu scene_dda.grid_cameras", cam_cap),
555 grid_cameras_cap: cam_cap,
556 point_lights: mk("roxlap-gpu scene_dda.grid_point_lights", light_cap),
557 point_lights_cap: light_cap,
558 sprite_lights: mk("roxlap-gpu sprite_model_dda.point_lights", light_cap),
559 sprite_lights_cap: light_cap,
560 dda_bg: None,
561 sprite_bg: None,
562 }
563 }
564
565 /// Write `bytes` into the selected persistent buffer, regrowing (pow2)
566 /// when capacity is exceeded. Regrowth replaces the buffer handle, which
567 /// the bind-group cache detects by identity on its next lookup.
568 fn write_grow(
569 device: &wgpu::Device,
570 queue: &wgpu::Queue,
571 buf: &mut wgpu::Buffer,
572 cap: &mut u64,
573 label: &str,
574 bytes: &[u8],
575 ) {
576 let needed = bytes.len() as u64;
577 if needed > *cap {
578 let new_cap = needed.next_power_of_two();
579 *buf = device.create_buffer(&wgpu::BufferDescriptor {
580 label: Some(label),
581 size: new_cap,
582 usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
583 mapped_at_creation: false,
584 });
585 *cap = new_cap;
586 }
587 if !bytes.is_empty() {
588 queue.write_buffer(buf, 0, bytes);
589 }
590 }
591
592 fn write_cameras(
593 &mut self,
594 device: &wgpu::Device,
595 queue: &wgpu::Queue,
596 cams: &[SceneDdaPerGridCamera],
597 ) {
598 Self::write_grow(
599 device,
600 queue,
601 &mut self.grid_cameras,
602 &mut self.grid_cameras_cap,
603 "roxlap-gpu scene_dda.grid_cameras",
604 bytemuck::cast_slice(cams),
605 );
606 }
607
608 fn write_point_lights(
609 &mut self,
610 device: &wgpu::Device,
611 queue: &wgpu::Queue,
612 lights: &[GpuPointLight],
613 ) {
614 Self::write_grow(
615 device,
616 queue,
617 &mut self.point_lights,
618 &mut self.point_lights_cap,
619 "roxlap-gpu scene_dda.grid_point_lights",
620 bytemuck::cast_slice(lights),
621 );
622 }
623
624 fn write_sprite_lights(
625 &mut self,
626 device: &wgpu::Device,
627 queue: &wgpu::Queue,
628 lights: &[GpuPointLight],
629 ) {
630 Self::write_grow(
631 device,
632 queue,
633 &mut self.sprite_lights,
634 &mut self.sprite_lights_cap,
635 "roxlap-gpu sprite_model_dda.point_lights",
636 bytemuck::cast_slice(lights),
637 );
638 }
639}
640
641/// PF.4 — return the cached bind group when it bound exactly `bufs` +
642/// `views` (identity compare), else build + cache a fresh one.
643/// `samplers` are bound but NOT part of the key: every sampler we bind
644/// (`sky_sampler`) is created once at init and never replaced
645/// (`set_sky_panorama` swaps the texture + view only).
646fn cached_bind_group<'a>(
647 slot: &'a mut Option<CachedBindGroup>,
648 device: &wgpu::Device,
649 label: &str,
650 layout: &wgpu::BindGroupLayout,
651 bufs: Vec<(u32, wgpu::Buffer)>,
652 views: Vec<(u32, wgpu::TextureView)>,
653 samplers: &[(u32, &wgpu::Sampler)],
654) -> &'a wgpu::BindGroup {
655 let hit = slot
656 .as_ref()
657 .is_some_and(|c| c.bufs == bufs && c.views == views);
658 if !hit {
659 let mut entries: Vec<wgpu::BindGroupEntry> = bufs
660 .iter()
661 .map(|(binding, b)| wgpu::BindGroupEntry {
662 binding: *binding,
663 resource: b.as_entire_binding(),
664 })
665 .collect();
666 entries.extend(views.iter().map(|(binding, v)| wgpu::BindGroupEntry {
667 binding: *binding,
668 resource: wgpu::BindingResource::TextureView(v),
669 }));
670 entries.extend(samplers.iter().map(|&(binding, s)| wgpu::BindGroupEntry {
671 binding,
672 resource: wgpu::BindingResource::Sampler(s),
673 }));
674 entries.sort_by_key(|e| e.binding);
675 let bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
676 label: Some(label),
677 layout,
678 entries: &entries,
679 });
680 *slot = Some(CachedBindGroup { bufs, views, bg });
681 }
682 &slot.as_ref().expect("just cached").bg
683}
684
685/// GPU.10.0 — single-sprite model-DDA pipeline: one thread per pixel
686/// marches the model voxel volume and composites against the scene
687/// depth buffer.
688struct SpriteModelDdaResources {
689 bgl: wgpu::BindGroupLayout,
690 pipeline: wgpu::ComputePipeline,
691 uniform_buf: wgpu::Buffer,
692 /// TV — global voxel-material palette (256 `MaterialGpu`, binding 12),
693 /// seeded from the renderer's `sprite_materials` and rewritten by
694 /// [`GpuRenderer::set_sprite_materials`].
695 materials_buf: wgpu::Buffer,
696}
697
698/// Per-frame uniform for the model-DDA pass. Mirrors `Uniform` in
699/// `sprite_model_dda.wgsl` (std140). Per-model + per-instance data
700/// now live in storage buffers; this holds only the camera, fog, and
701/// instance count.
702#[repr(C)]
703#[derive(Clone, Copy, Pod, Zeroable)]
704struct SpriteModelUniform {
705 cam_pos: [f32; 3],
706 _p0: f32,
707 cam_right: [f32; 3],
708 _p1: f32,
709 cam_down: [f32; 3],
710 _p2: f32,
711 cam_forward: [f32; 3],
712 _p3: f32,
713 fog_color: [f32; 4],
714 screen_size: [u32; 2],
715 instance_count: u32,
716 fog_far: f32,
717 fov_y_rad: f32,
718 tiles_x: u32,
719 tile_size: u32,
720 /// TV — 1 if any palette material is translucent: gates the shader's
721 /// accumulate path. 0 ⇒ the unchanged nearest-hit opaque path.
722 has_translucent: u32,
723 // ── DL.4 — dynamic lighting for sprites (world space; all-zero ⇒
724 // unchanged flat-lit sprites). No sprite shadows (deferred). ──
725 /// World-space unit direction TO the sun (xyz; w unused).
726 sun_dir: [f32; 4],
727 /// `rgb` = sun colour, `w` = sun intensity.
728 sun_color: [f32; 4],
729 /// `rgb` = ambient multiplier on the sprite's albedo, `w` unused.
730 ambient_color: [f32; 4],
731 /// bit0 = sun enabled, bit2 = dynamic lighting active (use the lit path).
732 sun_flags: u32,
733 point_light_count: u32,
734 /// EV — 1 if any palette material is emissive: gates the opaque
735 /// marcher's per-hit material fetch (an emissive-free palette never
736 /// touches the palette there).
737 has_emissive: u32,
738 _pad_dl: u32,
739 // ── DL.6 — stylized sprite lighting (cel + ramp + flat per voxel) ──
740 /// `rgb` = cool unlit end of the sun ramp; `w` unused.
741 shadow_tint: [f32; 4],
742 /// Cel band count; 0 = smooth.
743 style_bands: u32,
744 // ── XS.4.2 — GPU sprite-shadow (receive) params. Mirror the scene pass's
745 // paging + shadow uniform fields so the sprite pass's duplicated terrain
746 // occupancy march reads the exact same ABI. All zero ⇒ no sprite shadows
747 // (the capability fallback / pre-XS.4 path). ──
748 occ_num_pages: u32,
749 occ_page_words: u32,
750 grid_count: u32,
751 max_outer_steps: u32,
752 shadow_max_steps: u32,
753 shadow_bias: f32,
754 shadow_max_dist: f32,
755 /// Fraction of a caster's light removed in shadow (`in_shadow = 1 - this`).
756 shadow_strength: f32,
757 _pad_xs: [u32; 3],
758}
759
760/// GPU.10.3 — sprite screen-tile edge in pixels for instance binning.
761const SPRITE_TILE_SIZE: u32 = 16;
762
763/// One material in the GPU material palettes (scene binding 16, sprite
764/// binding 12). Mirrors `Mat` in `scene_dda.wgsl` / `sprite_model_dda.wgsl`
765/// (std430, 16 bytes). TV stage; EV.2 added `emissive`.
766#[repr(C)]
767#[derive(Clone, Copy, Pod, Zeroable)]
768struct MaterialGpu {
769 /// Opacity / additive intensity, normalised to `0..=1`.
770 alpha: f32,
771 /// [`roxlap_formats::material::BlendMode`] discriminant.
772 mode: u32,
773 /// EV.2 — pre-scaled emissive factor `(128 + (e >> 1)) / 128`
774 /// (~1.0×..2.0× over-bright), or `0.0` for a non-emissive material —
775 /// the shader gates on `> 0.0`. Pre-scaling host-side keeps the WGSL
776 /// branch to one multiply and matches the CPU's `emissive_shade`
777 /// fixed-point ladder.
778 emissive: f32,
779 _pad: u32,
780}
781
782/// Convert the global [`MaterialTable`](roxlap_formats::material::MaterialTable)
783/// into the GPU palette + flags of whether any material is non-opaque and
784/// whether any is emissive (the shader gates — an all-opaque emissive-free
785/// palette runs the unchanged first-hit path).
786fn material_palette(
787 table: &roxlap_formats::material::MaterialTable,
788) -> (Box<[MaterialGpu; 256]>, bool, bool) {
789 let mut out = Box::new(
790 [MaterialGpu {
791 alpha: 1.0,
792 mode: 0,
793 emissive: 0.0,
794 _pad: 0,
795 }; 256],
796 );
797 let mut any_translucent = false;
798 let mut any_emissive = false;
799 for (id, slot) in out.iter_mut().enumerate() {
800 let m = table.get(id as u8);
801 slot.alpha = f32::from(m.alpha) / 255.0;
802 slot.mode = u32::from(m.mode.as_u8());
803 // EV.2 — `0.0` = not emissive; else the CPU `emissive_shade`
804 // multiplier `(128 + (e >> 1)) / 128`.
805 slot.emissive = if m.emissive == 0 {
806 0.0
807 } else {
808 f32::from(128 + u16::from(m.emissive >> 1)) / 128.0
809 };
810 if !m.is_opaque() {
811 any_translucent = true;
812 }
813 if m.emissive > 0 {
814 any_emissive = true;
815 }
816 }
817 (out, any_translucent, any_emissive)
818}
819
820/// Build the per-grid camera storage buffer bound at `scene_dda.wgsl`
821/// binding 15 (read-only). One [`SceneDdaPerGridCamera`] per grid; the
822/// shader only indexes `0..grid_count`. An empty scene pads to one
823/// zeroed element (wgpu rejects a zero-sized storage binding). This
824/// replaces the old fixed `[…; 16]` uniform array, so a scene can hold
825/// any number of grids — the only ceiling is the device's storage size.
826fn upload_grid_cameras(device: &wgpu::Device, cams: &[SceneDdaPerGridCamera]) -> wgpu::Buffer {
827 use wgpu::util::DeviceExt;
828 let one = [SceneDdaPerGridCamera::zeroed()];
829 let src: &[SceneDdaPerGridCamera] = if cams.is_empty() { &one } else { cams };
830 device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
831 label: Some("roxlap-gpu scene_dda.grid_cameras"),
832 contents: bytemuck::cast_slice(src),
833 usage: wgpu::BufferUsages::STORAGE,
834 })
835}
836
837// The scene_dda bind group + layout wire occupancy pages 1..=3 at
838// bindings 12..=14 explicitly; keep that in lockstep with the page
839// count. Bump the bindings (here, in the WGSL, and in the bind
840// group) if MAX_OCC_PAGES changes.
841const _: () = assert!(scene::MAX_OCC_PAGES == 4);
842
843#[repr(C)]
844#[derive(Clone, Copy, Pod, Zeroable)]
845struct SceneDdaPerGridCamera {
846 pos: [f32; 3],
847 _pad0: f32,
848 right: [f32; 3],
849 _pad1: f32,
850 down: [f32; 3],
851 _pad2: f32,
852 forward: [f32; 3],
853 _pad3: f32,
854 /// DL — unit direction TO the sun in this grid's local frame (xyz; w
855 /// unused). Packed here rather than a separate per-grid storage buffer
856 /// because the device's `max_storage_buffers_per_shader_stage` (16) is
857 /// already saturated. Zero ⇒ no sun (the uniform's `sun_flags` gates).
858 sun_dir: [f32; 4],
859 /// XS.3 — this grid's world transform, for cross-grid shadows: a shadow
860 /// ray (grid-local in the grid being shaded) is lifted to world space and
861 /// tested against every grid. `world_origin` (xyz) is the grid origin;
862 /// `rot0/1/2` (xyz) are the local→world rotation columns (world images of
863 /// grid-local axes x/y/z). Packed here for the same buffer-limit reason.
864 world_origin: [f32; 4],
865 rot0: [f32; 4],
866 rot1: [f32; 4],
867 rot2: [f32; 4],
868}
869
870impl SceneDdaPerGridCamera {
871 fn from_camera(c: &Camera) -> Self {
872 Self {
873 pos: c.position,
874 _pad0: 0.0,
875 right: c.right,
876 _pad1: 0.0,
877 down: c.down,
878 _pad2: 0.0,
879 forward: c.forward,
880 _pad3: 0.0,
881 sun_dir: [0.0; 4],
882 // Identity world transform by default; the per-grid build
883 // (`grid_cameras`) overwrites it with the grid's real transform.
884 // SC.4 — `world_origin.w` is the grid's `voxel_world_size`;
885 // default 1.0 so the shader's `× vws` marcher scaling is identity
886 // even if `set_world_transform` is never called (never 0).
887 world_origin: [0.0, 0.0, 0.0, 1.0],
888 rot0: [1.0, 0.0, 0.0, 0.0],
889 rot1: [0.0, 1.0, 0.0, 0.0],
890 rot2: [0.0, 0.0, 1.0, 0.0],
891 }
892 }
893
894 /// XS.3 — stamp this grid's world transform (for cross-grid shadows).
895 /// `rot_cols[i]` is the world image of grid-local axis `i` (the
896 /// local→world rotation's columns).
897 fn set_world_transform(&mut self, t: &GridWorldTransform) {
898 // SC.4 — `.w` carries voxel_world_size (world units per voxel); the
899 // scene DDA marcher scales chunk_dim + vsize by it.
900 self.world_origin = [t.origin[0], t.origin[1], t.origin[2], t.voxel_world_size];
901 self.rot0 = [t.rot_cols[0][0], t.rot_cols[0][1], t.rot_cols[0][2], 0.0];
902 self.rot1 = [t.rot_cols[1][0], t.rot_cols[1][1], t.rot_cols[1][2], 0.0];
903 self.rot2 = [t.rot_cols[2][0], t.rot_cols[2][1], t.rot_cols[2][2], 0.0];
904 }
905}
906
907/// XS.3 — a grid's world transform for cross-grid shadows: world origin +
908/// the local→world rotation columns (`rot_cols[i]` = world image of grid-local
909/// axis `i`). Built host-side per frame from the grid's `GridTransform` and
910/// handed to `SceneRenderer::render_scene` alongside the per-grid cameras.
911#[derive(Clone, Copy)]
912pub struct GridWorldTransform {
913 /// World position of the grid's local origin, voxel units.
914 pub origin: [f32; 3],
915 /// Local→world rotation as columns: `rot_cols[i]` is the world
916 /// image of grid-local axis `i` (unit vectors for a pure rotation).
917 /// Identity for an unrotated grid.
918 pub rot_cols: [[f32; 3]; 3],
919 /// SC.4 — world units per voxel. The scene DDA marcher scales its
920 /// chunk/voxel cell dimensions by this, so a scaled grid renders,
921 /// shadows, and composites at its true world footprint. `1.0` for an
922 /// unscaled grid (byte-identical to pre-SC).
923 pub voxel_world_size: f32,
924}
925
926impl Default for GridWorldTransform {
927 fn default() -> Self {
928 Self {
929 origin: [0.0; 3],
930 rot_cols: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
931 voxel_world_size: 1.0,
932 }
933 }
934}
935
936#[repr(C)]
937#[derive(Clone, Copy, Pod, Zeroable)]
938struct SceneDdaUniform {
939 fov_y_rad: f32,
940 grid_count: u32,
941 max_outer_steps: u32,
942 _pad0: u32,
943 screen_size: [u32; 2],
944 _pad1: [u32; 2],
945 /// GPU.8 — `[r, g, b, fog_near]`. The `near` distance is packed
946 /// into the colour's alpha channel to keep std140 alignment
947 /// tidy (a bare `f32` after the `vec4` would force extra pads).
948 fog_color: [f32; 4],
949 fog_far: f32,
950 /// GPU.9 — `1` when the sprite pass is active (scene pass then
951 /// records `best_t` into the depth buffer), `0` otherwise.
952 write_depth: u32,
953 /// Occupancy paging: words per storage page (see
954 /// `scene::split_occupancy_pages`). Only consulted by the shader
955 /// when `occ_num_pages > 1`.
956 occ_page_words: u32,
957 /// Number of real occupancy pages (1 on multi-GiB GPUs → the
958 /// shader takes a branch-free single-page read).
959 occ_num_pages: u32,
960 /// GPU.11.1 — scene-grid LOD scan distance (world units). A chunk
961 /// entered at world-t `t` marches at mip
962 /// `floor(log2(max(t, msd) / msd))`, clamped to the grid's mip
963 /// count. `0` disables LOD (always mip-0).
964 mip_scan_dist: f32,
965 /// TV.6 — `1` if any mapped terrain material is translucent OR
966 /// emissive (EV.2) — gates the material lookup + accumulate path;
967 /// `0` ⇒ unchanged opaque first-hit march.
968 terrain_has_translucent: u32,
969 /// TV.6 — number of `(rgb, material_id)` entries in the terrain map.
970 terrain_map_count: u32,
971 _pad4: u32,
972 /// World camera used only to derive the per-pixel sky direction —
973 /// always valid, so a `grid_count == 0` (sprite-only / empty) scene
974 /// still paints a proper sky instead of a degenerate `(0,0,1)`
975 /// (whose `atan2(0,0)` sky lookup samples black).
976 sky_cam: SceneDdaPerGridCamera,
977 /// Per-face side-shade intensities (voxlap setsideshades), each the
978 /// u8 shade subtracted from a voxel's brightness byte at a hit.
979 /// `side_shades0 = (top, bot, left, right)`,
980 /// `side_shades1 = (up, down, _, _)`. All-zero = no shading.
981 side_shades0: [i32; 4],
982 side_shades1: [i32; 4],
983 // ── DL — dynamic lighting (appended; all-zero ⇒ pre-DL render) ──
984 /// `rgb` = sun colour, `w` = sun intensity.
985 sun_color: [f32; 4],
986 /// `rgb` = ambient multiplier on the baked byte, `w` = shadow strength.
987 ambient_color: [f32; 4],
988 /// Bit 0 = sun enabled, bit 1 = sun casts shadow.
989 sun_flags: u32,
990 /// Number of point lights per grid (rows in the binding-18 buffer).
991 point_light_count: u32,
992 /// Shadow-ray step budget (DL.3).
993 shadow_max_steps: u32,
994 _pad5: u32,
995 /// Shadow-ray origin bias along the surface normal (voxel units).
996 shadow_bias: f32,
997 /// Sun shadow-ray length cap (world units).
998 shadow_max_dist: f32,
999 _pad6: [f32; 2],
1000 /// DL.6 — stylized ramp's cool shadow tint (rgb; w unused).
1001 shadow_tint: [f32; 4],
1002 /// DL.6 — cel band count; 0 = smooth (no banding / gradient map).
1003 style_bands: u32,
1004 /// XS.4.3 — visible sprite-instance count for the scene pass's
1005 /// sprite-cast shadow march (sprites cast onto terrain). `0` ⇒ no sprite
1006 /// casters (the loop is skipped); only consulted by the capable variant.
1007 sprite_cast_count: u32,
1008 _pad7: [u32; 2],
1009}
1010
1011impl GpuRenderer {
1012 /// Stand up the device + surface + swapchain on `window`. Async
1013 /// because `wgpu::Adapter`/`Device` requests are.
1014 ///
1015 /// `window` is any [`raw-window-handle`] provider (winit, SDL,
1016 /// GLFW, …) wrapped in an `Arc`; `size` is its initial physical
1017 /// framebuffer size in pixels — passed explicitly so the renderer
1018 /// stays decoupled from any one windowing library's size API.
1019 ///
1020 /// [`raw-window-handle`]: raw_window_handle
1021 ///
1022 /// # Errors
1023 /// Returns [`GpuInitError`] if surface creation, adapter
1024 /// selection, or device request fails. Hosts treat any error as
1025 /// "fall back to the CPU path".
1026 pub async fn new<W>(
1027 window: Arc<W>,
1028 size: (u32, u32),
1029 settings: GpuRendererSettings,
1030 ) -> Result<Self, GpuInitError>
1031 where
1032 W: HasWindowHandle + HasDisplayHandle + Send + Sync + 'static,
1033 {
1034 let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
1035 let surface = instance.create_surface(window.clone())?;
1036 let adapter = Self::request_adapter(&instance, Some(&surface), settings).await?;
1037 let (device, queue) = Self::request_device(&adapter).await?;
1038 Ok(Self::finish_init(
1039 &adapter, device, queue, surface, size, settings,
1040 ))
1041 }
1042
1043 /// wasm/WebGPU: build the renderer against an HTML `canvas`. No
1044 /// `Send + Sync` bound — wgpu's surface/device/queue are `!Send` on
1045 /// the `+atomics` shared-memory wasm build, and the browser host is
1046 /// single-threaded (`Rc<RefCell<…>>`). The native generic-`W` entry
1047 /// (which carries the bound) isn't reachable on wasm.
1048 ///
1049 /// Probes for an adapter **before** `create_surface`: on wasm,
1050 /// creating the surface calls `canvas.getContext("webgpu")`, which
1051 /// permanently locks the canvas's context type. If we bound it and
1052 /// then found no adapter, a CPU/WebGL2 fallback on the *same* canvas
1053 /// (the facade clones the handle, but it's the same DOM element)
1054 /// would fail with "no webgl2 context". Probing first leaves the
1055 /// canvas pristine when WebGPU is unavailable.
1056 ///
1057 /// # Errors
1058 /// See [`Self::new`].
1059 #[cfg(target_arch = "wasm32")]
1060 pub async fn new_from_canvas(
1061 canvas: web_sys::HtmlCanvasElement,
1062 size: (u32, u32),
1063 settings: GpuRendererSettings,
1064 ) -> Result<Self, GpuInitError> {
1065 let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
1066 // Probe adapter AND device before binding the canvas — both
1067 // `requestAdapter` and `requestDevice` can fail on wasm, and
1068 // `create_surface` permanently locks the canvas to a WebGPU
1069 // context. Creating the surface last keeps the canvas pristine
1070 // for the CPU/WebGL2 fallback on any GPU-init failure.
1071 let adapter = Self::request_adapter(&instance, None, settings).await?;
1072 let (device, queue) = Self::request_device(&adapter).await?;
1073 let surface = instance.create_surface(wgpu::SurfaceTarget::Canvas(canvas))?;
1074 Ok(Self::finish_init(
1075 &adapter, device, queue, surface, size, settings,
1076 ))
1077 }
1078
1079 /// Pick a GPU adapter at the settings' power preference. `None`
1080 /// `compatible_surface` is used on the wasm canvas path so the probe
1081 /// doesn't bind the canvas's context (see [`Self::new_from_canvas`]);
1082 /// WebGPU exposes a single surface-independent adapter, so this is
1083 /// safe there.
1084 async fn request_adapter(
1085 instance: &wgpu::Instance,
1086 compatible_surface: Option<&wgpu::Surface<'static>>,
1087 settings: GpuRendererSettings,
1088 ) -> Result<wgpu::Adapter, GpuInitError> {
1089 // QE-C6: this crate reads no environment — the render facade
1090 // (roxlap-render's `env_config`) resolves the `ROXLAP_GPU_POWER`
1091 // escape hatch into `settings.power_preference` before init.
1092 // `Low` matters on broken hybrid-GPU (PRIME) driver stacks,
1093 // where rendering on the display-owning iGPU avoids the
1094 // cross-GPU present entirely. (A nixos mesa update deadlocked
1095 // the nouveau↔i915 explicit-sync fences: dGPU frames hit the
1096 // drm job timeout and the channel was killed; `low` kept the
1097 // demo alive.)
1098 let power_preference = match settings.power_preference {
1099 PowerPreference::Low => wgpu::PowerPreference::LowPower,
1100 PowerPreference::High => wgpu::PowerPreference::HighPerformance,
1101 };
1102 instance
1103 .request_adapter(&wgpu::RequestAdapterOptions {
1104 power_preference,
1105 compatible_surface,
1106 force_fallback_adapter: false,
1107 })
1108 .await
1109 .map_err(|_| GpuInitError::NoAdapter)
1110 }
1111
1112 /// Request the device + queue from `adapter`. Pulled out of
1113 /// [`Self::finish_init`] so the wasm canvas path can validate the
1114 /// device **before** `create_surface` binds the canvas's WebGPU
1115 /// context — if the device request fails (e.g. a browser that
1116 /// rejects a wgpu-sent limit), the canvas stays pristine for the
1117 /// CPU/WebGL2 fallback instead of being poisoned.
1118 async fn request_device(
1119 adapter: &wgpu::Adapter,
1120 ) -> Result<(wgpu::Device, wgpu::Queue), GpuInitError> {
1121 Ok(adapter
1122 .request_device(&wgpu::DeviceDescriptor {
1123 label: Some("roxlap-gpu device"),
1124 required_features: wgpu::Features::empty(),
1125 required_limits: pick_required_limits(&adapter.limits()),
1126 experimental_features: wgpu::ExperimentalFeatures::disabled(),
1127 memory_hints: wgpu::MemoryHints::default(),
1128 trace: wgpu::Trace::Off,
1129 })
1130 .await?)
1131 }
1132
1133 /// Shared swapchain → sky/sampler setup, run after the adapter +
1134 /// device + surface exist (the surface comes from a window handle on
1135 /// native, or an HTML canvas on wasm — created last on wasm so a
1136 /// failed device request never touches the canvas).
1137 fn finish_init(
1138 adapter: &wgpu::Adapter,
1139 device: wgpu::Device,
1140 queue: wgpu::Queue,
1141 surface: wgpu::Surface<'static>,
1142 size: (u32, u32),
1143 settings: GpuRendererSettings,
1144 ) -> Self {
1145 let info = adapter.get_info();
1146 let adapter_info = format!(
1147 "{name} ({backend:?}, {device_type:?})",
1148 name = info.name,
1149 backend = info.backend,
1150 device_type = info.device_type,
1151 );
1152 let low_power = info.device_type != wgpu::DeviceType::DiscreteGpu;
1153
1154 let caps = surface.get_capabilities(adapter);
1155 // Pick a NON-sRGB, 8-bit swapchain format. Voxlap colours are
1156 // already sRGB-encoded (the slab bytes are display-ready,
1157 // matching what the CPU softbuffer path writes straight to the
1158 // framebuffer with no conversion); an sRGB swapchain would
1159 // re-apply the gamma curve, washing the look out. We also
1160 // *prefer 8-bit BGRA/RGBA* over any other non-sRGB format: some
1161 // adapters (e.g. NVK) advertise a 16-bit-unorm format first,
1162 // and wgpu 29 gates `create_view` on 16-bit-norm formats behind
1163 // the `TEXTURE_FORMAT_16BIT_NORM` device feature (which we don't
1164 // enable, to stay WebGPU-portable). Falls back to the first
1165 // non-sRGB format, then `caps.formats[0]`.
1166 let surface_format = caps
1167 .formats
1168 .iter()
1169 .copied()
1170 .find(|f| {
1171 matches!(
1172 f,
1173 wgpu::TextureFormat::Bgra8Unorm | wgpu::TextureFormat::Rgba8Unorm
1174 )
1175 })
1176 .or_else(|| caps.formats.iter().copied().find(|f| !f.is_srgb()))
1177 .unwrap_or(caps.formats[0]);
1178 let present_mode = if settings.uncapped_present {
1179 pick_present_mode(&caps.present_modes)
1180 } else {
1181 wgpu::PresentMode::Fifo
1182 };
1183 // GPU.11.2 — surface the present mode: `Fifo` is vsync-capped
1184 // (FPS pinned to refresh rate → compute optimisations like the
1185 // mip LOD won't show up in the FPS counter). Mailbox/Immediate
1186 // are uncapped. Wayland under Mesa frequently offers only Fifo.
1187 eprintln!(
1188 "roxlap-gpu: present mode = {present_mode:?} (available: {:?})",
1189 caps.present_modes,
1190 );
1191 let (init_w, init_h) = size;
1192 let surface_config = wgpu::SurfaceConfiguration {
1193 usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
1194 format: surface_format,
1195 width: init_w.max(1),
1196 height: init_h.max(1),
1197 present_mode,
1198 alpha_mode: caps.alpha_modes[0],
1199 view_formats: vec![],
1200 desired_maximum_frame_latency: 2,
1201 };
1202 surface.configure(&device, &surface_config);
1203
1204 // GPU.8 default sky: a 1×1 mid-grey texture. Hosts replace
1205 // it via `set_sky_panorama` with a real equirectangular
1206 // panorama; the default stops the shader sampling
1207 // uninitialised memory before that happens.
1208 let default_sky_pixel = [0x80u8, 0x80, 0x80, 0xff];
1209 let (sky_texture, sky_view) = create_sky_texture(&device, 1, 1, &default_sky_pixel);
1210 queue.write_texture(
1211 wgpu::TexelCopyTextureInfo {
1212 texture: &sky_texture,
1213 mip_level: 0,
1214 origin: wgpu::Origin3d::ZERO,
1215 aspect: wgpu::TextureAspect::All,
1216 },
1217 &default_sky_pixel,
1218 wgpu::TexelCopyBufferLayout {
1219 offset: 0,
1220 bytes_per_row: Some(4),
1221 rows_per_image: Some(1),
1222 },
1223 wgpu::Extent3d {
1224 width: 1,
1225 height: 1,
1226 depth_or_array_layers: 1,
1227 },
1228 );
1229 let sky_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
1230 label: Some("roxlap-gpu sky_sampler"),
1231 // Voxlap-convention panorama: u = elevation [0, 1]
1232 // (Repeat is a no-op since values don't go outside),
1233 // v = azimuth (wraps 360° — Repeat is required).
1234 address_mode_u: wgpu::AddressMode::Repeat,
1235 address_mode_v: wgpu::AddressMode::Repeat,
1236 address_mode_w: wgpu::AddressMode::ClampToEdge,
1237 mag_filter: wgpu::FilterMode::Linear,
1238 min_filter: wgpu::FilterMode::Linear,
1239 mipmap_filter: wgpu::MipmapFilterMode::Nearest,
1240 ..Default::default()
1241 });
1242
1243 // XS.4 — did the device grant enough storage buffers per stage for the
1244 // GPU sprite-shadow cross-pass bindings? If not, sprites render
1245 // unshadowed (the CPU backend still has full sprite shadows).
1246 let sprite_shadows_capable = device.limits().max_storage_buffers_per_shader_stage
1247 >= SPRITE_SHADOW_MIN_STORAGE_BUFFERS;
1248
1249 Self {
1250 surface,
1251 surface_config,
1252 device,
1253 queue,
1254 adapter_info,
1255 low_power,
1256 clear_colour: settings.clear_colour,
1257 frame_count: 0,
1258 flip_x: false,
1259 render_res: RenderResolution::Native,
1260 ssaa: 1,
1261 posterize: None,
1262 scene_dda: None,
1263 scene_materials: Box::new(
1264 [MaterialGpu {
1265 alpha: 1.0,
1266 mode: 0,
1267 emissive: 0.0,
1268 _pad: 0,
1269 }; 256],
1270 ),
1271 scene_terrain_map: Vec::new(),
1272 scene_terrain_translucent: false,
1273 dirty: FrameDirty::default(),
1274 sky_texture,
1275 sky_view,
1276 sky_sampler,
1277 // Fog disabled by default — voxlap's CPU rasterizer
1278 // also runs without fog in the scene-demo, so matching
1279 // it means no GPU fog out of the box. Hosts can opt in
1280 // via `set_fog` (e.g. for atmospheric far-LOD masking).
1281 fog_color: [0.66, 0.74, 0.88],
1282 fog_near: 0.0,
1283 fog_far: 1.0e30,
1284 sprite_registry: None,
1285 sprite_model_dda: None,
1286 sprite_shadows_capable,
1287 sprite_materials: Box::new(
1288 [MaterialGpu {
1289 alpha: 1.0,
1290 mode: 0,
1291 emissive: 0.0,
1292 _pad: 0,
1293 }; 256],
1294 ),
1295 sprite_has_translucent: false,
1296 sprite_has_emissive: false,
1297 // GPU.10.4 — default LOD threshold: step to a coarser mip
1298 // once a voxel projects below 4 px. Empirically the best
1299 // quality/cost tradeoff; the host can override.
1300 sprite_lod_px: 1.0,
1301 // GPU.11.1 — matches the CPU demo's mip_scan_dist=64.
1302 scene_mip_scan_dist: 64.0,
1303 scene_side_shades: [[0; 4]; 2],
1304 scene_lights: SceneLights::default(),
1305 lights_sun_flags: 0,
1306 lights_point_count: 0,
1307 lights_packed_grids: 0,
1308 last_fov_y_rad: 0.0,
1309 pending_frame: None,
1310 frame_pack: None,
1311 line_resources: None,
1312 line_vbuf: None,
1313 line_vbuf_cap: 0,
1314 line_bg_cache: None,
1315 image_resources: None,
1316 image_vbuf: None,
1317 image_vbuf_cap: 0,
1318 image_bg_cache: std::collections::HashMap::new(),
1319 image_bg_depth: None,
1320 images: Vec::new(),
1321 #[cfg(feature = "hud")]
1322 egui_renderer: None,
1323 }
1324 }
1325
1326 /// Synchronous wrapper for hosts that don't have an async
1327 /// runtime. Internally `pollster::block_on`s [`Self::new`].
1328 ///
1329 /// # Errors
1330 /// See [`Self::new`].
1331 #[cfg(not(target_arch = "wasm32"))]
1332 pub fn new_blocking<W>(
1333 window: Arc<W>,
1334 size: (u32, u32),
1335 settings: GpuRendererSettings,
1336 ) -> Result<Self, GpuInitError>
1337 where
1338 W: HasWindowHandle + HasDisplayHandle + Send + Sync + 'static,
1339 {
1340 pollster::block_on(Self::new(window, size, settings))
1341 }
1342
1343 /// Human-readable adapter description — name + backend +
1344 /// device type. The demo host prints this in the title bar.
1345 pub fn adapter_info(&self) -> &str {
1346 &self.adapter_info
1347 }
1348
1349 /// `true` when the adapter is NOT a discrete GPU (integrated,
1350 /// software rasterizer, virtual, unknown) — a hint that hosts
1351 /// should default to a lighter render resolution.
1352 pub fn low_power(&self) -> bool {
1353 self.low_power
1354 }
1355
1356 /// Borrow the underlying wgpu device — hosts use this to build
1357 /// chunk uploads (`GpuChunkResident::upload(gpu.device(), …)`).
1358 pub fn device(&self) -> &wgpu::Device {
1359 &self.device
1360 }
1361
1362 /// XS.4 — whether this device can run GPU sprite shadows (it granted
1363 /// enough storage buffers per shader stage for the cross-pass occupancy
1364 /// bindings). `false` ⇒ GPU sprites render unshadowed; the CPU backend
1365 /// always has sprite shadows. Lets the facade/host report the fallback.
1366 #[must_use]
1367 pub fn sprite_shadows_capable(&self) -> bool {
1368 self.sprite_shadows_capable
1369 }
1370
1371 /// Borrow the wgpu queue — hosts use this for read-back paths
1372 /// (`GpuChunkResident::read_voxel_blocking(gpu.device(), gpu.queue(), …)`).
1373 pub fn queue(&self) -> &wgpu::Queue {
1374 &self.queue
1375 }
1376
1377 /// GPU.8 — upload an equirectangular panorama as the scene's
1378 /// sky texture. `rgba` is row-major, `width × height` pixels,
1379 /// 4 bytes per pixel (R, G, B, A). The shader samples it with
1380 /// `u = atan2(dir.x, dir.y) / (2π) + 0.5` (azimuth) and
1381 /// `v = acos(-dir.z) / π` (elevation), matching standard
1382 /// equirectangular layout (top of image = zenith for voxlap's
1383 /// `+z = down` basis).
1384 /// Mirror the marched scene (and its line/image overlays) horizontally
1385 /// on present, leaving the egui overlay upright. See `Self::flip_x`.
1386 pub fn set_flip_x(&mut self, flip: bool) {
1387 self.flip_x = flip;
1388 }
1389
1390 ///
1391 /// # Panics
1392 /// If `rgba.len() != (width * height * 4) as usize`.
1393 pub fn set_sky_panorama(&mut self, rgba: &[u8], width: u32, height: u32) {
1394 assert_eq!(
1395 rgba.len(),
1396 (width as usize) * (height as usize) * 4,
1397 "set_sky_panorama: expected w*h*4 bytes, got {}",
1398 rgba.len(),
1399 );
1400 let (tex, view) = create_sky_texture(&self.device, width, height, rgba);
1401 // Upload pixel data via `queue.write_texture` so we don't
1402 // have to map the buffer manually.
1403 self.queue.write_texture(
1404 wgpu::TexelCopyTextureInfo {
1405 texture: &tex,
1406 mip_level: 0,
1407 origin: wgpu::Origin3d::ZERO,
1408 aspect: wgpu::TextureAspect::All,
1409 },
1410 rgba,
1411 wgpu::TexelCopyBufferLayout {
1412 offset: 0,
1413 bytes_per_row: Some(width * 4),
1414 rows_per_image: Some(height),
1415 },
1416 wgpu::Extent3d {
1417 width,
1418 height,
1419 depth_or_array_layers: 1,
1420 },
1421 );
1422 self.sky_texture = tex;
1423 self.sky_view = view;
1424 }
1425
1426 /// GPU.8 — set the fog blend. `color` is per-channel [0, 1];
1427 /// `near`/`far` are world-space ray distances in voxel units.
1428 /// Hits with `t < near` show their full colour; hits with
1429 /// `t > far` show `color` exclusively; in between is a
1430 /// smoothstep blend.
1431 pub fn set_fog(&mut self, color: [f32; 3], near: f32, far: f32) {
1432 self.fog_color = color;
1433 self.fog_near = near;
1434 self.fog_far = far.max(near + 1.0);
1435 }
1436
1437 /// Re-configure the swapchain to a new physical size. Call from
1438 /// `WindowEvent::Resized`. The scene resources rebuild lazily at
1439 /// the new size on the next [`Self::render_scene`].
1440 pub fn resize(&mut self, width: u32, height: u32) {
1441 if width == 0 || height == 0 {
1442 return;
1443 }
1444 self.surface_config.width = width;
1445 self.surface_config.height = height;
1446 self.surface.configure(&self.device, &self.surface_config);
1447 self.scene_dda = None;
1448 }
1449
1450 /// RP.0 — set the logical render resolution. Rebuilds the scene-DDA
1451 /// resources on the next [`Self::render_scene`] when the render size
1452 /// changes.
1453 pub fn set_render_resolution(&mut self, res: RenderResolution) {
1454 self.render_res = res;
1455 }
1456
1457 /// RP.1 — set the supersampling factor (clamped to `1..=4`). `1` = off.
1458 pub fn set_ssaa(&mut self, factor: u8) {
1459 self.ssaa = u32::from(factor).clamp(1, 4);
1460 }
1461
1462 /// RP.2 — set (or clear) the posterize post. Applied per-frame via the
1463 /// resolve uniform, so no pipeline rebuild is needed.
1464 pub fn set_posterize(&mut self, cfg: Option<PosterizeGpu>) {
1465 self.posterize = cfg;
1466 }
1467
1468 /// RP.0 — the logical (retro) grid size the scene resolves to before the
1469 /// upscale, resolved against the swapchain size. `logical_dims ==
1470 /// surface_dims` under [`RenderResolution::Native`].
1471 #[must_use]
1472 pub fn logical_dims(&self) -> (u32, u32) {
1473 self.render_res.logical_for(self.surface_dims())
1474 }
1475
1476 /// RP.1 — the resolution the scene/sprite passes actually march at:
1477 /// `logical_dims × ssaa`. The framebuffer + depth buffer are sized to this.
1478 #[must_use]
1479 pub fn render_dims(&self) -> (u32, u32) {
1480 let (lw, lh) = self.logical_dims();
1481 (lw * self.ssaa, lh * self.ssaa)
1482 }
1483
1484 /// RP.0 — the swapchain (native window) size.
1485 #[must_use]
1486 pub fn surface_dims(&self) -> (u32, u32) {
1487 (self.surface_config.width, self.surface_config.height)
1488 }
1489
1490 /// Acquire the next swapchain frame, or `None` to skip this frame.
1491 /// wgpu 29's `get_current_texture` returns a
1492 /// [`wgpu::CurrentSurfaceTexture`] status enum (was
1493 /// `Result<_, SurfaceError>`): an outdated/lost surface reconfigures
1494 /// and skips, transient statuses just skip.
1495 fn acquire_frame(&self) -> Option<wgpu::SurfaceTexture> {
1496 use wgpu::CurrentSurfaceTexture as C;
1497 match self.surface.get_current_texture() {
1498 C::Success(t) | C::Suboptimal(t) => Some(t),
1499 C::Outdated | C::Lost => {
1500 self.surface.configure(&self.device, &self.surface_config);
1501 None
1502 }
1503 C::Timeout | C::Occluded | C::Validation => None,
1504 }
1505 }
1506
1507 /// GPU.1 render: single render pass clearing the swapchain to a
1508 /// slowly drifting colour, then presenting. Voxels arrive in
1509 /// GPU.3+.
1510 pub fn render(&mut self) {
1511 let Some(surf_tex) = self.acquire_frame() else {
1512 return;
1513 };
1514 let view = surf_tex
1515 .texture
1516 .create_view(&wgpu::TextureViewDescriptor::default());
1517
1518 // Slow colour drift so the user can tell the GPU path is
1519 // actually presenting frames vs. e.g. a frozen window.
1520 // Wrap at 2π/0.005 frames (~1257) so the cast stays exact.
1521 let phase = f64::from(self.frame_count % 1257) * 0.005;
1522 let [r, g, b] = self.clear_colour;
1523 let drift = (phase.sin() * 0.04 + 0.04).clamp(0.0, 0.1);
1524 let clear = wgpu::Color {
1525 r: (r + drift).clamp(0.0, 1.0),
1526 g: (g + drift * 0.5).clamp(0.0, 1.0),
1527 b: (b + drift * 0.25).clamp(0.0, 1.0),
1528 a: 1.0,
1529 };
1530
1531 let mut encoder = self
1532 .device
1533 .create_command_encoder(&wgpu::CommandEncoderDescriptor {
1534 label: Some("roxlap-gpu encoder"),
1535 });
1536 {
1537 let _rp = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1538 label: Some("roxlap-gpu clear"),
1539 color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1540 view: &view,
1541 depth_slice: None,
1542 resolve_target: None,
1543 ops: wgpu::Operations {
1544 load: wgpu::LoadOp::Clear(clear),
1545 store: wgpu::StoreOp::Store,
1546 },
1547 })],
1548 depth_stencil_attachment: None,
1549 timestamp_writes: None,
1550 occlusion_query_set: None,
1551 multiview_mask: None,
1552 });
1553 }
1554 self.queue.submit(std::iter::once(encoder.finish()));
1555 surf_tex.present();
1556 self.frame_count = self.frame_count.wrapping_add(1);
1557 }
1558
1559 /// GPU.5 render — multi-grid scene marcher. `cameras[i]` is the
1560 /// world camera transformed into grid `i`'s local frame
1561 /// (caller-supplied; see scene-demo's `redraw_gpu` for the
1562 /// glam-based transform). `fov_y_rad` is the shared vertical
1563 /// FOV; `max_outer_steps` caps per-ray chunk-DDA work for each
1564 /// grid.
1565 ///
1566 /// # Panics
1567 /// If `cameras.len() != scene.grid_count`.
1568 /// `cameras[i]` is grid `i`'s world camera transformed into that
1569 /// grid's local frame (the grid marcher works in grid-local space).
1570 /// `sprite_camera` is the **world** camera: instanced sprites carry
1571 /// world-space positions/transforms, so they must project through
1572 /// the untransformed world camera — not `cameras[0]`, which is only
1573 /// the world camera when grid 0 is at identity.
1574 pub fn render_scene(
1575 &mut self,
1576 scene: &GpuSceneResident,
1577 cameras: &[Camera],
1578 // XS.3 — per-grid world transforms (parallel to `cameras`) for
1579 // cross-grid shadows. Empty ⇒ identity (shadows stay intra-grid).
1580 grid_world: &[GridWorldTransform],
1581 sprite_camera: &Camera,
1582 fov_y_rad: f32,
1583 max_outer_steps: u32,
1584 ) {
1585 assert_eq!(
1586 cameras.len(),
1587 scene.grid_count as usize,
1588 "render_scene: {} cameras supplied, scene has {} grids",
1589 cameras.len(),
1590 scene.grid_count,
1591 );
1592 self.last_fov_y_rad = fov_y_rad; // cached for pixel_ray (picking)
1593
1594 // Deferred present: drop any frame a prior render left
1595 // un-presented (a host that skipped present/paint_egui) so we
1596 // never hold two outstanding swapchain textures.
1597 self.pending_frame = None;
1598 let Some(surf_tex) = self.acquire_frame() else {
1599 return;
1600 };
1601 let surf_view = surf_tex
1602 .texture
1603 .create_view(&wgpu::TextureViewDescriptor::default());
1604
1605 let surface_w = self.surface_config.width;
1606 let surface_h = self.surface_config.height;
1607 let surface_format = self.surface_config.format;
1608 // RP.0/RP.1 — the scene + sprite + depth passes march at the *render*
1609 // size (`logical × ssaa`); a resolve pass box-downfilters to the
1610 // logical grid; the blit nearest-upscales to the swapchain. The
1611 // framebuffer/depth/occupancy + per-pixel projection key off the render
1612 // (march) size. `Native` + `ssaa==1` ⇒ render == logical == surface.
1613 let (logical_w, logical_h) = self.logical_dims();
1614 let (render_w, render_h) = self.render_dims();
1615
1616 let needs_build = match &self.scene_dda {
1617 Some(r) => {
1618 r.storage_size != (render_w, render_h) || r.logical_size != (logical_w, logical_h)
1619 }
1620 None => true,
1621 };
1622 if needs_build {
1623 self.scene_dda = Some(self.build_scene_dda(
1624 render_w,
1625 render_h,
1626 logical_w,
1627 logical_h,
1628 surface_w,
1629 surface_h,
1630 surface_format,
1631 ));
1632 }
1633 // GPU.9 — materialise the sprite pipeline the first frame
1634 // sprites are present (before the immutable `dda` borrow).
1635 // GPU.10.0 — build the model-DDA pipeline the first frame a
1636 // sprite registry is present.
1637 if self.sprite_registry.is_some() && self.sprite_model_dda.is_none() {
1638 self.sprite_model_dda = Some(self.build_sprite_model_dda());
1639 }
1640 // GPU.10.3 — frustum-cull + screen-tile-bin the sprite instances
1641 // (needs &mut self for buffer growth, so before the immutable
1642 // scene_dda borrow). Captures (visible_count, tiles_x); None when
1643 // nothing is in view.
1644 let sprite_pass: Option<(u32, u32)> = if let Some(reg) = self.sprite_registry.as_mut() {
1645 if reg.instance_capacity > 0 {
1646 // World camera — sprite positions/transforms are world-
1647 // space (independent of any grid's transform).
1648 let cam = sprite_camera;
1649 // Aspect + tile binning are in render (logical) space — the
1650 // sprite pass writes the render-sized framebuffer/depth.
1651 #[allow(clippy::cast_precision_loss)]
1652 let aspect = render_w as f32 / render_h as f32;
1653 let half_h = (fov_y_rad * 0.5).tan();
1654 let frustum = sprite_model::ViewFrustum {
1655 pos: cam.position,
1656 right: cam.right,
1657 down: cam.down,
1658 forward: cam.forward,
1659 half_w: half_h * aspect,
1660 half_h,
1661 far: 1.0e9,
1662 };
1663 let (visible, tiles_x, _tiles_y) = reg.cull_bin_upload(
1664 &self.device,
1665 &self.queue,
1666 &frustum,
1667 render_w,
1668 render_h,
1669 SPRITE_TILE_SIZE,
1670 self.sprite_lod_px,
1671 );
1672 (visible > 0).then_some((visible, tiles_x))
1673 } else {
1674 None
1675 }
1676 } else {
1677 None
1678 };
1679 let dda = self.scene_dda.as_ref().expect("just built");
1680
1681 // Refresh the blit's flip flag each frame (offset 16, after the
1682 // src + dst vec2 sizes), so toggling the flip applies without a
1683 // resize. The src/dst sizes themselves are written at build time
1684 // (a render/surface size change forces a rebuild).
1685 self.queue.write_buffer(
1686 &dda.blit_dims,
1687 16,
1688 bytemuck::bytes_of(&[u32::from(self.flip_x), 0u32]),
1689 );
1690 // RP.2 — refresh the resolve pass's posterize fields each frame (offset
1691 // 20, after src/dst dims + ssaa). `None` ⇒ `levels = [1,1,1]`, `dither
1692 // = 0` ⇒ the resolve does box-downfilter only (RP.1).
1693 let (plevels, pdither) = match self.posterize {
1694 Some(p) => (p.levels, p.dither),
1695 None => ([1u32; 3], 0u32),
1696 };
1697 self.queue.write_buffer(
1698 &dda.resolve_dims,
1699 20,
1700 bytemuck::bytes_of(&[plevels[0], plevels[1], plevels[2], pdither]),
1701 );
1702
1703 // Pack per-grid cameras into a runtime-sized storage buffer
1704 // (binding 15) — no fixed cap on grid count.
1705 let mut cam_vec: Vec<SceneDdaPerGridCamera> = cameras
1706 .iter()
1707 .map(SceneDdaPerGridCamera::from_camera)
1708 .collect();
1709 // XS.3 — stamp each grid's world transform for cross-grid shadows.
1710 for (c, t) in cam_vec.iter_mut().zip(grid_world.iter()) {
1711 c.set_world_transform(t);
1712 }
1713
1714 // DL — pack the per-frame lights (already grid-local). The per-grid
1715 // sun direction rides in each `PerGridCamera.sun_dir` (binding 15);
1716 // point lights go in one storage buffer (binding 18). All-zero
1717 // ⇒ the pre-DL render. Shared with the headless path.
1718 // PF.4 — pack CPU-side (no clone of `scene_lights`), then write into
1719 // the persistent grow-only buffers instead of `create_buffer_init`-ing
1720 // fresh ones (which also forced a bind-group rebuild) every frame.
1721 if self.frame_pack.is_none() {
1722 self.frame_pack = Some(FramePackBuffers::new(&self.device));
1723 }
1724 let lights = &self.scene_lights;
1725 // Sun dirs ride in the per-frame camera vector — inject every frame.
1726 inject_grid_sun_dirs(lights, &mut cam_vec);
1727 let fp = self.frame_pack.as_mut().expect("just built");
1728 fp.write_cameras(&self.device, &self.queue, &cam_vec);
1729 // PF.5 — re-pack + re-upload the grid-major point lights only when
1730 // the rig changed (or the grid count did — the rows depend on it).
1731 if self.dirty.scene_lights || self.lights_packed_grids != scene.grid_count {
1732 let (packed_lights, sun_flags, point_count) =
1733 pack_scene_lights(lights, scene.grid_count as usize);
1734 fp.write_point_lights(&self.device, &self.queue, &packed_lights);
1735 self.lights_sun_flags = sun_flags;
1736 self.lights_point_count = point_count;
1737 self.lights_packed_grids = scene.grid_count;
1738 self.dirty.scene_lights = false;
1739 }
1740 let (sun_flags, point_count) = (self.lights_sun_flags, self.lights_point_count);
1741
1742 let uniform = SceneDdaUniform {
1743 fov_y_rad,
1744 grid_count: scene.grid_count,
1745 max_outer_steps,
1746 _pad0: 0,
1747 screen_size: [render_w, render_h],
1748 _pad1: [0; 2],
1749 fog_color: [
1750 self.fog_color[0],
1751 self.fog_color[1],
1752 self.fog_color[2],
1753 self.fog_near,
1754 ],
1755 fog_far: self.fog_far,
1756 // L3.1: always write scene depth. Costs one storage store per
1757 // pixel, and the depth is needed for sprite z-test, sprite-less
1758 // `pick_depth`, and `draw_lines` occlusion alike.
1759 write_depth: 1,
1760 occ_page_words: scene.occupancy_page_words,
1761 occ_num_pages: scene.occupancy_num_pages,
1762 mip_scan_dist: self.scene_mip_scan_dist,
1763 terrain_has_translucent: u32::from(self.scene_terrain_translucent),
1764 terrain_map_count: self.scene_terrain_map.len() as u32,
1765 _pad4: 0,
1766 // Sky direction comes from the world (sprite) camera, so a
1767 // grid-less sprite-only scene still paints a real sky.
1768 sky_cam: SceneDdaPerGridCamera::from_camera(sprite_camera),
1769 side_shades0: self.scene_side_shades[0],
1770 side_shades1: self.scene_side_shades[1],
1771 sun_color: [
1772 lights.sun_color[0],
1773 lights.sun_color[1],
1774 lights.sun_color[2],
1775 lights.sun_intensity,
1776 ],
1777 ambient_color: [
1778 lights.ambient[0],
1779 lights.ambient[1],
1780 lights.ambient[2],
1781 lights.shadow_strength,
1782 ],
1783 sun_flags,
1784 point_light_count: point_count,
1785 shadow_max_steps: lights.shadow_max_steps,
1786 _pad5: 0,
1787 shadow_bias: lights.shadow_bias,
1788 shadow_max_dist: lights.shadow_max_dist,
1789 _pad6: [0.0; 2],
1790 shadow_tint: [
1791 lights.shadow_tint[0],
1792 lights.shadow_tint[1],
1793 lights.shadow_tint[2],
1794 0.0,
1795 ],
1796 style_bands: lights.style_bands,
1797 // XS.4.3 — visible sprite casters for the scene-pass cast march
1798 // (only when the device is sprite-shadow capable; else the cast
1799 // bindings/loop are absent).
1800 sprite_cast_count: if self.sprite_shadows_capable {
1801 sprite_pass.map_or(0, |(visible, _)| visible)
1802 } else {
1803 0
1804 },
1805 _pad7: [0; 2],
1806 };
1807 self.queue
1808 .write_buffer(&dda.uniform_buf, 0, bytemuck::bytes_of(&uniform));
1809
1810 // PF.4 — cached bind group, keyed on the exact resources bound.
1811 // Occupancy page 0 at binding 1; pages 1..MAX_OCC_PAGES at 12..
1812 // (GPU.X paging). Per-grid point lights at 18 (DL); the per-grid
1813 // sun dir rides in PerGridCamera.sun_dir (binding 15).
1814 let mut dda_bufs: Vec<(u32, wgpu::Buffer)> = vec![
1815 (0, dda.uniform_buf.clone()),
1816 (1, scene.occupancy_pages[0].clone()),
1817 (2, scene.all_color_offsets.clone()),
1818 (3, scene.all_colors.clone()),
1819 (4, scene.all_chunk_colors_base.clone()),
1820 (5, scene.all_chunk_occupancy.clone()),
1821 (6, scene.grid_static_meta.clone()),
1822 (7, scene.all_slot_chunk_idx.clone()),
1823 (8, dda.framebuffer.clone()),
1824 (11, dda.depth_buffer.clone()),
1825 (12, scene.occupancy_pages[1].clone()),
1826 (13, scene.occupancy_pages[2].clone()),
1827 (14, scene.occupancy_pages[3].clone()),
1828 (15, fp.grid_cameras.clone()),
1829 (16, dda.materials_pal_buf.clone()),
1830 (17, dda.terrain_map_buf.clone()),
1831 (18, fp.point_lights.clone()),
1832 ];
1833 // XS.4.3 — sprite-cast bindings (19..21). On a capable device the BGL
1834 // has them, so bind the sprite registry when present (terrain shadow
1835 // rays test sprite volumes), else the dummy (sprite_cast_count == 0).
1836 if self.sprite_shadows_capable {
1837 let dummy = dda
1838 .sprite_cast_dummy
1839 .as_ref()
1840 .expect("capable scene_dda has a sprite-cast dummy");
1841 let (insts, models, occ) = match &self.sprite_registry {
1842 Some(reg) => (®.instances, ®.model_meta, ®.occupancy),
1843 None => (dummy, dummy, dummy),
1844 };
1845 dda_bufs.push((19, insts.clone()));
1846 dda_bufs.push((20, models.clone()));
1847 dda_bufs.push((21, occ.clone()));
1848 }
1849 let dda_bg = cached_bind_group(
1850 &mut fp.dda_bg,
1851 &self.device,
1852 "roxlap-gpu scene_dda.bg",
1853 &dda.bgl_dda,
1854 dda_bufs,
1855 vec![(9, self.sky_view.clone())],
1856 &[(10, &self.sky_sampler)],
1857 )
1858 .clone();
1859
1860 // GPU.9 — when sprites are present, build both splatter bind
1861 // groups up front (the splat pass writes the key buffer; the
1862 // resolve pass reads keys + scene depth and writes colour).
1863 // GPU.10.3 — model-DDA bind group + per-frame uniform, using the
1864 // cull/bin results captured above. Per-model + per-instance data
1865 // + the tile lists live in the registry buffers.
1866 let sprite_model_bg = match (&self.sprite_model_dda, &self.sprite_registry, sprite_pass) {
1867 (Some(smd), Some(reg), Some((visible, tiles_x))) => {
1868 // World camera (see the cull pass above) — sprites
1869 // project through it regardless of grid 0's transform.
1870 let cam = sprite_camera;
1871 // DL.4 — world-space lights for the sprite pass (sprites are
1872 // world-space, not grid-local). No sprite shadows (deferred).
1873 let dl = &self.scene_lights;
1874 let sprite_sun_enabled = dl.world_sun_dir != [0.0; 3];
1875 let sprite_point_count = dl.world_points.len().min(MAX_POINT_LIGHTS) as u32;
1876 // PF.4 — persistent buffer instead of a per-frame allocation.
1877 // PF.5 — rebuilt + re-uploaded only when the rig changed;
1878 // this pass's own dirty flag (it only runs with sprites on
1879 // screen, so it can't ride the scene pack's flag).
1880 if self.dirty.sprite_lights {
1881 let sprite_pts: Vec<GpuPointLight> = dl
1882 .world_points
1883 .iter()
1884 .take(MAX_POINT_LIGHTS)
1885 .map(|l| GpuPointLight {
1886 pos: l.position,
1887 radius: l.radius,
1888 color: l.color,
1889 intensity: l.intensity,
1890 spot_dir: l.spot_dir,
1891 cos_outer: l.cos_outer,
1892 cos_inner: l.cos_inner,
1893 // XS.4.2 — honour the light's caster flag so a
1894 // receiving sprite is shadowed by it (capable
1895 // devices).
1896 casts_shadow: u32::from(l.casts_shadow),
1897 _pad: [0; 2],
1898 })
1899 .collect();
1900 fp.write_sprite_lights(&self.device, &self.queue, &sprite_pts);
1901 self.dirty.sprite_lights = false;
1902 }
1903 // sun_flags bit0 = sun enabled, bit1 = sun casts shadow (XS.4.2),
1904 // bit2 = dynamic lighting active.
1905 let sprite_sun_flags = u32::from(sprite_sun_enabled)
1906 | (u32::from(dl.sun_casts_shadow) << 1)
1907 | (u32::from(dl.enabled) << 2);
1908 let uni = SpriteModelUniform {
1909 cam_pos: cam.position,
1910 _p0: 0.0,
1911 cam_right: cam.right,
1912 _p1: 0.0,
1913 cam_down: cam.down,
1914 _p2: 0.0,
1915 cam_forward: cam.forward,
1916 _p3: 0.0,
1917 fog_color: [
1918 self.fog_color[0],
1919 self.fog_color[1],
1920 self.fog_color[2],
1921 self.fog_near,
1922 ],
1923 screen_size: [render_w, render_h],
1924 instance_count: visible,
1925 fog_far: self.fog_far,
1926 fov_y_rad,
1927 tiles_x,
1928 tile_size: SPRITE_TILE_SIZE,
1929 has_translucent: u32::from(self.sprite_has_translucent),
1930 sun_dir: [
1931 dl.world_sun_dir[0],
1932 dl.world_sun_dir[1],
1933 dl.world_sun_dir[2],
1934 0.0,
1935 ],
1936 sun_color: [
1937 dl.sun_color[0],
1938 dl.sun_color[1],
1939 dl.sun_color[2],
1940 dl.sun_intensity,
1941 ],
1942 ambient_color: [dl.ambient[0], dl.ambient[1], dl.ambient[2], 0.0],
1943 sun_flags: sprite_sun_flags,
1944 point_light_count: sprite_point_count,
1945 has_emissive: u32::from(self.sprite_has_emissive),
1946 _pad_dl: 0,
1947 shadow_tint: [dl.shadow_tint[0], dl.shadow_tint[1], dl.shadow_tint[2], 0.0],
1948 style_bands: dl.style_bands,
1949 // XS.4.2 — sprite-shadow (receive) ABI, mirroring the scene
1950 // pass. Only consulted when the device is sprite-shadow
1951 // capable (the shadowed shader variant is built); otherwise
1952 // the stub `sprite_shadow_occluded` ignores them.
1953 occ_num_pages: scene.occupancy_num_pages,
1954 occ_page_words: scene.occupancy_page_words,
1955 grid_count: scene.grid_count,
1956 max_outer_steps,
1957 shadow_max_steps: dl.shadow_max_steps,
1958 shadow_bias: dl.shadow_bias,
1959 shadow_max_dist: dl.shadow_max_dist,
1960 shadow_strength: dl.shadow_strength,
1961 _pad_xs: [0; 3],
1962 };
1963 self.queue
1964 .write_buffer(&smd.uniform_buf, 0, bytemuck::bytes_of(&uni));
1965 // PF.4 — cached bind group (identity-keyed, like the scene
1966 // pass's). World point lights at 15 (DL.7; binding 14 univec
1967 // normal table dropped — face-normal lighting now).
1968 let mut sprite_bufs: Vec<(u32, wgpu::Buffer)> = vec![
1969 (0, smd.uniform_buf.clone()),
1970 (1, reg.occupancy.clone()),
1971 (2, reg.colors.clone()),
1972 (3, reg.color_offsets.clone()),
1973 (4, reg.model_meta.clone()),
1974 (5, reg.instances.clone()),
1975 (6, dda.depth_buffer.clone()),
1976 (7, dda.framebuffer.clone()),
1977 (8, reg.tile_ranges.clone()),
1978 (9, reg.tile_instances.clone()),
1979 (10, reg.dirs.clone()),
1980 (11, reg.colmul.clone()),
1981 (12, smd.materials_buf.clone()),
1982 (13, reg.materials_vox.clone()),
1983 (15, fp.sprite_lights.clone()),
1984 ];
1985 // XS.4.2 — when capable, bind the terrain occupancy set (the
1986 // same resident buffers + the per-frame grid cameras the scene
1987 // pass uses) so sprite shadow rays march terrain. Must match
1988 // the BGL built in `build_sprite_model_dda`.
1989 if self.sprite_shadows_capable {
1990 let terrain: [(u32, &wgpu::Buffer); 8] = [
1991 (16, &scene.occupancy_pages[0]),
1992 (17, &scene.occupancy_pages[1]),
1993 (18, &scene.occupancy_pages[2]),
1994 (19, &scene.occupancy_pages[3]),
1995 (20, &scene.all_chunk_occupancy),
1996 (21, &scene.all_slot_chunk_idx),
1997 (22, &scene.grid_static_meta),
1998 (23, &fp.grid_cameras),
1999 ];
2000 for (binding, buf) in terrain {
2001 sprite_bufs.push((binding, buf.clone()));
2002 }
2003 }
2004 Some(
2005 cached_bind_group(
2006 &mut fp.sprite_bg,
2007 &self.device,
2008 "roxlap-gpu sprite_model_dda.bg",
2009 &smd.bgl,
2010 sprite_bufs,
2011 Vec::new(),
2012 &[],
2013 )
2014 .clone(),
2015 )
2016 }
2017 _ => None,
2018 };
2019
2020 let mut encoder = self
2021 .device
2022 .create_command_encoder(&wgpu::CommandEncoderDescriptor {
2023 label: Some("roxlap-gpu scene encoder"),
2024 });
2025 {
2026 let mut cpass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
2027 label: Some("roxlap-gpu scene_dda compute"),
2028 timestamp_writes: None,
2029 });
2030 cpass.set_pipeline(&dda.pipeline_dda);
2031 cpass.set_bind_group(0, &dda_bg, &[]);
2032 cpass.dispatch_workgroups(render_w.div_ceil(8), render_h.div_ceil(8), 1);
2033 }
2034 // GPU.10 — sprite model-DDA pass: one thread per pixel marches
2035 // the tile's instances + composites against scene depth, after
2036 // the scene pass wrote the depth buffer and before the blit.
2037 if let (Some(smd), Some(bg)) = (&self.sprite_model_dda, &sprite_model_bg) {
2038 let mut cpass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
2039 label: Some("roxlap-gpu sprite_model_dda"),
2040 timestamp_writes: None,
2041 });
2042 cpass.set_pipeline(&smd.pipeline);
2043 cpass.set_bind_group(0, bg, &[]);
2044 cpass.dispatch_workgroups(render_w.div_ceil(8), render_h.div_ceil(8), 1);
2045 }
2046 // RP.1 — resolve pass: box-downfilter framebuffer(march) →
2047 // resolve_buf(logical). One thread per logical pixel.
2048 // PF.5 (H6) — with ssaa == 1 AND posterize off the resolve is an
2049 // identity copy: skip the whole full-screen pass and blit straight
2050 // from the framebuffer instead (byte-identical output).
2051 let identity_resolve =
2052 (render_w, render_h) == (logical_w, logical_h) && self.posterize.is_none();
2053 if !identity_resolve {
2054 let mut cpass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
2055 label: Some("roxlap-gpu scene_dda resolve"),
2056 timestamp_writes: None,
2057 });
2058 cpass.set_pipeline(&dda.pipeline_resolve);
2059 cpass.set_bind_group(0, &dda.resolve_bg, &[]);
2060 cpass.dispatch_workgroups(logical_w.div_ceil(8), logical_h.div_ceil(8), 1);
2061 }
2062 {
2063 let mut rpass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
2064 label: Some("roxlap-gpu scene_dda blit"),
2065 color_attachments: &[Some(wgpu::RenderPassColorAttachment {
2066 view: &surf_view,
2067 depth_slice: None,
2068 resolve_target: None,
2069 ops: wgpu::Operations {
2070 load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
2071 store: wgpu::StoreOp::Store,
2072 },
2073 })],
2074 depth_stencil_attachment: None,
2075 timestamp_writes: None,
2076 occlusion_query_set: None,
2077 multiview_mask: None,
2078 });
2079 rpass.set_pipeline(&dda.pipeline_blit);
2080 rpass.set_bind_group(
2081 0,
2082 if identity_resolve {
2083 &dda.blit_bg_direct
2084 } else {
2085 &dda.blit_bg
2086 },
2087 &[],
2088 );
2089 rpass.draw(0..3, 0..1);
2090 }
2091 self.queue.submit(std::iter::once(encoder.finish()));
2092 // This frame wrote `scene_dda.depth_buffer`, so depth-tested
2093 // overlays may test against it.
2094 self.dirty.scene_depth_valid = true;
2095 // Deferred present — the host calls `present` or `paint_egui`.
2096 self.pending_frame = Some((surf_tex, surf_view));
2097 self.frame_count = self.frame_count.wrapping_add(1);
2098 }
2099
2100 /// Like [`Self::render`] (clear to colour) but **deferred**: stashes
2101 /// the frame for [`Self::present`] / [`Self::paint_egui`] instead of
2102 /// presenting. The facade uses this before any grid is resident so a
2103 /// HUD can still be painted over an empty scene.
2104 pub fn render_clear_deferred(&mut self) {
2105 // No scene pass this frame ⇒ `scene_dda.depth_buffer` (if it
2106 // exists from an earlier scene) is stale; depth-tested overlays
2107 // must not test against it.
2108 self.dirty.scene_depth_valid = false;
2109 self.pending_frame = None;
2110 let Some(surf_tex) = self.acquire_frame() else {
2111 return;
2112 };
2113 let view = surf_tex
2114 .texture
2115 .create_view(&wgpu::TextureViewDescriptor::default());
2116 let [r, g, b] = self.clear_colour;
2117 let mut encoder = self
2118 .device
2119 .create_command_encoder(&wgpu::CommandEncoderDescriptor {
2120 label: Some("roxlap-gpu clear (deferred)"),
2121 });
2122 {
2123 let _rp = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
2124 label: Some("roxlap-gpu clear (deferred)"),
2125 color_attachments: &[Some(wgpu::RenderPassColorAttachment {
2126 view: &view,
2127 depth_slice: None,
2128 resolve_target: None,
2129 ops: wgpu::Operations {
2130 load: wgpu::LoadOp::Clear(wgpu::Color { r, g, b, a: 1.0 }),
2131 store: wgpu::StoreOp::Store,
2132 },
2133 })],
2134 depth_stencil_attachment: None,
2135 timestamp_writes: None,
2136 occlusion_query_set: None,
2137 multiview_mask: None,
2138 });
2139 }
2140 self.queue.submit(std::iter::once(encoder.finish()));
2141 self.pending_frame = Some((surf_tex, view));
2142 }
2143
2144 /// Present the frame stashed by the last deferred render
2145 /// ([`Self::render_scene`] / [`Self::render_clear_deferred`]). No-op
2146 /// if nothing is pending (e.g. the surface was lost mid-render).
2147 pub fn present(&mut self) {
2148 if let Some((surf_tex, _view)) = self.pending_frame.take() {
2149 surf_tex.present();
2150 }
2151 }
2152
2153 /// Block until the GPU has drained every submitted command (queue
2154 /// idle), dropping any not-yet-presented swapchain frame first. Call at
2155 /// shutdown — before the [`GpuRenderer`] (and its window) drop — so the
2156 /// device is torn down with no work in flight and no half-presented
2157 /// frame, instead of yanking the swapchain mid-submission (which leaves
2158 /// the driver/compositor compositing stale buffers — the "leftover
2159 /// triangles / flicker after an unclean exit" symptom). No-op on wasm
2160 /// (`poll(Wait)` is unavailable there; the browser reclaims the device).
2161 pub fn wait_idle(&mut self) {
2162 // Release the acquired-but-unpresented frame so its swapchain image
2163 // isn't held across teardown.
2164 self.pending_frame = None;
2165 #[cfg(not(target_arch = "wasm32"))]
2166 {
2167 self.device.poll(wgpu::PollType::wait_indefinitely()).ok();
2168 }
2169 }
2170
2171 /// Project a world point to window pixels under the marcher's
2172 /// vertical-FOV pinhole (the inverse of [`Self::pixel_ray`]), using
2173 /// the last-rendered frame's size + FOV. `None` before the first
2174 /// scene render or for a point at/behind the near plane.
2175 #[must_use]
2176 pub fn project_point(
2177 &self,
2178 cam_pos: [f32; 3],
2179 right: [f32; 3],
2180 down: [f32; 3],
2181 forward: [f32; 3],
2182 world: [f32; 3],
2183 ) -> Option<(f32, f32)> {
2184 let dda = self.scene_dda.as_ref()?;
2185 let (w, h) = dda.storage_size;
2186 if w == 0 || h == 0 || self.last_fov_y_rad <= 0.0 {
2187 return None;
2188 }
2189 let d = [
2190 world[0] - cam_pos[0],
2191 world[1] - cam_pos[1],
2192 world[2] - cam_pos[2],
2193 ];
2194 let cz = forward[0] * d[0] + forward[1] * d[1] + forward[2] * d[2];
2195 if cz < LINE_NEAR_Z {
2196 return None;
2197 }
2198 let cx = right[0] * d[0] + right[1] * d[1] + right[2] * d[2];
2199 let cy = down[0] * d[0] + down[1] * d[1] + down[2] * d[2];
2200 let half_h = (self.last_fov_y_rad * 0.5).tan();
2201 let half_w = half_h * (w as f32 / h as f32);
2202 let ndc_x = (cx / cz) / half_w;
2203 let ndc_y = -(cy / cz) / half_h;
2204 let sx = (ndc_x * 0.5 + 0.5) * w as f32;
2205 let sy = (0.5 - ndc_y * 0.5) * h as f32;
2206 Some((sx, sy))
2207 }
2208
2209 fn build_scene_dda(
2210 &self,
2211 width: u32,
2212 height: u32,
2213 logical_w: u32,
2214 logical_h: u32,
2215 surface_w: u32,
2216 surface_h: u32,
2217 surface_format: wgpu::TextureFormat,
2218 ) -> SceneDdaResources {
2219 // `width`/`height` are the **march** size (`logical × ssaa`) — the
2220 // scene + sprite + depth passes run at it. `logical_*` is the resolved
2221 // (retro) grid the resolve pass downfilters into and the blit reads.
2222 // `surface_*` is the swapchain the blit upscales onto. Framebuffer is a
2223 // packed-`rgba8unorm` storage buffer (row stride = march `width`).
2224 let framebuffer = self.device.create_buffer(&wgpu::BufferDescriptor {
2225 label: Some("roxlap-gpu scene_dda.framebuffer"),
2226 size: u64::from(width) * u64::from(height) * 4,
2227 // QE.7a - COPY_SRC so `read_frame_pixels` can stage the
2228 // identity-resolve path (ssaa 1, posterize off) for capture.
2229 usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC,
2230 mapped_at_creation: false,
2231 });
2232 // RP.1 — logical-resolution buffer the resolve pass writes; the blit
2233 // reads it (so the blit src is the *logical* size, not the march size).
2234 let resolve_buf = self.device.create_buffer(&wgpu::BufferDescriptor {
2235 label: Some("roxlap-gpu scene_dda.resolve_buf"),
2236 size: u64::from(logical_w) * u64::from(logical_h) * 4,
2237 // QE.7a - COPY_SRC so `read_frame_pixels` can stage it (capture).
2238 usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC,
2239 mapped_at_creation: false,
2240 });
2241 // Resolve uniform: `[src(march) w,h, dst(logical) w,h, ssaa,
2242 // levels r,g,b, dither, pad×3]` (48 B). Dims+ssaa written here; the
2243 // posterize fields (offset 20) are re-written per frame in render_scene.
2244 let resolve_dims = self.device.create_buffer(&wgpu::BufferDescriptor {
2245 label: Some("roxlap-gpu scene_dda.resolve_dims"),
2246 size: 48,
2247 usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
2248 mapped_at_creation: false,
2249 });
2250 self.queue.write_buffer(
2251 &resolve_dims,
2252 0,
2253 bytemuck::bytes_of(&[width, height, logical_w, logical_h, self.ssaa]),
2254 );
2255 // Blit uniform `Dims`: logical (src) size, swapchain (dst) size, then
2256 // `flip_x` + pad (RP.0 nearest upscale). The flip flag (offset 16) is
2257 // re-written per frame in `render_scene`; a render/surface resize
2258 // forces a full rebuild, so the sizes only need writing here.
2259 let blit_dims = self.device.create_buffer(&wgpu::BufferDescriptor {
2260 label: Some("roxlap-gpu scene_dda.blit_dims"),
2261 size: 32,
2262 usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
2263 mapped_at_creation: false,
2264 });
2265 self.queue.write_buffer(
2266 &blit_dims,
2267 0,
2268 bytemuck::bytes_of(&[
2269 logical_w,
2270 logical_h,
2271 surface_w,
2272 surface_h,
2273 u32::from(self.flip_x),
2274 0u32,
2275 0u32,
2276 0u32,
2277 ]),
2278 );
2279
2280 let uniform_buf = self.device.create_buffer(&wgpu::BufferDescriptor {
2281 label: Some("roxlap-gpu scene_dda.uniform"),
2282 size: std::mem::size_of::<SceneDdaUniform>() as u64,
2283 usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
2284 mapped_at_creation: false,
2285 });
2286
2287 // GPU.9 — per-pixel world-t depth (f32 bits as u32). Sized to
2288 // the storage texture; written by the scene pass when sprites
2289 // are active, read+tested by the sprite splatter.
2290 let depth_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
2291 label: Some("roxlap-gpu scene_dda.depth"),
2292 size: u64::from(width) * u64::from(height) * 4,
2293 // COPY_SRC so `read_depth_pixel` can stage it for picking.
2294 usage: wgpu::BufferUsages::STORAGE
2295 | wgpu::BufferUsages::COPY_DST
2296 | wgpu::BufferUsages::COPY_SRC,
2297 mapped_at_creation: false,
2298 });
2299 let depth_readback = self.device.create_buffer(&wgpu::BufferDescriptor {
2300 label: Some("roxlap-gpu scene_dda.depth_readback"),
2301 size: u64::from(width) * u64::from(height) * 4,
2302 usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
2303 mapped_at_creation: false,
2304 });
2305 // XS.4.3 — on sprite-shadow-capable devices, splice the sprite-cast
2306 // snippet over the `sprites_occlude` stub (binds the sprite registry at
2307 // 19..21 so terrain shadow rays test sprite volumes).
2308 let capable = self.sprite_shadows_capable;
2309 let dda_shader = self
2310 .device
2311 .create_shader_module(wgpu::ShaderModuleDescriptor {
2312 label: Some("scene_dda.wgsl"),
2313 source: wgpu::ShaderSource::Wgsl(scene_shader_source(capable).into()),
2314 });
2315 let mut dda_entries = vec![
2316 bgl_uniform_entry(0),
2317 bgl_storage_entry(1, true),
2318 bgl_storage_entry(2, true),
2319 bgl_storage_entry(3, true),
2320 bgl_storage_entry(4, true),
2321 bgl_storage_entry(5, true),
2322 bgl_storage_entry(6, true),
2323 bgl_storage_entry(7, true),
2324 // Framebuffer storage buffer (read-write; the scene +
2325 // sprite passes write packed pixels into it).
2326 bgl_storage_entry(8, false),
2327 // GPU.8 sky panorama + sampler.
2328 wgpu::BindGroupLayoutEntry {
2329 binding: 9,
2330 visibility: wgpu::ShaderStages::COMPUTE,
2331 ty: wgpu::BindingType::Texture {
2332 sample_type: wgpu::TextureSampleType::Float { filterable: true },
2333 view_dimension: wgpu::TextureViewDimension::D2,
2334 multisampled: false,
2335 },
2336 count: None,
2337 },
2338 wgpu::BindGroupLayoutEntry {
2339 binding: 10,
2340 visibility: wgpu::ShaderStages::COMPUTE,
2341 ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
2342 count: None,
2343 },
2344 // GPU.9 — read-write per-pixel depth buffer.
2345 bgl_storage_entry(11, false),
2346 // Occupancy pages 1..MAX_OCC_PAGES (page 0 is
2347 // binding 1). Unused pages bind a dummy buffer.
2348 bgl_storage_entry(12, true),
2349 bgl_storage_entry(13, true),
2350 bgl_storage_entry(14, true),
2351 // Per-grid cameras (runtime-sized; one per grid).
2352 bgl_storage_entry(15, true),
2353 // TV.6 — material palette + terrain colour→material map.
2354 bgl_storage_entry(16, true),
2355 bgl_storage_entry(17, true),
2356 // DL — per-grid point lights (18). Sun dir rides in
2357 // PerGridCamera (binding 15) to stay within the 16
2358 // storage-buffer limit.
2359 bgl_storage_entry(18, true),
2360 ];
2361 if capable {
2362 // XS.4.3 — sprite registry for the sprite-cast shadow march.
2363 dda_entries.push(bgl_storage_entry(19, true)); // sprite_instances
2364 dda_entries.push(bgl_storage_entry(20, true)); // sprite_models
2365 dda_entries.push(bgl_storage_entry(21, true)); // sprite_occupancy
2366 }
2367 let bgl_dda = self
2368 .device
2369 .create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
2370 label: Some("roxlap-gpu scene_dda.bgl"),
2371 entries: &dda_entries,
2372 });
2373 let dda_pl = self
2374 .device
2375 .create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
2376 label: Some("roxlap-gpu scene_dda.layout"),
2377 bind_group_layouts: &[Some(&bgl_dda)],
2378 immediate_size: 0,
2379 });
2380 let pipeline_dda = self
2381 .device
2382 .create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
2383 label: Some("roxlap-gpu scene_dda.pipeline"),
2384 layout: Some(&dda_pl),
2385 module: &dda_shader,
2386 entry_point: Some("render_scene"),
2387 compilation_options: wgpu::PipelineCompilationOptions::default(),
2388 cache: None,
2389 });
2390
2391 // RP.1 — box-downfilter resolve pass (framebuffer march → resolve_buf
2392 // logical). `ssaa == 1` is a 1×1 copy; the blit always reads resolve_buf.
2393 let resolve_shader = self
2394 .device
2395 .create_shader_module(wgpu::ShaderModuleDescriptor {
2396 label: Some("scene_resolve.wgsl"),
2397 source: wgpu::ShaderSource::Wgsl(
2398 include_str!("../shaders/scene_resolve.wgsl").into(),
2399 ),
2400 });
2401 let bgl_resolve = self
2402 .device
2403 .create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
2404 label: Some("roxlap-gpu scene_dda.resolve_bgl"),
2405 entries: &[
2406 bgl_storage_entry(0, true), // src framebuffer (read)
2407 bgl_storage_entry(1, false), // dst resolve_buf (read-write)
2408 bgl_uniform_entry(2), // resolve dims
2409 ],
2410 });
2411 let resolve_pl = self
2412 .device
2413 .create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
2414 label: Some("roxlap-gpu scene_dda.resolve_layout"),
2415 bind_group_layouts: &[Some(&bgl_resolve)],
2416 immediate_size: 0,
2417 });
2418 let pipeline_resolve =
2419 self.device
2420 .create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
2421 label: Some("roxlap-gpu scene_dda.resolve_pipeline"),
2422 layout: Some(&resolve_pl),
2423 module: &resolve_shader,
2424 entry_point: Some("main"),
2425 compilation_options: wgpu::PipelineCompilationOptions::default(),
2426 cache: None,
2427 });
2428 let resolve_bg = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
2429 label: Some("roxlap-gpu scene_dda.resolve_bg"),
2430 layout: &bgl_resolve,
2431 entries: &[
2432 wgpu::BindGroupEntry {
2433 binding: 0,
2434 resource: framebuffer.as_entire_binding(),
2435 },
2436 wgpu::BindGroupEntry {
2437 binding: 1,
2438 resource: resolve_buf.as_entire_binding(),
2439 },
2440 wgpu::BindGroupEntry {
2441 binding: 2,
2442 resource: resolve_dims.as_entire_binding(),
2443 },
2444 ],
2445 });
2446
2447 let blit_shader = self
2448 .device
2449 .create_shader_module(wgpu::ShaderModuleDescriptor {
2450 label: Some("scene_blit.wgsl"),
2451 source: wgpu::ShaderSource::Wgsl(include_str!("../shaders/scene_blit.wgsl").into()),
2452 });
2453 let bgl_blit = self
2454 .device
2455 .create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
2456 label: Some("roxlap-gpu scene_dda.blit_bgl"),
2457 entries: &[
2458 // Framebuffer storage buffer (read-only in the blit).
2459 wgpu::BindGroupLayoutEntry {
2460 binding: 0,
2461 visibility: wgpu::ShaderStages::FRAGMENT,
2462 ty: wgpu::BindingType::Buffer {
2463 ty: wgpu::BufferBindingType::Storage { read_only: true },
2464 has_dynamic_offset: false,
2465 min_binding_size: None,
2466 },
2467 count: None,
2468 },
2469 // Screen-size uniform for the pixel→index math.
2470 wgpu::BindGroupLayoutEntry {
2471 binding: 1,
2472 visibility: wgpu::ShaderStages::FRAGMENT,
2473 ty: wgpu::BindingType::Buffer {
2474 ty: wgpu::BufferBindingType::Uniform,
2475 has_dynamic_offset: false,
2476 min_binding_size: None,
2477 },
2478 count: None,
2479 },
2480 ],
2481 });
2482 let blit_pl = self
2483 .device
2484 .create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
2485 label: Some("roxlap-gpu scene_dda.blit_layout"),
2486 bind_group_layouts: &[Some(&bgl_blit)],
2487 immediate_size: 0,
2488 });
2489 let pipeline_blit = self
2490 .device
2491 .create_render_pipeline(&wgpu::RenderPipelineDescriptor {
2492 label: Some("roxlap-gpu scene_dda.blit_pipeline"),
2493 layout: Some(&blit_pl),
2494 vertex: wgpu::VertexState {
2495 module: &blit_shader,
2496 entry_point: Some("vs_main"),
2497 compilation_options: wgpu::PipelineCompilationOptions::default(),
2498 buffers: &[],
2499 },
2500 fragment: Some(wgpu::FragmentState {
2501 module: &blit_shader,
2502 entry_point: Some("fs_main"),
2503 compilation_options: wgpu::PipelineCompilationOptions::default(),
2504 targets: &[Some(wgpu::ColorTargetState {
2505 format: surface_format,
2506 blend: None,
2507 write_mask: wgpu::ColorWrites::ALL,
2508 })],
2509 }),
2510 primitive: wgpu::PrimitiveState::default(),
2511 depth_stencil: None,
2512 multisample: wgpu::MultisampleState::default(),
2513 multiview_mask: None,
2514 cache: None,
2515 });
2516 let blit_bg = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
2517 label: Some("roxlap-gpu scene_dda.blit_bg"),
2518 layout: &bgl_blit,
2519 entries: &[
2520 wgpu::BindGroupEntry {
2521 binding: 0,
2522 // RP.1 — blit reads the logical resolve buffer.
2523 resource: resolve_buf.as_entire_binding(),
2524 },
2525 wgpu::BindGroupEntry {
2526 binding: 1,
2527 resource: blit_dims.as_entire_binding(),
2528 },
2529 ],
2530 });
2531 // PF.5 (H6) — direct-blit variant reading the march framebuffer:
2532 // used when the resolve pass would be an identity copy (ssaa == 1,
2533 // posterize off ⇒ march size == logical size), letting render_scene
2534 // skip that full-screen pass entirely.
2535 let blit_bg_direct = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
2536 label: Some("roxlap-gpu scene_dda.blit_bg_direct"),
2537 layout: &bgl_blit,
2538 entries: &[
2539 wgpu::BindGroupEntry {
2540 binding: 0,
2541 resource: framebuffer.as_entire_binding(),
2542 },
2543 wgpu::BindGroupEntry {
2544 binding: 1,
2545 resource: blit_dims.as_entire_binding(),
2546 },
2547 ],
2548 });
2549
2550 // TV.6 — material palette + terrain map buffers, seeded from the
2551 // renderer's current scene-material state (so a map defined before the
2552 // scene pass was built still takes effect).
2553 let (materials_pal_buf, terrain_map_buf) = {
2554 use wgpu::util::DeviceExt;
2555 let pal = self
2556 .device
2557 .create_buffer_init(&wgpu::util::BufferInitDescriptor {
2558 label: Some("roxlap-gpu scene_dda.materials_pal"),
2559 contents: bytemuck::cast_slice(self.scene_materials.as_slice()),
2560 usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
2561 });
2562 // Fixed 256-row map (≤256 materials anyway) → no re-alloc when the
2563 // host changes the map after the scene pass is built.
2564 let mut rows = [[0u32; 2]; 256];
2565 for (slot, &row) in rows.iter_mut().zip(self.scene_terrain_map.iter()) {
2566 *slot = row;
2567 }
2568 let map = self
2569 .device
2570 .create_buffer_init(&wgpu::util::BufferInitDescriptor {
2571 label: Some("roxlap-gpu scene_dda.terrain_map"),
2572 contents: bytemuck::cast_slice(&rows),
2573 usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
2574 });
2575 (pal, map)
2576 };
2577
2578 SceneDdaResources {
2579 storage_size: (width, height),
2580 logical_size: (logical_w, logical_h),
2581 framebuffer,
2582 resolve_buf,
2583 uniform_buf,
2584 bgl_dda,
2585 pipeline_dda,
2586 pipeline_resolve,
2587 resolve_bg,
2588 resolve_dims,
2589 blit_bg,
2590 blit_bg_direct,
2591 pipeline_blit,
2592 blit_dims,
2593 depth_buffer,
2594 depth_readback,
2595 materials_pal_buf,
2596 terrain_map_buf,
2597 // XS.4.3 — 80-byte dummy (≥ one Instance) for the sprite-cast
2598 // bindings when capable but no sprite registry is bound this frame.
2599 sprite_cast_dummy: capable.then(|| {
2600 self.device.create_buffer(&wgpu::BufferDescriptor {
2601 label: Some("roxlap-gpu scene_dda.sprite_cast_dummy"),
2602 size: 80,
2603 usage: wgpu::BufferUsages::STORAGE,
2604 mapped_at_creation: false,
2605 })
2606 }),
2607 }
2608 }
2609
2610 /// GPU.10.1 — upload a sprite model registry + its instances for
2611 /// the DDA path. An empty instance slice clears all sprites.
2612 pub fn set_sprite_instances(
2613 &mut self,
2614 registry: &sprite_model::SpriteModelRegistry,
2615 instances: &[sprite_model::SpriteInstance],
2616 ) {
2617 if instances.is_empty() {
2618 self.sprite_registry = None;
2619 return;
2620 }
2621 self.sprite_registry = Some(sprite_model::SpriteRegistryResident::upload(
2622 &self.device,
2623 registry,
2624 instances,
2625 ));
2626 }
2627
2628 /// Incrementally append sprite instances **without** rebuilding the
2629 /// registry — the cheap streaming-spawn path (asteroids, projectiles).
2630 /// Returns the index of the first appended instance (`[base, base+N)`).
2631 ///
2632 /// Every appended instance must reference a model already registered
2633 /// by the [`Self::set_sprite_instances`] that established residency
2634 /// (model volumes are not re-uploaded here — build the full
2635 /// `SpriteModelRegistry` up front and seed it once, then stream
2636 /// instances). If no registry is resident yet, this performs the
2637 /// initial full upload and returns `0`.
2638 ///
2639 /// Cost is amortised O(1) per instance (the GPU instance buffer grows
2640 /// by powers of two), versus the full volume + buffer rebuild of
2641 /// [`Self::set_sprite_instances`].
2642 pub fn append_sprite_instances(
2643 &mut self,
2644 registry: &sprite_model::SpriteModelRegistry,
2645 instances: &[sprite_model::SpriteInstance],
2646 ) -> u32 {
2647 match self.sprite_registry.as_mut() {
2648 Some(reg) => reg.append_instances(&self.device, registry, instances),
2649 None => {
2650 self.set_sprite_instances(registry, instances);
2651 0
2652 }
2653 }
2654 }
2655
2656 /// Remove the sprite instance at `index` (swap-remove, O(1), no model
2657 /// re-upload). Returns `Some(old_last)` if a different instance was
2658 /// moved into `index` to fill the hole — its index changed from
2659 /// `old_last` to `index`, so a caller tracking instance handles must
2660 /// update that one. Returns `None` if `index` was the last element /
2661 /// out of range, or no registry is resident.
2662 pub fn remove_sprite_instance(&mut self, index: usize) -> Option<usize> {
2663 self.sprite_registry
2664 .as_mut()
2665 .and_then(|reg| reg.remove_instance(index))
2666 }
2667
2668 /// Flush queued `write_buffer` uploads by submitting an empty command
2669 /// stream. wgpu stages `write_buffer` data and flushes it on the next
2670 /// `Queue::submit`; calling this between batches of uploads (e.g. a
2671 /// flipbook's frames in [`Self::add_sprite_model`]) recycles the device
2672 /// staging pool so a big one-shot batch can't exhaust it (which would
2673 /// then crash egui-wgpu's own `write_buffer`).
2674 pub fn flush_writes(&self) {
2675 self.queue.submit(std::iter::empty::<wgpu::CommandBuffer>());
2676 }
2677
2678 /// Incrementally add model `chain_id` (its full LOD chain) from
2679 /// `registry` to the resident sprite registry **without**
2680 /// re-uploading the existing models — the streaming-in counterpart
2681 /// to [`Self::append_sprite_instances`]. Register the model on the
2682 /// CPU registry first (`add` / `add_lod`), then pass the returned
2683 /// `chain_id` here; afterwards instances may reference it.
2684 ///
2685 /// If no registry is resident yet, this instead performs the
2686 /// initial full upload of `registry` (all current models, zero
2687 /// instances) to establish residency. Cost is amortised O(new
2688 /// model voxels): the shared volume buffers carry slack and
2689 /// bump-append, rebuilding from the registry only on overflow.
2690 pub fn add_sprite_model(
2691 &mut self,
2692 registry: &sprite_model::SpriteModelRegistry,
2693 chain_id: u32,
2694 ) {
2695 match self.sprite_registry.as_mut() {
2696 Some(reg) => reg.add_model(&self.device, &self.queue, registry, chain_id),
2697 None => {
2698 self.sprite_registry = Some(sprite_model::SpriteRegistryResident::upload(
2699 &self.device,
2700 registry,
2701 &[],
2702 ));
2703 }
2704 }
2705 }
2706
2707 /// Remove a model (tombstone its LOD chain) from the resident sprite
2708 /// registry — the counterpart to [`Self::add_sprite_model`]. Frees its
2709 /// `colors`/`dirs` space for reuse by a later add; the smaller
2710 /// `occupancy`/`color_offsets` holes are reclaimed by
2711 /// [`Self::compact_sprite_models`]. Entry / chain ids stay stable, so
2712 /// other models' `chain_id`s remain valid.
2713 ///
2714 /// Instances of the removed model keep their slots but draw as nothing
2715 /// until the caller drops them via [`Self::remove_sprite_instance`].
2716 /// No-op if `chain_id` is unknown / already removed / no registry.
2717 pub fn remove_sprite_model(&mut self, chain_id: u32) {
2718 if let Some(reg) = self.sprite_registry.as_mut() {
2719 reg.remove_model(chain_id);
2720 }
2721 }
2722
2723 /// Reclaim the holes left by [`Self::remove_sprite_model`] by rebuilding
2724 /// the shared volume buffers from the live models only. `registry` must
2725 /// be the resident one. Cost is O(live volume) — call it when
2726 /// [`Self::dead_sprite_model_count`] is high (e.g. exceeds the live
2727 /// count), not every frame. No-op if no registry is resident.
2728 pub fn compact_sprite_models(&mut self, registry: &sprite_model::SpriteModelRegistry) {
2729 if let Some(reg) = self.sprite_registry.as_mut() {
2730 reg.compact(&self.device, &self.queue, registry);
2731 }
2732 }
2733
2734 /// Number of live (non-removed) sprite models (0 if none uploaded).
2735 #[must_use]
2736 pub fn sprite_model_count(&self) -> usize {
2737 self.sprite_registry
2738 .as_ref()
2739 .map_or(0, sprite_model::SpriteRegistryResident::live_model_count)
2740 }
2741
2742 /// Number of removed-but-not-yet-compacted sprite models — the
2743 /// fragmentation signal for deciding when to call
2744 /// [`Self::compact_sprite_models`].
2745 #[must_use]
2746 pub fn dead_sprite_model_count(&self) -> usize {
2747 self.sprite_registry
2748 .as_ref()
2749 .map_or(0, sprite_model::SpriteRegistryResident::dead_model_count)
2750 }
2751
2752 /// Number of resident sprite instances (0 if none uploaded).
2753 #[must_use]
2754 pub fn sprite_instance_count(&self) -> usize {
2755 self.sprite_registry
2756 .as_ref()
2757 .map_or(0, sprite_model::SpriteRegistryResident::instance_count)
2758 }
2759
2760 /// Re-pose the already-resident sprite instances in place (no model
2761 /// volume re-upload) — the cheap per-frame path for animated KFA
2762 /// limbs. `instances` must match the last [`Self::set_sprite_instances`]
2763 /// in length + order. No-op if no sprite registry is resident.
2764 pub fn update_sprite_instance_transforms(
2765 &mut self,
2766 instances: &[sprite_model::SpriteInstance],
2767 ) {
2768 if let Some(reg) = self.sprite_registry.as_mut() {
2769 reg.update_transforms(instances);
2770 }
2771 }
2772
2773 /// GPU.12 incremental — re-upload only LOD chain `chain_id`'s entries
2774 /// after an in-place edit of `registry` (carve / recolour), without
2775 /// rebuilding the whole sprite registry. `registry` must be the one
2776 /// last passed to [`Self::set_sprite_instances`] with chain
2777 /// `chain_id` already edited. No-op if no registry is resident.
2778 pub fn update_sprite_model(
2779 &mut self,
2780 registry: &sprite_model::SpriteModelRegistry,
2781 chain_id: u32,
2782 ) {
2783 if let Some(reg) = self.sprite_registry.as_mut() {
2784 reg.update_model(&self.device, &self.queue, registry, chain_id);
2785 }
2786 }
2787
2788 /// VCL.2 — repoint sprite instance `index` at LOD chain `chain_id`
2789 /// (the per-frame flipbook step for animated voxel clips). `registry`
2790 /// is the resident one; `chain_id`'s volume must already be uploaded
2791 /// (e.g. a clip's frames registered via [`Self::add_sprite_model`]).
2792 /// CPU-side rewrite picked up by the next frame's cull — no volume
2793 /// re-upload. No-op if no registry is resident.
2794 pub fn set_sprite_instance_model(
2795 &mut self,
2796 registry: &sprite_model::SpriteModelRegistry,
2797 index: usize,
2798 chain_id: u32,
2799 ) {
2800 if let Some(reg) = self.sprite_registry.as_mut() {
2801 reg.set_instance_model(registry, index, chain_id);
2802 }
2803 }
2804
2805 /// Set the per-instance `kv6colmul[256]` lighting tables (voxlap's
2806 /// `update_reflects` output, e.g. via `roxlap_core::sprite::
2807 /// sprite_colmul`), in the same order/length as the last
2808 /// [`Self::set_sprite_instances`]. The GPU sprite pass modulates each
2809 /// voxel by its surface normal's entry — matching the CPU rasteriser.
2810 /// No-op if no sprite registry is resident.
2811 pub fn set_sprite_instance_colmul(&mut self, tables: &[[u64; 256]]) {
2812 if let Some(reg) = self.sprite_registry.as_mut() {
2813 reg.set_instance_colmul(tables);
2814 }
2815 }
2816
2817 /// GPU.10.4 — set the LOD pixel threshold: a sprite steps to the
2818 /// next mip once a mip-0 voxel would project below `px` screen
2819 /// pixels. `1.0` is the natural "no sub-pixel voxels" default;
2820 /// larger values force LOD in closer (useful for inspection).
2821 /// Clamped to ≥ 0.25.
2822 pub fn set_sprite_lod_px(&mut self, px: f32) {
2823 self.sprite_lod_px = px.max(0.25);
2824 }
2825
2826 /// GPU.11.1 — set the scene-grid LOD scan distance (world units).
2827 /// A chunk entered at world-t `t` is marched at mip
2828 /// `floor(log2(max(t, msd) / msd))`, clamped to its grid's mip
2829 /// ladder. `0` disables LOD (always mip-0). Larger values push
2830 /// the coarser mips farther out — the axis-aligned-mip-beams
2831 /// mitigation lever (GPU.11.2). Default 64 (matches CPU
2832 /// `mip_scan_dist`).
2833 pub fn set_scene_mip_scan_dist(&mut self, dist: f32) {
2834 self.scene_mip_scan_dist = dist.max(0.0);
2835 }
2836
2837 /// Set per-face grid side-shading — voxlap's
2838 /// `setsideshades(top, bot, left, right, up, down)`. Each value is
2839 /// subtracted (as a u8, matching the CPU `gcsub` high byte) from a
2840 /// hit voxel's brightness byte before shading, so the scene-DDA pass
2841 /// darkens grid faces the same way the CPU rasteriser does. `[0; 6]`
2842 /// disables it (the default). The hit face is taken from the DDA's
2843 /// last-stepped axis + ray direction.
2844 pub fn set_scene_side_shades(&mut self, s: [i8; 6]) {
2845 // Reinterpret each i8 as u8 (voxlap stamps `sxx` into gcsub's
2846 // high byte verbatim), then pack (top, bot, left, right) /
2847 // (up, down, 0, 0) for the two uniform vec4s.
2848 let v = |i: usize| i32::from(s[i] as u8);
2849 self.scene_side_shades = [[v(0), v(1), v(2), v(3)], [v(4), v(5), 0, 0]];
2850 }
2851
2852 /// GPU.10.1 — build the instanced model-DDA pipeline (one thread
2853 /// per pixel). Lazily invoked the first frame a registry is present.
2854 fn build_sprite_model_dda(&self) -> SpriteModelDdaResources {
2855 // XS.4.2 — on sprite-shadow-capable devices, splice the terrain shadow
2856 // snippet over the stub (`shadow_occluded_world` becomes a real terrain
2857 // march; binds occupancy 16..23). Otherwise the stub keeps sprites
2858 // unshadowed and the BGL stays at the base 14 storage buffers.
2859 let capable = self.sprite_shadows_capable;
2860 let src = sprite_shader_source(capable);
2861 let shader = self
2862 .device
2863 .create_shader_module(wgpu::ShaderModuleDescriptor {
2864 label: Some("sprite_model_dda.wgsl"),
2865 source: wgpu::ShaderSource::Wgsl(src.into()),
2866 });
2867 let mut entries = vec![
2868 bgl_uniform_entry(0),
2869 bgl_storage_entry(1, true), // occupancy
2870 bgl_storage_entry(2, true), // colors
2871 bgl_storage_entry(3, true), // color_offsets
2872 bgl_storage_entry(4, true), // model_meta
2873 bgl_storage_entry(5, true), // instances
2874 bgl_storage_entry(6, true), // scene depth
2875 bgl_storage_entry(7, false), // framebuffer (read-write buffer)
2876 bgl_storage_entry(8, true), // tile_ranges
2877 bgl_storage_entry(9, true), // tile_instances
2878 bgl_storage_entry(10, true), // per-voxel dir
2879 bgl_storage_entry(11, true), // per-instance kv6colmul
2880 bgl_storage_entry(12, true), // TV — material palette
2881 bgl_storage_entry(13, true), // TV.3 — per-voxel material id
2882 bgl_storage_entry(15, true), // DL.7 — world point lights
2883 ];
2884 if capable {
2885 // XS.4.2 — terrain occupancy set for sprite RECEIVE shadows.
2886 entries.push(bgl_storage_entry(16, true)); // occ_page0
2887 entries.push(bgl_storage_entry(17, true)); // occ_page1
2888 entries.push(bgl_storage_entry(18, true)); // occ_page2
2889 entries.push(bgl_storage_entry(19, true)); // occ_page3
2890 entries.push(bgl_storage_entry(20, true)); // all_chunk_occupancy
2891 entries.push(bgl_storage_entry(21, true)); // all_slot_chunk_idx
2892 entries.push(bgl_storage_entry(22, true)); // grid_static_meta
2893 entries.push(bgl_storage_entry(23, true)); // grid_cameras
2894 }
2895 let bgl = self
2896 .device
2897 .create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
2898 label: Some("roxlap-gpu sprite_model_dda.bgl"),
2899 entries: &entries,
2900 });
2901 let pl = self
2902 .device
2903 .create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
2904 label: Some("roxlap-gpu sprite_model_dda.layout"),
2905 bind_group_layouts: &[Some(&bgl)],
2906 immediate_size: 0,
2907 });
2908 let pipeline = self
2909 .device
2910 .create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
2911 label: Some("roxlap-gpu sprite_model_dda.pipeline"),
2912 layout: Some(&pl),
2913 module: &shader,
2914 entry_point: Some("march"),
2915 compilation_options: wgpu::PipelineCompilationOptions::default(),
2916 cache: None,
2917 });
2918 let uniform_buf = self.device.create_buffer(&wgpu::BufferDescriptor {
2919 label: Some("roxlap-gpu sprite_model_dda.uniform"),
2920 size: std::mem::size_of::<SpriteModelUniform>() as u64,
2921 usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
2922 mapped_at_creation: false,
2923 });
2924 // TV — material palette, seeded from the current renderer state so a
2925 // table defined before the sprite pass was built still takes effect.
2926 let materials_buf = {
2927 use wgpu::util::DeviceExt;
2928 self.device
2929 .create_buffer_init(&wgpu::util::BufferInitDescriptor {
2930 label: Some("roxlap-gpu sprite_model_dda.materials"),
2931 contents: bytemuck::cast_slice(self.sprite_materials.as_slice()),
2932 usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
2933 })
2934 };
2935 SpriteModelDdaResources {
2936 bgl,
2937 pipeline,
2938 uniform_buf,
2939 materials_buf,
2940 }
2941 }
2942
2943 /// TV — set the global voxel-material palette for the GPU sprite pass.
2944 /// Mirrors the renderer's [`MaterialTable`](roxlap_formats::material::MaterialTable):
2945 /// every sprite/clip instance's `material` id indexes it for opacity +
2946 /// blend mode. Cheap (2 KB); call it whenever the palette changes (or
2947 /// each frame). While every material is opaque the shader stays on the
2948 /// unchanged first-hit path.
2949 pub fn set_sprite_materials(&mut self, table: &roxlap_formats::material::MaterialTable) {
2950 let (palette, any_translucent, any_emissive) = material_palette(table);
2951 self.sprite_materials = palette;
2952 self.sprite_has_translucent = any_translucent;
2953 self.sprite_has_emissive = any_emissive;
2954 if let Some(smd) = &self.sprite_model_dda {
2955 self.queue.write_buffer(
2956 &smd.materials_buf,
2957 0,
2958 bytemuck::cast_slice(self.sprite_materials.as_slice()),
2959 );
2960 }
2961 }
2962
2963 /// TV.6 — set the scene (terrain) material palette + colour→material map
2964 /// for the multi-grid scene pass. Matching-colour terrain voxels render
2965 /// translucent (and/or emissive, EV.2); an empty map / all-opaque
2966 /// non-emissive palette renders unchanged. The map is capped at 256 rows
2967 /// (the fixed buffer size).
2968 pub fn set_scene_terrain_materials(
2969 &mut self,
2970 table: &roxlap_formats::material::MaterialTable,
2971 map: &[(Rgb, u8)],
2972 ) {
2973 let (palette, _, _) = material_palette(table);
2974 self.scene_materials = palette;
2975 self.scene_terrain_map = map
2976 .iter()
2977 .take(256)
2978 .map(|&(c, m)| [c.0 & 0x00ff_ffff, u32::from(m)])
2979 .collect();
2980 // EV.2 — the material path also activates for emissive mappings
2981 // (an opaque glowing palette must leave the first-hit fast path).
2982 self.scene_terrain_translucent = map.iter().any(|&(_, m)| {
2983 let mm = table.get(m);
2984 !mm.is_opaque() || mm.emissive > 0
2985 });
2986 if let Some(dda) = &self.scene_dda {
2987 self.queue.write_buffer(
2988 &dda.materials_pal_buf,
2989 0,
2990 bytemuck::cast_slice(self.scene_materials.as_slice()),
2991 );
2992 if !self.scene_terrain_map.is_empty() {
2993 self.queue.write_buffer(
2994 &dda.terrain_map_buf,
2995 0,
2996 bytemuck::cast_slice(&self.scene_terrain_map),
2997 );
2998 }
2999 }
3000 }
3001}
3002
3003/// GPU.11 — headless scene-DDA renderer for tests + offline visual
3004/// gates. Owns the `scene_dda.wgsl` compute pipeline with no surface
3005/// and no blit pass; renders a [`GpuSceneResident`] to an in-memory
3006/// RGBA framebuffer via texture readback. The per-substage visual
3007/// gate (render reference scenes, diff PPMs) and the GPU.11.1 mip
3008/// render-diff both ride on this.
3009pub struct HeadlessSceneRenderer {
3010 width: u32,
3011 height: u32,
3012 /// Framebuffer storage buffer (packed `rgba8unorm`, tight rows) —
3013 /// matches the buffer-output `scene_dda.wgsl` (see its note).
3014 framebuffer: wgpu::Buffer,
3015 depth_buffer: wgpu::Buffer,
3016 uniform_buf: wgpu::Buffer,
3017 _sky_texture: wgpu::Texture,
3018 sky_view: wgpu::TextureView,
3019 sky_sampler: wgpu::Sampler,
3020 bgl: wgpu::BindGroupLayout,
3021 pipeline: wgpu::ComputePipeline,
3022 readback: wgpu::Buffer,
3023 /// Staging copy of `depth_buffer` for the depth-readback path.
3024 depth_readback: wgpu::Buffer,
3025 /// Per-face side-shades for the gate render (default none). Packed
3026 /// `[(top,bot,left,right), (up,down,_,_)]`; set via
3027 /// [`Self::set_side_shades`].
3028 side_shades: [[i32; 4]; 2],
3029 /// DL — dynamic lights for the render (already grid-local, like the
3030 /// surface path). Default = none (baked-only). Set via
3031 /// [`Self::set_scene_lights`]; lets tests exercise the lit path.
3032 lights: SceneLights,
3033 /// EV — terrain material palette + colour→material map for the render
3034 /// (mirror of the surface path's `set_scene_terrain_materials`).
3035 /// Default: all-opaque non-emissive palette + empty map ⇒ the gate
3036 /// stays 0 and the shader keeps the pre-material fast path. Set via
3037 /// [`Self::set_terrain_materials`]; lets CI diff the GPU emissive /
3038 /// translucent material branch against the CPU.
3039 terrain_materials: Box<[MaterialGpu; 256]>,
3040 terrain_map: Vec<[u32; 2]>,
3041 terrain_translucent: bool,
3042}
3043
3044impl HeadlessSceneRenderer {
3045 /// Build the compute pipeline + output/readback resources for a
3046 /// `width × height` framebuffer. Validates `scene_dda.wgsl` and
3047 /// the [`scene::GridStaticMeta`] std430 layout at pipeline /
3048 /// bind-group time.
3049 #[must_use]
3050 pub fn new(device: &wgpu::Device, queue: &wgpu::Queue, width: u32, height: u32) -> Self {
3051 let framebuffer = device.create_buffer(&wgpu::BufferDescriptor {
3052 label: Some("roxlap-gpu headless.framebuffer"),
3053 size: u64::from(width) * u64::from(height) * 4,
3054 usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC,
3055 mapped_at_creation: false,
3056 });
3057
3058 let uniform_buf = device.create_buffer(&wgpu::BufferDescriptor {
3059 label: Some("roxlap-gpu headless.uniform"),
3060 size: std::mem::size_of::<SceneDdaUniform>() as u64,
3061 usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
3062 mapped_at_creation: false,
3063 });
3064 let depth_buffer = device.create_buffer(&wgpu::BufferDescriptor {
3065 label: Some("roxlap-gpu headless.depth"),
3066 size: u64::from(width) * u64::from(height) * 4,
3067 // COPY_SRC so the depth-readback path can stage it (the vws≠1
3068 // depth-parity test reads back `best_t`).
3069 usage: wgpu::BufferUsages::STORAGE
3070 | wgpu::BufferUsages::COPY_DST
3071 | wgpu::BufferUsages::COPY_SRC,
3072 mapped_at_creation: false,
3073 });
3074
3075 let default_sky_pixel = [120u8, 150, 220, 255];
3076 let (sky_texture, sky_view) = create_sky_texture(device, 1, 1, &default_sky_pixel);
3077 // Upload the default sky texel (create_sky_texture only allocates
3078 // — the texel must be written or the shader samples black, which
3079 // is why a grid-less headless render came back black).
3080 queue.write_texture(
3081 wgpu::TexelCopyTextureInfo {
3082 texture: &sky_texture,
3083 mip_level: 0,
3084 origin: wgpu::Origin3d::ZERO,
3085 aspect: wgpu::TextureAspect::All,
3086 },
3087 &default_sky_pixel,
3088 wgpu::TexelCopyBufferLayout {
3089 offset: 0,
3090 bytes_per_row: Some(4),
3091 rows_per_image: Some(1),
3092 },
3093 wgpu::Extent3d {
3094 width: 1,
3095 height: 1,
3096 depth_or_array_layers: 1,
3097 },
3098 );
3099 let sky_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
3100 label: Some("roxlap-gpu headless.sky_sampler"),
3101 address_mode_u: wgpu::AddressMode::Repeat,
3102 address_mode_v: wgpu::AddressMode::Repeat,
3103 mag_filter: wgpu::FilterMode::Linear,
3104 min_filter: wgpu::FilterMode::Linear,
3105 ..Default::default()
3106 });
3107
3108 let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
3109 label: Some("scene_dda.wgsl (headless)"),
3110 // QE.8 — assembled source (common snippet + stub variant);
3111 // the raw file is no longer standalone-valid WGSL.
3112 source: wgpu::ShaderSource::Wgsl(crate::shader_src::scene_shader_source(false).into()),
3113 });
3114 let bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
3115 label: Some("roxlap-gpu headless.bgl"),
3116 entries: &[
3117 bgl_uniform_entry(0),
3118 bgl_storage_entry(1, true),
3119 bgl_storage_entry(2, true),
3120 bgl_storage_entry(3, true),
3121 bgl_storage_entry(4, true),
3122 bgl_storage_entry(5, true),
3123 bgl_storage_entry(6, true),
3124 bgl_storage_entry(7, true),
3125 // Framebuffer storage buffer (read-write).
3126 bgl_storage_entry(8, false),
3127 wgpu::BindGroupLayoutEntry {
3128 binding: 9,
3129 visibility: wgpu::ShaderStages::COMPUTE,
3130 ty: wgpu::BindingType::Texture {
3131 sample_type: wgpu::TextureSampleType::Float { filterable: true },
3132 view_dimension: wgpu::TextureViewDimension::D2,
3133 multisampled: false,
3134 },
3135 count: None,
3136 },
3137 wgpu::BindGroupLayoutEntry {
3138 binding: 10,
3139 visibility: wgpu::ShaderStages::COMPUTE,
3140 ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
3141 count: None,
3142 },
3143 bgl_storage_entry(11, false),
3144 bgl_storage_entry(12, true),
3145 bgl_storage_entry(13, true),
3146 bgl_storage_entry(14, true),
3147 // Per-grid cameras (runtime-sized; one per grid).
3148 bgl_storage_entry(15, true),
3149 // TV.6 — material palette + terrain map (opaque dummies here).
3150 bgl_storage_entry(16, true),
3151 bgl_storage_entry(17, true),
3152 // DL — per-grid point lights (18). Sun dir rides in
3153 // PerGridCamera (binding 15).
3154 bgl_storage_entry(18, true),
3155 ],
3156 });
3157 let pl = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
3158 label: Some("roxlap-gpu headless.layout"),
3159 bind_group_layouts: &[Some(&bgl)],
3160 immediate_size: 0,
3161 });
3162 let pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
3163 label: Some("roxlap-gpu headless.pipeline"),
3164 layout: Some(&pl),
3165 module: &shader,
3166 entry_point: Some("render_scene"),
3167 compilation_options: wgpu::PipelineCompilationOptions::default(),
3168 cache: None,
3169 });
3170
3171 // Readback is a tight buffer-to-buffer copy (no 256-byte row
3172 // padding, unlike the old texture-to-buffer path).
3173 let readback = device.create_buffer(&wgpu::BufferDescriptor {
3174 label: Some("roxlap-gpu headless.readback"),
3175 size: u64::from(width) * u64::from(height) * 4,
3176 usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
3177 mapped_at_creation: false,
3178 });
3179 let depth_readback = device.create_buffer(&wgpu::BufferDescriptor {
3180 label: Some("roxlap-gpu headless.depth_readback"),
3181 size: u64::from(width) * u64::from(height) * 4,
3182 usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
3183 mapped_at_creation: false,
3184 });
3185
3186 Self {
3187 width,
3188 height,
3189 framebuffer,
3190 depth_buffer,
3191 uniform_buf,
3192 _sky_texture: sky_texture,
3193 sky_view,
3194 sky_sampler,
3195 bgl,
3196 pipeline,
3197 readback,
3198 depth_readback,
3199 side_shades: [[0; 4]; 2],
3200 lights: SceneLights::default(),
3201 terrain_materials: Box::new(
3202 [MaterialGpu {
3203 alpha: 1.0,
3204 mode: 0,
3205 emissive: 0.0,
3206 _pad: 0,
3207 }; 256],
3208 ),
3209 terrain_map: Vec::new(),
3210 terrain_translucent: false,
3211 }
3212 }
3213
3214 /// EV — set the terrain material palette + colour→material map for
3215 /// subsequent renders (the headless mirror of
3216 /// [`GpuRenderer::set_scene_terrain_materials`]): matching-colour
3217 /// terrain voxels render translucent and/or emissive. An empty map /
3218 /// all-opaque non-emissive mapping keeps the gate 0 — the unchanged
3219 /// pre-material fast path.
3220 pub fn set_terrain_materials(
3221 &mut self,
3222 table: &roxlap_formats::material::MaterialTable,
3223 map: &[(Rgb, u8)],
3224 ) {
3225 let (palette, _, _) = material_palette(table);
3226 self.terrain_materials = palette;
3227 self.terrain_map = map
3228 .iter()
3229 .take(256)
3230 .map(|&(c, m)| [c.0 & 0x00ff_ffff, u32::from(m)])
3231 .collect();
3232 // EV.2 — the material path also activates for emissive mappings
3233 // (an opaque glowing palette must leave the first-hit fast path).
3234 self.terrain_translucent = map.iter().any(|&(_, m)| {
3235 let mm = table.get(m);
3236 !mm.is_opaque() || mm.emissive > 0
3237 });
3238 }
3239
3240 /// Set per-face side-shades for subsequent [`Self::render`] calls —
3241 /// voxlap `setsideshades(top, bot, left, right, up, down)`, each an
3242 /// i8 stamped as u8 (matching the engine path). Lets the gate test
3243 /// the GPU side-shade darkening.
3244 pub fn set_side_shades(&mut self, s: [i8; 6]) {
3245 let v = |i: usize| i32::from(s[i] as u8);
3246 self.side_shades = [[v(0), v(1), v(2), v(3)], [v(4), v(5), 0, 0]];
3247 }
3248
3249 /// Render `scene` from `cameras` (one per grid) and read the
3250 /// framebuffer back as `width*height` packed `0xAABBGGRR` pixels
3251 /// (R in the low byte). Fog is disabled. `mip_scan_dist` drives
3252 /// the GPU.11.1 scene-grid LOD (`0` = always mip-0). Blocks on
3253 /// readback.
3254 ///
3255 /// # Panics
3256 /// If `cameras.len() != scene.grid_count`.
3257 /// Headless render with identity per-grid world transforms (shadows stay
3258 /// intra-grid). See [`Self::render_with_transforms`] for the cross-grid
3259 /// (XS.3) variant.
3260 #[must_use]
3261 #[allow(clippy::too_many_arguments)]
3262 pub fn render(
3263 &self,
3264 device: &wgpu::Device,
3265 queue: &wgpu::Queue,
3266 scene: &GpuSceneResident,
3267 cameras: &[Camera],
3268 fov_y_rad: f32,
3269 max_outer_steps: u32,
3270 mip_scan_dist: f32,
3271 ) -> Vec<u32> {
3272 self.render_with_transforms(
3273 device,
3274 queue,
3275 scene,
3276 cameras,
3277 &[],
3278 fov_y_rad,
3279 max_outer_steps,
3280 mip_scan_dist,
3281 )
3282 }
3283
3284 /// XS.3 — headless render with explicit per-grid world transforms, so the
3285 /// scene shader can lift a shadow ray to world space and test it against
3286 /// every grid (cross-grid shadows). Empty `grid_world` ⇒ identity.
3287 #[must_use]
3288 #[allow(clippy::too_many_arguments)]
3289 #[allow(clippy::too_many_arguments)]
3290 pub fn render_with_transforms(
3291 &self,
3292 device: &wgpu::Device,
3293 queue: &wgpu::Queue,
3294 scene: &GpuSceneResident,
3295 cameras: &[Camera],
3296 grid_world: &[GridWorldTransform],
3297 fov_y_rad: f32,
3298 max_outer_steps: u32,
3299 mip_scan_dist: f32,
3300 ) -> Vec<u32> {
3301 self.dispatch(
3302 device,
3303 queue,
3304 scene,
3305 cameras,
3306 grid_world,
3307 fov_y_rad,
3308 max_outer_steps,
3309 mip_scan_dist,
3310 false,
3311 )
3312 .0
3313 }
3314
3315 /// Like [`Self::render_with_transforms`] but also reads back the depth
3316 /// buffer (`best_t` per pixel, WORLD units) — the vws≠1 CPU-vs-GPU depth
3317 /// parity check. `f32::INFINITY` where a ray hit nothing.
3318 #[allow(clippy::too_many_arguments)]
3319 pub fn render_depth_with_transforms(
3320 &self,
3321 device: &wgpu::Device,
3322 queue: &wgpu::Queue,
3323 scene: &GpuSceneResident,
3324 cameras: &[Camera],
3325 grid_world: &[GridWorldTransform],
3326 fov_y_rad: f32,
3327 max_outer_steps: u32,
3328 mip_scan_dist: f32,
3329 ) -> Vec<f32> {
3330 self.dispatch(
3331 device,
3332 queue,
3333 scene,
3334 cameras,
3335 grid_world,
3336 fov_y_rad,
3337 max_outer_steps,
3338 mip_scan_dist,
3339 true,
3340 )
3341 .1
3342 }
3343
3344 #[allow(clippy::too_many_arguments)]
3345 fn dispatch(
3346 &self,
3347 device: &wgpu::Device,
3348 queue: &wgpu::Queue,
3349 scene: &GpuSceneResident,
3350 cameras: &[Camera],
3351 grid_world: &[GridWorldTransform],
3352 fov_y_rad: f32,
3353 max_outer_steps: u32,
3354 mip_scan_dist: f32,
3355 want_depth: bool,
3356 ) -> (Vec<u32>, Vec<f32>) {
3357 assert_eq!(
3358 cameras.len(),
3359 scene.grid_count as usize,
3360 "headless render: {} cameras for {} grids",
3361 cameras.len(),
3362 scene.grid_count,
3363 );
3364
3365 let mut cam_vec: Vec<SceneDdaPerGridCamera> = cameras
3366 .iter()
3367 .map(SceneDdaPerGridCamera::from_camera)
3368 .collect();
3369 // XS.3 — stamp world transforms for cross-grid shadows (identity if absent).
3370 for (c, t) in cam_vec.iter_mut().zip(grid_world.iter()) {
3371 c.set_world_transform(t);
3372 }
3373 // TV.6/EV — material palette + terrain map bindings, from the
3374 // renderer's plumbed state (defaults: all-opaque non-emissive +
3375 // empty map ⇒ gate 0, the pre-material fast path). An empty map
3376 // pads one zero row (wgpu rejects a zero-sized storage binding).
3377 let (mat_pal_buf, terrain_map_buf) = {
3378 use wgpu::util::DeviceExt;
3379 let p = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
3380 label: Some("roxlap-gpu headless.materials_pal"),
3381 contents: bytemuck::cast_slice(self.terrain_materials.as_slice()),
3382 usage: wgpu::BufferUsages::STORAGE,
3383 });
3384 let zero_row = [[0u32; 2]];
3385 let rows: &[[u32; 2]] = if self.terrain_map.is_empty() {
3386 &zero_row
3387 } else {
3388 &self.terrain_map
3389 };
3390 let m = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
3391 label: Some("roxlap-gpu headless.terrain_map"),
3392 contents: bytemuck::cast_slice(rows),
3393 usage: wgpu::BufferUsages::STORAGE,
3394 });
3395 (p, m)
3396 };
3397 // DL — pack any dynamic lights (default none ⇒ the baked-only path,
3398 // matching the oracle goldens). Injects sun dir into cam_vec.sun_dir
3399 // and builds the point-light buffer (binding 18). Shared with the
3400 // surface path.
3401 let dl = self.lights.clone();
3402 inject_grid_sun_dirs(&dl, &mut cam_vec);
3403 let (packed_lights, sun_flags, point_count) =
3404 pack_scene_lights(&dl, scene.grid_count as usize);
3405 let dummy_point_lights = upload_grid_point_lights(device, &packed_lights);
3406 let grid_cameras = upload_grid_cameras(device, &cam_vec);
3407 let uniform = SceneDdaUniform {
3408 fov_y_rad,
3409 grid_count: scene.grid_count,
3410 max_outer_steps,
3411 _pad0: 0,
3412 screen_size: [self.width, self.height],
3413 _pad1: [0; 2],
3414 // Fog off: near/far past any reachable t → factor 0.
3415 fog_color: [0.0, 0.0, 0.0, 1.0e29],
3416 fog_far: 1.0e30,
3417 write_depth: u32::from(want_depth),
3418 occ_page_words: scene.occupancy_page_words,
3419 occ_num_pages: scene.occupancy_num_pages,
3420 mip_scan_dist,
3421 terrain_has_translucent: u32::from(self.terrain_translucent),
3422 terrain_map_count: u32::try_from(self.terrain_map.len()).unwrap_or(0),
3423 _pad4: 0,
3424 // Sky direction from the first grid camera (the world frame
3425 // in these tests); a default forward camera when there are
3426 // none (grid_count == 0) so the sky lookup stays valid.
3427 sky_cam: SceneDdaPerGridCamera::from_camera(&cameras.first().copied().unwrap_or(
3428 Camera {
3429 position: [0.0; 3],
3430 right: [1.0, 0.0, 0.0],
3431 down: [0.0, 0.0, 1.0],
3432 forward: [0.0, 1.0, 0.0],
3433 fov_y_rad,
3434 },
3435 )),
3436 side_shades0: self.side_shades[0],
3437 side_shades1: self.side_shades[1],
3438 // DL — light parameters (default = no lights ⇒ sun_flags 0).
3439 sun_color: [
3440 dl.sun_color[0],
3441 dl.sun_color[1],
3442 dl.sun_color[2],
3443 dl.sun_intensity,
3444 ],
3445 ambient_color: [
3446 dl.ambient[0],
3447 dl.ambient[1],
3448 dl.ambient[2],
3449 dl.shadow_strength,
3450 ],
3451 sun_flags,
3452 point_light_count: point_count,
3453 shadow_max_steps: dl.shadow_max_steps,
3454 _pad5: 0,
3455 shadow_bias: dl.shadow_bias,
3456 shadow_max_dist: dl.shadow_max_dist,
3457 _pad6: [0.0; 2],
3458 shadow_tint: [dl.shadow_tint[0], dl.shadow_tint[1], dl.shadow_tint[2], 0.0],
3459 style_bands: dl.style_bands,
3460 sprite_cast_count: 0, // headless renderer has no sprite pass
3461 _pad7: [0; 2],
3462 };
3463 queue.write_buffer(&self.uniform_buf, 0, bytemuck::bytes_of(&uniform));
3464
3465 let bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
3466 label: Some("roxlap-gpu headless.bg"),
3467 layout: &self.bgl,
3468 entries: &[
3469 wgpu::BindGroupEntry {
3470 binding: 0,
3471 resource: self.uniform_buf.as_entire_binding(),
3472 },
3473 wgpu::BindGroupEntry {
3474 binding: 1,
3475 resource: scene.occupancy_pages[0].as_entire_binding(),
3476 },
3477 wgpu::BindGroupEntry {
3478 binding: 2,
3479 resource: scene.all_color_offsets.as_entire_binding(),
3480 },
3481 wgpu::BindGroupEntry {
3482 binding: 3,
3483 resource: scene.all_colors.as_entire_binding(),
3484 },
3485 wgpu::BindGroupEntry {
3486 binding: 4,
3487 resource: scene.all_chunk_colors_base.as_entire_binding(),
3488 },
3489 wgpu::BindGroupEntry {
3490 binding: 5,
3491 resource: scene.all_chunk_occupancy.as_entire_binding(),
3492 },
3493 wgpu::BindGroupEntry {
3494 binding: 6,
3495 resource: scene.grid_static_meta.as_entire_binding(),
3496 },
3497 wgpu::BindGroupEntry {
3498 binding: 7,
3499 resource: scene.all_slot_chunk_idx.as_entire_binding(),
3500 },
3501 wgpu::BindGroupEntry {
3502 binding: 8,
3503 resource: self.framebuffer.as_entire_binding(),
3504 },
3505 wgpu::BindGroupEntry {
3506 binding: 9,
3507 resource: wgpu::BindingResource::TextureView(&self.sky_view),
3508 },
3509 wgpu::BindGroupEntry {
3510 binding: 10,
3511 resource: wgpu::BindingResource::Sampler(&self.sky_sampler),
3512 },
3513 wgpu::BindGroupEntry {
3514 binding: 11,
3515 resource: self.depth_buffer.as_entire_binding(),
3516 },
3517 wgpu::BindGroupEntry {
3518 binding: 12,
3519 resource: scene.occupancy_pages[1].as_entire_binding(),
3520 },
3521 wgpu::BindGroupEntry {
3522 binding: 13,
3523 resource: scene.occupancy_pages[2].as_entire_binding(),
3524 },
3525 wgpu::BindGroupEntry {
3526 binding: 14,
3527 resource: scene.occupancy_pages[3].as_entire_binding(),
3528 },
3529 wgpu::BindGroupEntry {
3530 binding: 15,
3531 resource: grid_cameras.as_entire_binding(),
3532 },
3533 wgpu::BindGroupEntry {
3534 binding: 16,
3535 resource: mat_pal_buf.as_entire_binding(),
3536 },
3537 wgpu::BindGroupEntry {
3538 binding: 17,
3539 resource: terrain_map_buf.as_entire_binding(),
3540 },
3541 // DL — dummy per-grid point lights (18). Sun dir rides in
3542 // PerGridCamera (binding 15).
3543 wgpu::BindGroupEntry {
3544 binding: 18,
3545 resource: dummy_point_lights.as_entire_binding(),
3546 },
3547 ],
3548 });
3549
3550 let mut enc =
3551 device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
3552 {
3553 let mut pass = enc.begin_compute_pass(&wgpu::ComputePassDescriptor {
3554 label: Some("roxlap-gpu headless.pass"),
3555 timestamp_writes: None,
3556 });
3557 pass.set_pipeline(&self.pipeline);
3558 pass.set_bind_group(0, &bg, &[]);
3559 pass.dispatch_workgroups(self.width.div_ceil(8), self.height.div_ceil(8), 1);
3560 }
3561 let bytes = u64::from(self.width) * u64::from(self.height) * 4;
3562 enc.copy_buffer_to_buffer(&self.framebuffer, 0, &self.readback, 0, bytes);
3563 if want_depth {
3564 enc.copy_buffer_to_buffer(&self.depth_buffer, 0, &self.depth_readback, 0, bytes);
3565 }
3566 queue.submit(Some(enc.finish()));
3567
3568 let slice = self.readback.slice(..);
3569 let (tx, rx) = std::sync::mpsc::channel();
3570 slice.map_async(wgpu::MapMode::Read, move |r| {
3571 let _ = tx.send(r);
3572 });
3573 device.poll(wgpu::PollType::wait_indefinitely()).ok();
3574 rx.recv().expect("map_async channel").expect("map_async");
3575
3576 let data = slice.get_mapped_range();
3577 // Tight `width*height` packed pixels — the shader's
3578 // `pack4x8unorm(vec4(r,g,b,a))` already yields `0xAABBGGRR`
3579 // little-endian, so a straight u32 read reconstructs each pixel.
3580 let out: Vec<u32> = data
3581 .chunks_exact(4)
3582 .map(|px| u32::from_le_bytes([px[0], px[1], px[2], px[3]]))
3583 .collect();
3584 drop(data);
3585 self.readback.unmap();
3586
3587 // Depth: `best_t` is stored as the bit pattern of the f32 world-t.
3588 let depth: Vec<f32> = if want_depth {
3589 let ds = self.depth_readback.slice(..);
3590 let (dtx, drx) = std::sync::mpsc::channel();
3591 ds.map_async(wgpu::MapMode::Read, move |r| {
3592 let _ = dtx.send(r);
3593 });
3594 device.poll(wgpu::PollType::wait_indefinitely()).ok();
3595 drx.recv().expect("depth channel").expect("depth map");
3596 let dd = ds.get_mapped_range();
3597 let v: Vec<f32> = dd
3598 .chunks_exact(4)
3599 .map(|b| f32::from_bits(u32::from_le_bytes([b[0], b[1], b[2], b[3]])))
3600 .collect();
3601 drop(dd);
3602 self.depth_readback.unmap();
3603 v
3604 } else {
3605 Vec::new()
3606 };
3607 (out, depth)
3608 }
3609}
3610
3611fn bgl_uniform_entry(binding: u32) -> wgpu::BindGroupLayoutEntry {
3612 wgpu::BindGroupLayoutEntry {
3613 binding,
3614 visibility: wgpu::ShaderStages::COMPUTE,
3615 ty: wgpu::BindingType::Buffer {
3616 ty: wgpu::BufferBindingType::Uniform,
3617 has_dynamic_offset: false,
3618 min_binding_size: None,
3619 },
3620 count: None,
3621 }
3622}
3623
3624fn bgl_storage_entry(binding: u32, read_only: bool) -> wgpu::BindGroupLayoutEntry {
3625 wgpu::BindGroupLayoutEntry {
3626 binding,
3627 visibility: wgpu::ShaderStages::COMPUTE,
3628 ty: wgpu::BindingType::Buffer {
3629 ty: wgpu::BufferBindingType::Storage { read_only },
3630 has_dynamic_offset: false,
3631 min_binding_size: None,
3632 },
3633 count: None,
3634 }
3635}
3636
3637/// Create a fresh sky panorama texture sized `width × height` with
3638/// the initial pixel data uploaded via `write_texture`. Used by
3639/// `GpuRenderer::new` (1×1 default) and `set_sky_panorama` (host-
3640/// supplied panorama).
3641fn create_sky_texture(
3642 device: &wgpu::Device,
3643 width: u32,
3644 height: u32,
3645 _initial_pixels: &[u8],
3646) -> (wgpu::Texture, wgpu::TextureView) {
3647 let tex = device.create_texture(&wgpu::TextureDescriptor {
3648 label: Some("roxlap-gpu sky_texture"),
3649 size: wgpu::Extent3d {
3650 width,
3651 height,
3652 depth_or_array_layers: 1,
3653 },
3654 mip_level_count: 1,
3655 sample_count: 1,
3656 dimension: wgpu::TextureDimension::D2,
3657 format: wgpu::TextureFormat::Rgba8Unorm,
3658 usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
3659 view_formats: &[],
3660 });
3661 let view = tex.create_view(&wgpu::TextureViewDescriptor::default());
3662 (tex, view)
3663}
3664
3665/// GPU.4 needs to upload a whole grid (~hundreds of MiB) as a few
3666/// storage buffers. wgpu's default `max_storage_buffer_binding_size`
3667/// is 128 MiB, which is just enough for the demo's 32×32 ground
3668/// occupancy (~128 MiB) but not the colour array. We request as
3669/// much as the adapter is willing to give — most desktop GPUs cap
3670/// individual storage buffers at 2-4 GiB; iGPUs often offer the
3671/// full system memory.
3672pub(crate) fn pick_required_limits(adapter_limits: &wgpu::Limits) -> wgpu::Limits {
3673 wgpu::Limits {
3674 max_storage_buffer_binding_size: adapter_limits.max_storage_buffer_binding_size,
3675 max_buffer_size: adapter_limits.max_buffer_size,
3676 // Occupancy paging adds up to MAX_OCC_PAGES-1 extra storage
3677 // bindings; with the scene's other buffers + the GPU.9 depth
3678 // buffer the scene_dda stage needs 16. XS.4 GPU sprite shadows
3679 // need more (the sprite pass binds the terrain occupancy set on
3680 // top of its own — up to `SPRITE_SHADOW_MIN_STORAGE_BUFFERS`), so
3681 // request that many when the adapter offers them; capable devices
3682 // light up sprite shadows, others fall back (still ≥16 for the
3683 // base renderer). Both NVK and lavapipe advertise ≫16.
3684 max_storage_buffers_per_shader_stage: adapter_limits
3685 .max_storage_buffers_per_shader_stage
3686 .min(SPRITE_SHADOW_MIN_STORAGE_BUFFERS),
3687 ..wgpu::Limits::default()
3688 }
3689}
3690
3691/// XS.4 — storage buffers per shader stage needed for GPU sprite shadows. The
3692/// sprite pass binds its own 14 + the terrain occupancy set (occupancy pages
3693/// 0..3, chunk occupancy, slot index, grid meta, per-grid cameras) to march
3694/// terrain shadows. Devices granting fewer fall back to unshadowed GPU sprites.
3695pub(crate) const SPRITE_SHADOW_MIN_STORAGE_BUFFERS: u32 = 22;
3696
3697fn pick_present_mode(modes: &[wgpu::PresentMode]) -> wgpu::PresentMode {
3698 // Prefer Mailbox > Immediate > Fifo. Fifo is the universal
3699 // fallback and the only one Wayland-on-Mesa always offers.
3700 for &m in &[wgpu::PresentMode::Mailbox, wgpu::PresentMode::Immediate] {
3701 if modes.contains(&m) {
3702 return m;
3703 }
3704 }
3705 wgpu::PresentMode::Fifo
3706}