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