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