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 world_origin: [0.0; 4],
873 rot0: [1.0, 0.0, 0.0, 0.0],
874 rot1: [0.0, 1.0, 0.0, 0.0],
875 rot2: [0.0, 0.0, 1.0, 0.0],
876 }
877 }
878
879 /// XS.3 — stamp this grid's world transform (for cross-grid shadows).
880 /// `rot_cols[i]` is the world image of grid-local axis `i` (the
881 /// local→world rotation's columns).
882 fn set_world_transform(&mut self, t: &GridWorldTransform) {
883 self.world_origin = [t.origin[0], t.origin[1], t.origin[2], 0.0];
884 self.rot0 = [t.rot_cols[0][0], t.rot_cols[0][1], t.rot_cols[0][2], 0.0];
885 self.rot1 = [t.rot_cols[1][0], t.rot_cols[1][1], t.rot_cols[1][2], 0.0];
886 self.rot2 = [t.rot_cols[2][0], t.rot_cols[2][1], t.rot_cols[2][2], 0.0];
887 }
888}
889
890/// XS.3 — a grid's world transform for cross-grid shadows: world origin +
891/// the local→world rotation columns (`rot_cols[i]` = world image of grid-local
892/// axis `i`). Built host-side per frame from the grid's `GridTransform` and
893/// handed to `SceneRenderer::render_scene` alongside the per-grid cameras.
894#[derive(Clone, Copy)]
895pub struct GridWorldTransform {
896 /// World position of the grid's local origin, voxel units.
897 pub origin: [f32; 3],
898 /// Local→world rotation as columns: `rot_cols[i]` is the world
899 /// image of grid-local axis `i` (unit vectors for a pure rotation).
900 /// Identity for an unrotated grid.
901 pub rot_cols: [[f32; 3]; 3],
902}
903
904impl Default for GridWorldTransform {
905 fn default() -> Self {
906 Self {
907 origin: [0.0; 3],
908 rot_cols: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
909 }
910 }
911}
912
913#[repr(C)]
914#[derive(Clone, Copy, Pod, Zeroable)]
915struct SceneDdaUniform {
916 fov_y_rad: f32,
917 grid_count: u32,
918 max_outer_steps: u32,
919 _pad0: u32,
920 screen_size: [u32; 2],
921 _pad1: [u32; 2],
922 /// GPU.8 — `[r, g, b, fog_near]`. The `near` distance is packed
923 /// into the colour's alpha channel to keep std140 alignment
924 /// tidy (a bare `f32` after the `vec4` would force extra pads).
925 fog_color: [f32; 4],
926 fog_far: f32,
927 /// GPU.9 — `1` when the sprite pass is active (scene pass then
928 /// records `best_t` into the depth buffer), `0` otherwise.
929 write_depth: u32,
930 /// Occupancy paging: words per storage page (see
931 /// `scene::split_occupancy_pages`). Only consulted by the shader
932 /// when `occ_num_pages > 1`.
933 occ_page_words: u32,
934 /// Number of real occupancy pages (1 on multi-GiB GPUs → the
935 /// shader takes a branch-free single-page read).
936 occ_num_pages: u32,
937 /// GPU.11.1 — scene-grid LOD scan distance (world units). A chunk
938 /// entered at world-t `t` marches at mip
939 /// `floor(log2(max(t, msd) / msd))`, clamped to the grid's mip
940 /// count. `0` disables LOD (always mip-0).
941 mip_scan_dist: f32,
942 /// TV.6 — `1` if any mapped terrain material is translucent OR
943 /// emissive (EV.2) — gates the material lookup + accumulate path;
944 /// `0` ⇒ unchanged opaque first-hit march.
945 terrain_has_translucent: u32,
946 /// TV.6 — number of `(rgb, material_id)` entries in the terrain map.
947 terrain_map_count: u32,
948 _pad4: u32,
949 /// World camera used only to derive the per-pixel sky direction —
950 /// always valid, so a `grid_count == 0` (sprite-only / empty) scene
951 /// still paints a proper sky instead of a degenerate `(0,0,1)`
952 /// (whose `atan2(0,0)` sky lookup samples black).
953 sky_cam: SceneDdaPerGridCamera,
954 /// Per-face side-shade intensities (voxlap setsideshades), each the
955 /// u8 shade subtracted from a voxel's brightness byte at a hit.
956 /// `side_shades0 = (top, bot, left, right)`,
957 /// `side_shades1 = (up, down, _, _)`. All-zero = no shading.
958 side_shades0: [i32; 4],
959 side_shades1: [i32; 4],
960 // ── DL — dynamic lighting (appended; all-zero ⇒ pre-DL render) ──
961 /// `rgb` = sun colour, `w` = sun intensity.
962 sun_color: [f32; 4],
963 /// `rgb` = ambient multiplier on the baked byte, `w` = shadow strength.
964 ambient_color: [f32; 4],
965 /// Bit 0 = sun enabled, bit 1 = sun casts shadow.
966 sun_flags: u32,
967 /// Number of point lights per grid (rows in the binding-18 buffer).
968 point_light_count: u32,
969 /// Shadow-ray step budget (DL.3).
970 shadow_max_steps: u32,
971 _pad5: u32,
972 /// Shadow-ray origin bias along the surface normal (voxel units).
973 shadow_bias: f32,
974 /// Sun shadow-ray length cap (world units).
975 shadow_max_dist: f32,
976 _pad6: [f32; 2],
977 /// DL.6 — stylized ramp's cool shadow tint (rgb; w unused).
978 shadow_tint: [f32; 4],
979 /// DL.6 — cel band count; 0 = smooth (no banding / gradient map).
980 style_bands: u32,
981 /// XS.4.3 — visible sprite-instance count for the scene pass's
982 /// sprite-cast shadow march (sprites cast onto terrain). `0` ⇒ no sprite
983 /// casters (the loop is skipped); only consulted by the capable variant.
984 sprite_cast_count: u32,
985 _pad7: [u32; 2],
986}
987
988impl GpuRenderer {
989 /// Stand up the device + surface + swapchain on `window`. Async
990 /// because `wgpu::Adapter`/`Device` requests are.
991 ///
992 /// `window` is any [`raw-window-handle`] provider (winit, SDL,
993 /// GLFW, …) wrapped in an `Arc`; `size` is its initial physical
994 /// framebuffer size in pixels — passed explicitly so the renderer
995 /// stays decoupled from any one windowing library's size API.
996 ///
997 /// [`raw-window-handle`]: raw_window_handle
998 ///
999 /// # Errors
1000 /// Returns [`GpuInitError`] if surface creation, adapter
1001 /// selection, or device request fails. Hosts treat any error as
1002 /// "fall back to the CPU path".
1003 pub async fn new<W>(
1004 window: Arc<W>,
1005 size: (u32, u32),
1006 settings: GpuRendererSettings,
1007 ) -> Result<Self, GpuInitError>
1008 where
1009 W: HasWindowHandle + HasDisplayHandle + Send + Sync + 'static,
1010 {
1011 let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
1012 let surface = instance.create_surface(window.clone())?;
1013 let adapter = Self::request_adapter(&instance, Some(&surface), settings).await?;
1014 let (device, queue) = Self::request_device(&adapter).await?;
1015 Ok(Self::finish_init(
1016 &adapter, device, queue, surface, size, settings,
1017 ))
1018 }
1019
1020 /// wasm/WebGPU: build the renderer against an HTML `canvas`. No
1021 /// `Send + Sync` bound — wgpu's surface/device/queue are `!Send` on
1022 /// the `+atomics` shared-memory wasm build, and the browser host is
1023 /// single-threaded (`Rc<RefCell<…>>`). The native generic-`W` entry
1024 /// (which carries the bound) isn't reachable on wasm.
1025 ///
1026 /// Probes for an adapter **before** `create_surface`: on wasm,
1027 /// creating the surface calls `canvas.getContext("webgpu")`, which
1028 /// permanently locks the canvas's context type. If we bound it and
1029 /// then found no adapter, a CPU/WebGL2 fallback on the *same* canvas
1030 /// (the facade clones the handle, but it's the same DOM element)
1031 /// would fail with "no webgl2 context". Probing first leaves the
1032 /// canvas pristine when WebGPU is unavailable.
1033 ///
1034 /// # Errors
1035 /// See [`Self::new`].
1036 #[cfg(target_arch = "wasm32")]
1037 pub async fn new_from_canvas(
1038 canvas: web_sys::HtmlCanvasElement,
1039 size: (u32, u32),
1040 settings: GpuRendererSettings,
1041 ) -> Result<Self, GpuInitError> {
1042 let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
1043 // Probe adapter AND device before binding the canvas — both
1044 // `requestAdapter` and `requestDevice` can fail on wasm, and
1045 // `create_surface` permanently locks the canvas to a WebGPU
1046 // context. Creating the surface last keeps the canvas pristine
1047 // for the CPU/WebGL2 fallback on any GPU-init failure.
1048 let adapter = Self::request_adapter(&instance, None, settings).await?;
1049 let (device, queue) = Self::request_device(&adapter).await?;
1050 let surface = instance.create_surface(wgpu::SurfaceTarget::Canvas(canvas))?;
1051 Ok(Self::finish_init(
1052 &adapter, device, queue, surface, size, settings,
1053 ))
1054 }
1055
1056 /// Pick a GPU adapter at the settings' power preference. `None`
1057 /// `compatible_surface` is used on the wasm canvas path so the probe
1058 /// doesn't bind the canvas's context (see [`Self::new_from_canvas`]);
1059 /// WebGPU exposes a single surface-independent adapter, so this is
1060 /// safe there.
1061 async fn request_adapter(
1062 instance: &wgpu::Instance,
1063 compatible_surface: Option<&wgpu::Surface<'static>>,
1064 settings: GpuRendererSettings,
1065 ) -> Result<wgpu::Adapter, GpuInitError> {
1066 // QE-C6: this crate reads no environment — the render facade
1067 // (roxlap-render's `env_config`) resolves the `ROXLAP_GPU_POWER`
1068 // escape hatch into `settings.power_preference` before init.
1069 // `Low` matters on broken hybrid-GPU (PRIME) driver stacks,
1070 // where rendering on the display-owning iGPU avoids the
1071 // cross-GPU present entirely. (A nixos mesa update deadlocked
1072 // the nouveau↔i915 explicit-sync fences: dGPU frames hit the
1073 // drm job timeout and the channel was killed; `low` kept the
1074 // demo alive.)
1075 let power_preference = match settings.power_preference {
1076 PowerPreference::Low => wgpu::PowerPreference::LowPower,
1077 PowerPreference::High => wgpu::PowerPreference::HighPerformance,
1078 };
1079 instance
1080 .request_adapter(&wgpu::RequestAdapterOptions {
1081 power_preference,
1082 compatible_surface,
1083 force_fallback_adapter: false,
1084 })
1085 .await
1086 .map_err(|_| GpuInitError::NoAdapter)
1087 }
1088
1089 /// Request the device + queue from `adapter`. Pulled out of
1090 /// [`Self::finish_init`] so the wasm canvas path can validate the
1091 /// device **before** `create_surface` binds the canvas's WebGPU
1092 /// context — if the device request fails (e.g. a browser that
1093 /// rejects a wgpu-sent limit), the canvas stays pristine for the
1094 /// CPU/WebGL2 fallback instead of being poisoned.
1095 async fn request_device(
1096 adapter: &wgpu::Adapter,
1097 ) -> Result<(wgpu::Device, wgpu::Queue), GpuInitError> {
1098 Ok(adapter
1099 .request_device(&wgpu::DeviceDescriptor {
1100 label: Some("roxlap-gpu device"),
1101 required_features: wgpu::Features::empty(),
1102 required_limits: pick_required_limits(&adapter.limits()),
1103 experimental_features: wgpu::ExperimentalFeatures::disabled(),
1104 memory_hints: wgpu::MemoryHints::default(),
1105 trace: wgpu::Trace::Off,
1106 })
1107 .await?)
1108 }
1109
1110 /// Shared swapchain → sky/sampler setup, run after the adapter +
1111 /// device + surface exist (the surface comes from a window handle on
1112 /// native, or an HTML canvas on wasm — created last on wasm so a
1113 /// failed device request never touches the canvas).
1114 fn finish_init(
1115 adapter: &wgpu::Adapter,
1116 device: wgpu::Device,
1117 queue: wgpu::Queue,
1118 surface: wgpu::Surface<'static>,
1119 size: (u32, u32),
1120 settings: GpuRendererSettings,
1121 ) -> Self {
1122 let info = adapter.get_info();
1123 let adapter_info = format!(
1124 "{name} ({backend:?}, {device_type:?})",
1125 name = info.name,
1126 backend = info.backend,
1127 device_type = info.device_type,
1128 );
1129 let low_power = info.device_type != wgpu::DeviceType::DiscreteGpu;
1130
1131 let caps = surface.get_capabilities(adapter);
1132 // Pick a NON-sRGB, 8-bit swapchain format. Voxlap colours are
1133 // already sRGB-encoded (the slab bytes are display-ready,
1134 // matching what the CPU softbuffer path writes straight to the
1135 // framebuffer with no conversion); an sRGB swapchain would
1136 // re-apply the gamma curve, washing the look out. We also
1137 // *prefer 8-bit BGRA/RGBA* over any other non-sRGB format: some
1138 // adapters (e.g. NVK) advertise a 16-bit-unorm format first,
1139 // and wgpu 29 gates `create_view` on 16-bit-norm formats behind
1140 // the `TEXTURE_FORMAT_16BIT_NORM` device feature (which we don't
1141 // enable, to stay WebGPU-portable). Falls back to the first
1142 // non-sRGB format, then `caps.formats[0]`.
1143 let surface_format = caps
1144 .formats
1145 .iter()
1146 .copied()
1147 .find(|f| {
1148 matches!(
1149 f,
1150 wgpu::TextureFormat::Bgra8Unorm | wgpu::TextureFormat::Rgba8Unorm
1151 )
1152 })
1153 .or_else(|| caps.formats.iter().copied().find(|f| !f.is_srgb()))
1154 .unwrap_or(caps.formats[0]);
1155 let present_mode = if settings.uncapped_present {
1156 pick_present_mode(&caps.present_modes)
1157 } else {
1158 wgpu::PresentMode::Fifo
1159 };
1160 // GPU.11.2 — surface the present mode: `Fifo` is vsync-capped
1161 // (FPS pinned to refresh rate → compute optimisations like the
1162 // mip LOD won't show up in the FPS counter). Mailbox/Immediate
1163 // are uncapped. Wayland under Mesa frequently offers only Fifo.
1164 eprintln!(
1165 "roxlap-gpu: present mode = {present_mode:?} (available: {:?})",
1166 caps.present_modes,
1167 );
1168 let (init_w, init_h) = size;
1169 let surface_config = wgpu::SurfaceConfiguration {
1170 usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
1171 format: surface_format,
1172 width: init_w.max(1),
1173 height: init_h.max(1),
1174 present_mode,
1175 alpha_mode: caps.alpha_modes[0],
1176 view_formats: vec![],
1177 desired_maximum_frame_latency: 2,
1178 };
1179 surface.configure(&device, &surface_config);
1180
1181 // GPU.8 default sky: a 1×1 mid-grey texture. Hosts replace
1182 // it via `set_sky_panorama` with a real equirectangular
1183 // panorama; the default stops the shader sampling
1184 // uninitialised memory before that happens.
1185 let default_sky_pixel = [0x80u8, 0x80, 0x80, 0xff];
1186 let (sky_texture, sky_view) = create_sky_texture(&device, 1, 1, &default_sky_pixel);
1187 queue.write_texture(
1188 wgpu::TexelCopyTextureInfo {
1189 texture: &sky_texture,
1190 mip_level: 0,
1191 origin: wgpu::Origin3d::ZERO,
1192 aspect: wgpu::TextureAspect::All,
1193 },
1194 &default_sky_pixel,
1195 wgpu::TexelCopyBufferLayout {
1196 offset: 0,
1197 bytes_per_row: Some(4),
1198 rows_per_image: Some(1),
1199 },
1200 wgpu::Extent3d {
1201 width: 1,
1202 height: 1,
1203 depth_or_array_layers: 1,
1204 },
1205 );
1206 let sky_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
1207 label: Some("roxlap-gpu sky_sampler"),
1208 // Voxlap-convention panorama: u = elevation [0, 1]
1209 // (Repeat is a no-op since values don't go outside),
1210 // v = azimuth (wraps 360° — Repeat is required).
1211 address_mode_u: wgpu::AddressMode::Repeat,
1212 address_mode_v: wgpu::AddressMode::Repeat,
1213 address_mode_w: wgpu::AddressMode::ClampToEdge,
1214 mag_filter: wgpu::FilterMode::Linear,
1215 min_filter: wgpu::FilterMode::Linear,
1216 mipmap_filter: wgpu::MipmapFilterMode::Nearest,
1217 ..Default::default()
1218 });
1219
1220 // XS.4 — did the device grant enough storage buffers per stage for the
1221 // GPU sprite-shadow cross-pass bindings? If not, sprites render
1222 // unshadowed (the CPU backend still has full sprite shadows).
1223 let sprite_shadows_capable = device.limits().max_storage_buffers_per_shader_stage
1224 >= SPRITE_SHADOW_MIN_STORAGE_BUFFERS;
1225
1226 Self {
1227 surface,
1228 surface_config,
1229 device,
1230 queue,
1231 adapter_info,
1232 low_power,
1233 clear_colour: settings.clear_colour,
1234 frame_count: 0,
1235 flip_x: false,
1236 render_res: RenderResolution::Native,
1237 ssaa: 1,
1238 posterize: None,
1239 scene_dda: None,
1240 scene_materials: Box::new(
1241 [MaterialGpu {
1242 alpha: 1.0,
1243 mode: 0,
1244 emissive: 0.0,
1245 _pad: 0,
1246 }; 256],
1247 ),
1248 scene_terrain_map: Vec::new(),
1249 scene_terrain_translucent: false,
1250 dirty: FrameDirty::default(),
1251 sky_texture,
1252 sky_view,
1253 sky_sampler,
1254 // Fog disabled by default — voxlap's CPU rasterizer
1255 // also runs without fog in the scene-demo, so matching
1256 // it means no GPU fog out of the box. Hosts can opt in
1257 // via `set_fog` (e.g. for atmospheric far-LOD masking).
1258 fog_color: [0.66, 0.74, 0.88],
1259 fog_near: 0.0,
1260 fog_far: 1.0e30,
1261 sprite_registry: None,
1262 sprite_model_dda: None,
1263 sprite_shadows_capable,
1264 sprite_materials: Box::new(
1265 [MaterialGpu {
1266 alpha: 1.0,
1267 mode: 0,
1268 emissive: 0.0,
1269 _pad: 0,
1270 }; 256],
1271 ),
1272 sprite_has_translucent: false,
1273 // GPU.10.4 — default LOD threshold: step to a coarser mip
1274 // once a voxel projects below 4 px. Empirically the best
1275 // quality/cost tradeoff; the host can override.
1276 sprite_lod_px: 1.0,
1277 // GPU.11.1 — matches the CPU demo's mip_scan_dist=64.
1278 scene_mip_scan_dist: 64.0,
1279 scene_side_shades: [[0; 4]; 2],
1280 scene_lights: SceneLights::default(),
1281 lights_sun_flags: 0,
1282 lights_point_count: 0,
1283 lights_packed_grids: 0,
1284 last_fov_y_rad: 0.0,
1285 pending_frame: None,
1286 frame_pack: None,
1287 line_resources: None,
1288 line_vbuf: None,
1289 line_vbuf_cap: 0,
1290 line_bg_cache: None,
1291 image_resources: None,
1292 image_vbuf: None,
1293 image_vbuf_cap: 0,
1294 image_bg_cache: std::collections::HashMap::new(),
1295 image_bg_depth: None,
1296 images: Vec::new(),
1297 #[cfg(feature = "hud")]
1298 egui_renderer: None,
1299 }
1300 }
1301
1302 /// Synchronous wrapper for hosts that don't have an async
1303 /// runtime. Internally `pollster::block_on`s [`Self::new`].
1304 ///
1305 /// # Errors
1306 /// See [`Self::new`].
1307 #[cfg(not(target_arch = "wasm32"))]
1308 pub fn new_blocking<W>(
1309 window: Arc<W>,
1310 size: (u32, u32),
1311 settings: GpuRendererSettings,
1312 ) -> Result<Self, GpuInitError>
1313 where
1314 W: HasWindowHandle + HasDisplayHandle + Send + Sync + 'static,
1315 {
1316 pollster::block_on(Self::new(window, size, settings))
1317 }
1318
1319 /// Human-readable adapter description — name + backend +
1320 /// device type. The demo host prints this in the title bar.
1321 pub fn adapter_info(&self) -> &str {
1322 &self.adapter_info
1323 }
1324
1325 /// `true` when the adapter is NOT a discrete GPU (integrated,
1326 /// software rasterizer, virtual, unknown) — a hint that hosts
1327 /// should default to a lighter render resolution.
1328 pub fn low_power(&self) -> bool {
1329 self.low_power
1330 }
1331
1332 /// Borrow the underlying wgpu device — hosts use this to build
1333 /// chunk uploads (`GpuChunkResident::upload(gpu.device(), …)`).
1334 pub fn device(&self) -> &wgpu::Device {
1335 &self.device
1336 }
1337
1338 /// XS.4 — whether this device can run GPU sprite shadows (it granted
1339 /// enough storage buffers per shader stage for the cross-pass occupancy
1340 /// bindings). `false` ⇒ GPU sprites render unshadowed; the CPU backend
1341 /// always has sprite shadows. Lets the facade/host report the fallback.
1342 #[must_use]
1343 pub fn sprite_shadows_capable(&self) -> bool {
1344 self.sprite_shadows_capable
1345 }
1346
1347 /// Borrow the wgpu queue — hosts use this for read-back paths
1348 /// (`GpuChunkResident::read_voxel_blocking(gpu.device(), gpu.queue(), …)`).
1349 pub fn queue(&self) -> &wgpu::Queue {
1350 &self.queue
1351 }
1352
1353 /// GPU.8 — upload an equirectangular panorama as the scene's
1354 /// sky texture. `rgba` is row-major, `width × height` pixels,
1355 /// 4 bytes per pixel (R, G, B, A). The shader samples it with
1356 /// `u = atan2(dir.x, dir.y) / (2π) + 0.5` (azimuth) and
1357 /// `v = acos(-dir.z) / π` (elevation), matching standard
1358 /// equirectangular layout (top of image = zenith for voxlap's
1359 /// `+z = down` basis).
1360 /// Mirror the marched scene (and its line/image overlays) horizontally
1361 /// on present, leaving the egui overlay upright. See `Self::flip_x`.
1362 pub fn set_flip_x(&mut self, flip: bool) {
1363 self.flip_x = flip;
1364 }
1365
1366 ///
1367 /// # Panics
1368 /// If `rgba.len() != (width * height * 4) as usize`.
1369 pub fn set_sky_panorama(&mut self, rgba: &[u8], width: u32, height: u32) {
1370 assert_eq!(
1371 rgba.len(),
1372 (width as usize) * (height as usize) * 4,
1373 "set_sky_panorama: expected w*h*4 bytes, got {}",
1374 rgba.len(),
1375 );
1376 let (tex, view) = create_sky_texture(&self.device, width, height, rgba);
1377 // Upload pixel data via `queue.write_texture` so we don't
1378 // have to map the buffer manually.
1379 self.queue.write_texture(
1380 wgpu::TexelCopyTextureInfo {
1381 texture: &tex,
1382 mip_level: 0,
1383 origin: wgpu::Origin3d::ZERO,
1384 aspect: wgpu::TextureAspect::All,
1385 },
1386 rgba,
1387 wgpu::TexelCopyBufferLayout {
1388 offset: 0,
1389 bytes_per_row: Some(width * 4),
1390 rows_per_image: Some(height),
1391 },
1392 wgpu::Extent3d {
1393 width,
1394 height,
1395 depth_or_array_layers: 1,
1396 },
1397 );
1398 self.sky_texture = tex;
1399 self.sky_view = view;
1400 }
1401
1402 /// GPU.8 — set the fog blend. `color` is per-channel [0, 1];
1403 /// `near`/`far` are world-space ray distances in voxel units.
1404 /// Hits with `t < near` show their full colour; hits with
1405 /// `t > far` show `color` exclusively; in between is a
1406 /// smoothstep blend.
1407 pub fn set_fog(&mut self, color: [f32; 3], near: f32, far: f32) {
1408 self.fog_color = color;
1409 self.fog_near = near;
1410 self.fog_far = far.max(near + 1.0);
1411 }
1412
1413 /// Re-configure the swapchain to a new physical size. Call from
1414 /// `WindowEvent::Resized`. The scene resources rebuild lazily at
1415 /// the new size on the next [`Self::render_scene`].
1416 pub fn resize(&mut self, width: u32, height: u32) {
1417 if width == 0 || height == 0 {
1418 return;
1419 }
1420 self.surface_config.width = width;
1421 self.surface_config.height = height;
1422 self.surface.configure(&self.device, &self.surface_config);
1423 self.scene_dda = None;
1424 }
1425
1426 /// RP.0 — set the logical render resolution. Rebuilds the scene-DDA
1427 /// resources on the next [`Self::render_scene`] when the render size
1428 /// changes.
1429 pub fn set_render_resolution(&mut self, res: RenderResolution) {
1430 self.render_res = res;
1431 }
1432
1433 /// RP.1 — set the supersampling factor (clamped to `1..=4`). `1` = off.
1434 pub fn set_ssaa(&mut self, factor: u8) {
1435 self.ssaa = u32::from(factor).clamp(1, 4);
1436 }
1437
1438 /// RP.2 — set (or clear) the posterize post. Applied per-frame via the
1439 /// resolve uniform, so no pipeline rebuild is needed.
1440 pub fn set_posterize(&mut self, cfg: Option<PosterizeGpu>) {
1441 self.posterize = cfg;
1442 }
1443
1444 /// RP.0 — the logical (retro) grid size the scene resolves to before the
1445 /// upscale, resolved against the swapchain size. `logical_dims ==
1446 /// surface_dims` under [`RenderResolution::Native`].
1447 #[must_use]
1448 pub fn logical_dims(&self) -> (u32, u32) {
1449 self.render_res.logical_for(self.surface_dims())
1450 }
1451
1452 /// RP.1 — the resolution the scene/sprite passes actually march at:
1453 /// `logical_dims × ssaa`. The framebuffer + depth buffer are sized to this.
1454 #[must_use]
1455 pub fn render_dims(&self) -> (u32, u32) {
1456 let (lw, lh) = self.logical_dims();
1457 (lw * self.ssaa, lh * self.ssaa)
1458 }
1459
1460 /// RP.0 — the swapchain (native window) size.
1461 #[must_use]
1462 pub fn surface_dims(&self) -> (u32, u32) {
1463 (self.surface_config.width, self.surface_config.height)
1464 }
1465
1466 /// Acquire the next swapchain frame, or `None` to skip this frame.
1467 /// wgpu 29's `get_current_texture` returns a
1468 /// [`wgpu::CurrentSurfaceTexture`] status enum (was
1469 /// `Result<_, SurfaceError>`): an outdated/lost surface reconfigures
1470 /// and skips, transient statuses just skip.
1471 fn acquire_frame(&self) -> Option<wgpu::SurfaceTexture> {
1472 use wgpu::CurrentSurfaceTexture as C;
1473 match self.surface.get_current_texture() {
1474 C::Success(t) | C::Suboptimal(t) => Some(t),
1475 C::Outdated | C::Lost => {
1476 self.surface.configure(&self.device, &self.surface_config);
1477 None
1478 }
1479 C::Timeout | C::Occluded | C::Validation => None,
1480 }
1481 }
1482
1483 /// GPU.1 render: single render pass clearing the swapchain to a
1484 /// slowly drifting colour, then presenting. Voxels arrive in
1485 /// GPU.3+.
1486 pub fn render(&mut self) {
1487 let Some(surf_tex) = self.acquire_frame() else {
1488 return;
1489 };
1490 let view = surf_tex
1491 .texture
1492 .create_view(&wgpu::TextureViewDescriptor::default());
1493
1494 // Slow colour drift so the user can tell the GPU path is
1495 // actually presenting frames vs. e.g. a frozen window.
1496 // Wrap at 2π/0.005 frames (~1257) so the cast stays exact.
1497 let phase = f64::from(self.frame_count % 1257) * 0.005;
1498 let [r, g, b] = self.clear_colour;
1499 let drift = (phase.sin() * 0.04 + 0.04).clamp(0.0, 0.1);
1500 let clear = wgpu::Color {
1501 r: (r + drift).clamp(0.0, 1.0),
1502 g: (g + drift * 0.5).clamp(0.0, 1.0),
1503 b: (b + drift * 0.25).clamp(0.0, 1.0),
1504 a: 1.0,
1505 };
1506
1507 let mut encoder = self
1508 .device
1509 .create_command_encoder(&wgpu::CommandEncoderDescriptor {
1510 label: Some("roxlap-gpu encoder"),
1511 });
1512 {
1513 let _rp = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1514 label: Some("roxlap-gpu clear"),
1515 color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1516 view: &view,
1517 depth_slice: None,
1518 resolve_target: None,
1519 ops: wgpu::Operations {
1520 load: wgpu::LoadOp::Clear(clear),
1521 store: wgpu::StoreOp::Store,
1522 },
1523 })],
1524 depth_stencil_attachment: None,
1525 timestamp_writes: None,
1526 occlusion_query_set: None,
1527 multiview_mask: None,
1528 });
1529 }
1530 self.queue.submit(std::iter::once(encoder.finish()));
1531 surf_tex.present();
1532 self.frame_count = self.frame_count.wrapping_add(1);
1533 }
1534
1535 /// GPU.5 render — multi-grid scene marcher. `cameras[i]` is the
1536 /// world camera transformed into grid `i`'s local frame
1537 /// (caller-supplied; see scene-demo's `redraw_gpu` for the
1538 /// glam-based transform). `fov_y_rad` is the shared vertical
1539 /// FOV; `max_outer_steps` caps per-ray chunk-DDA work for each
1540 /// grid.
1541 ///
1542 /// # Panics
1543 /// If `cameras.len() != scene.grid_count`.
1544 /// `cameras[i]` is grid `i`'s world camera transformed into that
1545 /// grid's local frame (the grid marcher works in grid-local space).
1546 /// `sprite_camera` is the **world** camera: instanced sprites carry
1547 /// world-space positions/transforms, so they must project through
1548 /// the untransformed world camera — not `cameras[0]`, which is only
1549 /// the world camera when grid 0 is at identity.
1550 pub fn render_scene(
1551 &mut self,
1552 scene: &GpuSceneResident,
1553 cameras: &[Camera],
1554 // XS.3 — per-grid world transforms (parallel to `cameras`) for
1555 // cross-grid shadows. Empty ⇒ identity (shadows stay intra-grid).
1556 grid_world: &[GridWorldTransform],
1557 sprite_camera: &Camera,
1558 fov_y_rad: f32,
1559 max_outer_steps: u32,
1560 ) {
1561 assert_eq!(
1562 cameras.len(),
1563 scene.grid_count as usize,
1564 "render_scene: {} cameras supplied, scene has {} grids",
1565 cameras.len(),
1566 scene.grid_count,
1567 );
1568 self.last_fov_y_rad = fov_y_rad; // cached for pixel_ray (picking)
1569
1570 // Deferred present: drop any frame a prior render left
1571 // un-presented (a host that skipped present/paint_egui) so we
1572 // never hold two outstanding swapchain textures.
1573 self.pending_frame = None;
1574 let Some(surf_tex) = self.acquire_frame() else {
1575 return;
1576 };
1577 let surf_view = surf_tex
1578 .texture
1579 .create_view(&wgpu::TextureViewDescriptor::default());
1580
1581 let surface_w = self.surface_config.width;
1582 let surface_h = self.surface_config.height;
1583 let surface_format = self.surface_config.format;
1584 // RP.0/RP.1 — the scene + sprite + depth passes march at the *render*
1585 // size (`logical × ssaa`); a resolve pass box-downfilters to the
1586 // logical grid; the blit nearest-upscales to the swapchain. The
1587 // framebuffer/depth/occupancy + per-pixel projection key off the render
1588 // (march) size. `Native` + `ssaa==1` ⇒ render == logical == surface.
1589 let (logical_w, logical_h) = self.logical_dims();
1590 let (render_w, render_h) = self.render_dims();
1591
1592 let needs_build = match &self.scene_dda {
1593 Some(r) => {
1594 r.storage_size != (render_w, render_h) || r.logical_size != (logical_w, logical_h)
1595 }
1596 None => true,
1597 };
1598 if needs_build {
1599 self.scene_dda = Some(self.build_scene_dda(
1600 render_w,
1601 render_h,
1602 logical_w,
1603 logical_h,
1604 surface_w,
1605 surface_h,
1606 surface_format,
1607 ));
1608 }
1609 // GPU.9 — materialise the sprite pipeline the first frame
1610 // sprites are present (before the immutable `dda` borrow).
1611 // GPU.10.0 — build the model-DDA pipeline the first frame a
1612 // sprite registry is present.
1613 if self.sprite_registry.is_some() && self.sprite_model_dda.is_none() {
1614 self.sprite_model_dda = Some(self.build_sprite_model_dda());
1615 }
1616 // GPU.10.3 — frustum-cull + screen-tile-bin the sprite instances
1617 // (needs &mut self for buffer growth, so before the immutable
1618 // scene_dda borrow). Captures (visible_count, tiles_x); None when
1619 // nothing is in view.
1620 let sprite_pass: Option<(u32, u32)> = if let Some(reg) = self.sprite_registry.as_mut() {
1621 if reg.instance_capacity > 0 {
1622 // World camera — sprite positions/transforms are world-
1623 // space (independent of any grid's transform).
1624 let cam = sprite_camera;
1625 // Aspect + tile binning are in render (logical) space — the
1626 // sprite pass writes the render-sized framebuffer/depth.
1627 #[allow(clippy::cast_precision_loss)]
1628 let aspect = render_w as f32 / render_h as f32;
1629 let half_h = (fov_y_rad * 0.5).tan();
1630 let frustum = sprite_model::ViewFrustum {
1631 pos: cam.position,
1632 right: cam.right,
1633 down: cam.down,
1634 forward: cam.forward,
1635 half_w: half_h * aspect,
1636 half_h,
1637 far: 1.0e9,
1638 };
1639 let (visible, tiles_x, _tiles_y) = reg.cull_bin_upload(
1640 &self.device,
1641 &self.queue,
1642 &frustum,
1643 render_w,
1644 render_h,
1645 SPRITE_TILE_SIZE,
1646 self.sprite_lod_px,
1647 );
1648 (visible > 0).then_some((visible, tiles_x))
1649 } else {
1650 None
1651 }
1652 } else {
1653 None
1654 };
1655 let dda = self.scene_dda.as_ref().expect("just built");
1656
1657 // Refresh the blit's flip flag each frame (offset 16, after the
1658 // src + dst vec2 sizes), so toggling the flip applies without a
1659 // resize. The src/dst sizes themselves are written at build time
1660 // (a render/surface size change forces a rebuild).
1661 self.queue.write_buffer(
1662 &dda.blit_dims,
1663 16,
1664 bytemuck::bytes_of(&[u32::from(self.flip_x), 0u32]),
1665 );
1666 // RP.2 — refresh the resolve pass's posterize fields each frame (offset
1667 // 20, after src/dst dims + ssaa). `None` ⇒ `levels = [1,1,1]`, `dither
1668 // = 0` ⇒ the resolve does box-downfilter only (RP.1).
1669 let (plevels, pdither) = match self.posterize {
1670 Some(p) => (p.levels, p.dither),
1671 None => ([1u32; 3], 0u32),
1672 };
1673 self.queue.write_buffer(
1674 &dda.resolve_dims,
1675 20,
1676 bytemuck::bytes_of(&[plevels[0], plevels[1], plevels[2], pdither]),
1677 );
1678
1679 // Pack per-grid cameras into a runtime-sized storage buffer
1680 // (binding 15) — no fixed cap on grid count.
1681 let mut cam_vec: Vec<SceneDdaPerGridCamera> = cameras
1682 .iter()
1683 .map(SceneDdaPerGridCamera::from_camera)
1684 .collect();
1685 // XS.3 — stamp each grid's world transform for cross-grid shadows.
1686 for (c, t) in cam_vec.iter_mut().zip(grid_world.iter()) {
1687 c.set_world_transform(t);
1688 }
1689
1690 // DL — pack the per-frame lights (already grid-local). The per-grid
1691 // sun direction rides in each `PerGridCamera.sun_dir` (binding 15);
1692 // point lights go in one storage buffer (binding 18). All-zero
1693 // ⇒ the pre-DL render. Shared with the headless path.
1694 // PF.4 — pack CPU-side (no clone of `scene_lights`), then write into
1695 // the persistent grow-only buffers instead of `create_buffer_init`-ing
1696 // fresh ones (which also forced a bind-group rebuild) every frame.
1697 if self.frame_pack.is_none() {
1698 self.frame_pack = Some(FramePackBuffers::new(&self.device));
1699 }
1700 let lights = &self.scene_lights;
1701 // Sun dirs ride in the per-frame camera vector — inject every frame.
1702 inject_grid_sun_dirs(lights, &mut cam_vec);
1703 let fp = self.frame_pack.as_mut().expect("just built");
1704 fp.write_cameras(&self.device, &self.queue, &cam_vec);
1705 // PF.5 — re-pack + re-upload the grid-major point lights only when
1706 // the rig changed (or the grid count did — the rows depend on it).
1707 if self.dirty.scene_lights || self.lights_packed_grids != scene.grid_count {
1708 let (packed_lights, sun_flags, point_count) =
1709 pack_scene_lights(lights, scene.grid_count as usize);
1710 fp.write_point_lights(&self.device, &self.queue, &packed_lights);
1711 self.lights_sun_flags = sun_flags;
1712 self.lights_point_count = point_count;
1713 self.lights_packed_grids = scene.grid_count;
1714 self.dirty.scene_lights = false;
1715 }
1716 let (sun_flags, point_count) = (self.lights_sun_flags, self.lights_point_count);
1717
1718 let uniform = SceneDdaUniform {
1719 fov_y_rad,
1720 grid_count: scene.grid_count,
1721 max_outer_steps,
1722 _pad0: 0,
1723 screen_size: [render_w, render_h],
1724 _pad1: [0; 2],
1725 fog_color: [
1726 self.fog_color[0],
1727 self.fog_color[1],
1728 self.fog_color[2],
1729 self.fog_near,
1730 ],
1731 fog_far: self.fog_far,
1732 // L3.1: always write scene depth. Costs one storage store per
1733 // pixel, and the depth is needed for sprite z-test, sprite-less
1734 // `pick_depth`, and `draw_lines` occlusion alike.
1735 write_depth: 1,
1736 occ_page_words: scene.occupancy_page_words,
1737 occ_num_pages: scene.occupancy_num_pages,
1738 mip_scan_dist: self.scene_mip_scan_dist,
1739 terrain_has_translucent: u32::from(self.scene_terrain_translucent),
1740 terrain_map_count: self.scene_terrain_map.len() as u32,
1741 _pad4: 0,
1742 // Sky direction comes from the world (sprite) camera, so a
1743 // grid-less sprite-only scene still paints a real sky.
1744 sky_cam: SceneDdaPerGridCamera::from_camera(sprite_camera),
1745 side_shades0: self.scene_side_shades[0],
1746 side_shades1: self.scene_side_shades[1],
1747 sun_color: [
1748 lights.sun_color[0],
1749 lights.sun_color[1],
1750 lights.sun_color[2],
1751 lights.sun_intensity,
1752 ],
1753 ambient_color: [
1754 lights.ambient[0],
1755 lights.ambient[1],
1756 lights.ambient[2],
1757 lights.shadow_strength,
1758 ],
1759 sun_flags,
1760 point_light_count: point_count,
1761 shadow_max_steps: lights.shadow_max_steps,
1762 _pad5: 0,
1763 shadow_bias: lights.shadow_bias,
1764 shadow_max_dist: lights.shadow_max_dist,
1765 _pad6: [0.0; 2],
1766 shadow_tint: [
1767 lights.shadow_tint[0],
1768 lights.shadow_tint[1],
1769 lights.shadow_tint[2],
1770 0.0,
1771 ],
1772 style_bands: lights.style_bands,
1773 // XS.4.3 — visible sprite casters for the scene-pass cast march
1774 // (only when the device is sprite-shadow capable; else the cast
1775 // bindings/loop are absent).
1776 sprite_cast_count: if self.sprite_shadows_capable {
1777 sprite_pass.map_or(0, |(visible, _)| visible)
1778 } else {
1779 0
1780 },
1781 _pad7: [0; 2],
1782 };
1783 self.queue
1784 .write_buffer(&dda.uniform_buf, 0, bytemuck::bytes_of(&uniform));
1785
1786 // PF.4 — cached bind group, keyed on the exact resources bound.
1787 // Occupancy page 0 at binding 1; pages 1..MAX_OCC_PAGES at 12..
1788 // (GPU.X paging). Per-grid point lights at 18 (DL); the per-grid
1789 // sun dir rides in PerGridCamera.sun_dir (binding 15).
1790 let mut dda_bufs: Vec<(u32, wgpu::Buffer)> = vec![
1791 (0, dda.uniform_buf.clone()),
1792 (1, scene.occupancy_pages[0].clone()),
1793 (2, scene.all_color_offsets.clone()),
1794 (3, scene.all_colors.clone()),
1795 (4, scene.all_chunk_colors_base.clone()),
1796 (5, scene.all_chunk_occupancy.clone()),
1797 (6, scene.grid_static_meta.clone()),
1798 (7, scene.all_slot_chunk_idx.clone()),
1799 (8, dda.framebuffer.clone()),
1800 (11, dda.depth_buffer.clone()),
1801 (12, scene.occupancy_pages[1].clone()),
1802 (13, scene.occupancy_pages[2].clone()),
1803 (14, scene.occupancy_pages[3].clone()),
1804 (15, fp.grid_cameras.clone()),
1805 (16, dda.materials_pal_buf.clone()),
1806 (17, dda.terrain_map_buf.clone()),
1807 (18, fp.point_lights.clone()),
1808 ];
1809 // XS.4.3 — sprite-cast bindings (19..21). On a capable device the BGL
1810 // has them, so bind the sprite registry when present (terrain shadow
1811 // rays test sprite volumes), else the dummy (sprite_cast_count == 0).
1812 if self.sprite_shadows_capable {
1813 let dummy = dda
1814 .sprite_cast_dummy
1815 .as_ref()
1816 .expect("capable scene_dda has a sprite-cast dummy");
1817 let (insts, models, occ) = match &self.sprite_registry {
1818 Some(reg) => (®.instances, ®.model_meta, ®.occupancy),
1819 None => (dummy, dummy, dummy),
1820 };
1821 dda_bufs.push((19, insts.clone()));
1822 dda_bufs.push((20, models.clone()));
1823 dda_bufs.push((21, occ.clone()));
1824 }
1825 let dda_bg = cached_bind_group(
1826 &mut fp.dda_bg,
1827 &self.device,
1828 "roxlap-gpu scene_dda.bg",
1829 &dda.bgl_dda,
1830 dda_bufs,
1831 vec![(9, self.sky_view.clone())],
1832 &[(10, &self.sky_sampler)],
1833 )
1834 .clone();
1835
1836 // GPU.9 — when sprites are present, build both splatter bind
1837 // groups up front (the splat pass writes the key buffer; the
1838 // resolve pass reads keys + scene depth and writes colour).
1839 // GPU.10.3 — model-DDA bind group + per-frame uniform, using the
1840 // cull/bin results captured above. Per-model + per-instance data
1841 // + the tile lists live in the registry buffers.
1842 let sprite_model_bg = match (&self.sprite_model_dda, &self.sprite_registry, sprite_pass) {
1843 (Some(smd), Some(reg), Some((visible, tiles_x))) => {
1844 // World camera (see the cull pass above) — sprites
1845 // project through it regardless of grid 0's transform.
1846 let cam = sprite_camera;
1847 // DL.4 — world-space lights for the sprite pass (sprites are
1848 // world-space, not grid-local). No sprite shadows (deferred).
1849 let dl = &self.scene_lights;
1850 let sprite_sun_enabled = dl.world_sun_dir != [0.0; 3];
1851 let sprite_point_count = dl.world_points.len().min(MAX_POINT_LIGHTS) as u32;
1852 // PF.4 — persistent buffer instead of a per-frame allocation.
1853 // PF.5 — rebuilt + re-uploaded only when the rig changed;
1854 // this pass's own dirty flag (it only runs with sprites on
1855 // screen, so it can't ride the scene pack's flag).
1856 if self.dirty.sprite_lights {
1857 let sprite_pts: Vec<GpuPointLight> = dl
1858 .world_points
1859 .iter()
1860 .take(MAX_POINT_LIGHTS)
1861 .map(|l| GpuPointLight {
1862 pos: l.position,
1863 radius: l.radius,
1864 color: l.color,
1865 intensity: l.intensity,
1866 spot_dir: l.spot_dir,
1867 cos_outer: l.cos_outer,
1868 cos_inner: l.cos_inner,
1869 // XS.4.2 — honour the light's caster flag so a
1870 // receiving sprite is shadowed by it (capable
1871 // devices).
1872 casts_shadow: u32::from(l.casts_shadow),
1873 _pad: [0; 2],
1874 })
1875 .collect();
1876 fp.write_sprite_lights(&self.device, &self.queue, &sprite_pts);
1877 self.dirty.sprite_lights = false;
1878 }
1879 // sun_flags bit0 = sun enabled, bit1 = sun casts shadow (XS.4.2),
1880 // bit2 = dynamic lighting active.
1881 let sprite_sun_flags = u32::from(sprite_sun_enabled)
1882 | (u32::from(dl.sun_casts_shadow) << 1)
1883 | (u32::from(dl.enabled) << 2);
1884 let uni = SpriteModelUniform {
1885 cam_pos: cam.position,
1886 _p0: 0.0,
1887 cam_right: cam.right,
1888 _p1: 0.0,
1889 cam_down: cam.down,
1890 _p2: 0.0,
1891 cam_forward: cam.forward,
1892 _p3: 0.0,
1893 fog_color: [
1894 self.fog_color[0],
1895 self.fog_color[1],
1896 self.fog_color[2],
1897 self.fog_near,
1898 ],
1899 screen_size: [render_w, render_h],
1900 instance_count: visible,
1901 fog_far: self.fog_far,
1902 fov_y_rad,
1903 tiles_x,
1904 tile_size: SPRITE_TILE_SIZE,
1905 has_translucent: u32::from(self.sprite_has_translucent),
1906 sun_dir: [
1907 dl.world_sun_dir[0],
1908 dl.world_sun_dir[1],
1909 dl.world_sun_dir[2],
1910 0.0,
1911 ],
1912 sun_color: [
1913 dl.sun_color[0],
1914 dl.sun_color[1],
1915 dl.sun_color[2],
1916 dl.sun_intensity,
1917 ],
1918 ambient_color: [dl.ambient[0], dl.ambient[1], dl.ambient[2], 0.0],
1919 sun_flags: sprite_sun_flags,
1920 point_light_count: sprite_point_count,
1921 _pad_dl: [0; 2],
1922 shadow_tint: [dl.shadow_tint[0], dl.shadow_tint[1], dl.shadow_tint[2], 0.0],
1923 style_bands: dl.style_bands,
1924 // XS.4.2 — sprite-shadow (receive) ABI, mirroring the scene
1925 // pass. Only consulted when the device is sprite-shadow
1926 // capable (the shadowed shader variant is built); otherwise
1927 // the stub `sprite_shadow_occluded` ignores them.
1928 occ_num_pages: scene.occupancy_num_pages,
1929 occ_page_words: scene.occupancy_page_words,
1930 grid_count: scene.grid_count,
1931 max_outer_steps,
1932 shadow_max_steps: dl.shadow_max_steps,
1933 shadow_bias: dl.shadow_bias,
1934 shadow_max_dist: dl.shadow_max_dist,
1935 shadow_strength: dl.shadow_strength,
1936 _pad_xs: [0; 3],
1937 };
1938 self.queue
1939 .write_buffer(&smd.uniform_buf, 0, bytemuck::bytes_of(&uni));
1940 // PF.4 — cached bind group (identity-keyed, like the scene
1941 // pass's). World point lights at 15 (DL.7; binding 14 univec
1942 // normal table dropped — face-normal lighting now).
1943 let mut sprite_bufs: Vec<(u32, wgpu::Buffer)> = vec![
1944 (0, smd.uniform_buf.clone()),
1945 (1, reg.occupancy.clone()),
1946 (2, reg.colors.clone()),
1947 (3, reg.color_offsets.clone()),
1948 (4, reg.model_meta.clone()),
1949 (5, reg.instances.clone()),
1950 (6, dda.depth_buffer.clone()),
1951 (7, dda.framebuffer.clone()),
1952 (8, reg.tile_ranges.clone()),
1953 (9, reg.tile_instances.clone()),
1954 (10, reg.dirs.clone()),
1955 (11, reg.colmul.clone()),
1956 (12, smd.materials_buf.clone()),
1957 (13, reg.materials_vox.clone()),
1958 (15, fp.sprite_lights.clone()),
1959 ];
1960 // XS.4.2 — when capable, bind the terrain occupancy set (the
1961 // same resident buffers + the per-frame grid cameras the scene
1962 // pass uses) so sprite shadow rays march terrain. Must match
1963 // the BGL built in `build_sprite_model_dda`.
1964 if self.sprite_shadows_capable {
1965 let terrain: [(u32, &wgpu::Buffer); 8] = [
1966 (16, &scene.occupancy_pages[0]),
1967 (17, &scene.occupancy_pages[1]),
1968 (18, &scene.occupancy_pages[2]),
1969 (19, &scene.occupancy_pages[3]),
1970 (20, &scene.all_chunk_occupancy),
1971 (21, &scene.all_slot_chunk_idx),
1972 (22, &scene.grid_static_meta),
1973 (23, &fp.grid_cameras),
1974 ];
1975 for (binding, buf) in terrain {
1976 sprite_bufs.push((binding, buf.clone()));
1977 }
1978 }
1979 Some(
1980 cached_bind_group(
1981 &mut fp.sprite_bg,
1982 &self.device,
1983 "roxlap-gpu sprite_model_dda.bg",
1984 &smd.bgl,
1985 sprite_bufs,
1986 Vec::new(),
1987 &[],
1988 )
1989 .clone(),
1990 )
1991 }
1992 _ => None,
1993 };
1994
1995 let mut encoder = self
1996 .device
1997 .create_command_encoder(&wgpu::CommandEncoderDescriptor {
1998 label: Some("roxlap-gpu scene encoder"),
1999 });
2000 {
2001 let mut cpass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
2002 label: Some("roxlap-gpu scene_dda compute"),
2003 timestamp_writes: None,
2004 });
2005 cpass.set_pipeline(&dda.pipeline_dda);
2006 cpass.set_bind_group(0, &dda_bg, &[]);
2007 cpass.dispatch_workgroups(render_w.div_ceil(8), render_h.div_ceil(8), 1);
2008 }
2009 // GPU.10 — sprite model-DDA pass: one thread per pixel marches
2010 // the tile's instances + composites against scene depth, after
2011 // the scene pass wrote the depth buffer and before the blit.
2012 if let (Some(smd), Some(bg)) = (&self.sprite_model_dda, &sprite_model_bg) {
2013 let mut cpass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
2014 label: Some("roxlap-gpu sprite_model_dda"),
2015 timestamp_writes: None,
2016 });
2017 cpass.set_pipeline(&smd.pipeline);
2018 cpass.set_bind_group(0, bg, &[]);
2019 cpass.dispatch_workgroups(render_w.div_ceil(8), render_h.div_ceil(8), 1);
2020 }
2021 // RP.1 — resolve pass: box-downfilter framebuffer(march) →
2022 // resolve_buf(logical). One thread per logical pixel.
2023 // PF.5 (H6) — with ssaa == 1 AND posterize off the resolve is an
2024 // identity copy: skip the whole full-screen pass and blit straight
2025 // from the framebuffer instead (byte-identical output).
2026 let identity_resolve =
2027 (render_w, render_h) == (logical_w, logical_h) && self.posterize.is_none();
2028 if !identity_resolve {
2029 let mut cpass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
2030 label: Some("roxlap-gpu scene_dda resolve"),
2031 timestamp_writes: None,
2032 });
2033 cpass.set_pipeline(&dda.pipeline_resolve);
2034 cpass.set_bind_group(0, &dda.resolve_bg, &[]);
2035 cpass.dispatch_workgroups(logical_w.div_ceil(8), logical_h.div_ceil(8), 1);
2036 }
2037 {
2038 let mut rpass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
2039 label: Some("roxlap-gpu scene_dda blit"),
2040 color_attachments: &[Some(wgpu::RenderPassColorAttachment {
2041 view: &surf_view,
2042 depth_slice: None,
2043 resolve_target: None,
2044 ops: wgpu::Operations {
2045 load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
2046 store: wgpu::StoreOp::Store,
2047 },
2048 })],
2049 depth_stencil_attachment: None,
2050 timestamp_writes: None,
2051 occlusion_query_set: None,
2052 multiview_mask: None,
2053 });
2054 rpass.set_pipeline(&dda.pipeline_blit);
2055 rpass.set_bind_group(
2056 0,
2057 if identity_resolve {
2058 &dda.blit_bg_direct
2059 } else {
2060 &dda.blit_bg
2061 },
2062 &[],
2063 );
2064 rpass.draw(0..3, 0..1);
2065 }
2066 self.queue.submit(std::iter::once(encoder.finish()));
2067 // This frame wrote `scene_dda.depth_buffer`, so depth-tested
2068 // overlays may test against it.
2069 self.dirty.scene_depth_valid = true;
2070 // Deferred present — the host calls `present` or `paint_egui`.
2071 self.pending_frame = Some((surf_tex, surf_view));
2072 self.frame_count = self.frame_count.wrapping_add(1);
2073 }
2074
2075 /// Like [`Self::render`] (clear to colour) but **deferred**: stashes
2076 /// the frame for [`Self::present`] / [`Self::paint_egui`] instead of
2077 /// presenting. The facade uses this before any grid is resident so a
2078 /// HUD can still be painted over an empty scene.
2079 pub fn render_clear_deferred(&mut self) {
2080 // No scene pass this frame ⇒ `scene_dda.depth_buffer` (if it
2081 // exists from an earlier scene) is stale; depth-tested overlays
2082 // must not test against it.
2083 self.dirty.scene_depth_valid = false;
2084 self.pending_frame = None;
2085 let Some(surf_tex) = self.acquire_frame() else {
2086 return;
2087 };
2088 let view = surf_tex
2089 .texture
2090 .create_view(&wgpu::TextureViewDescriptor::default());
2091 let [r, g, b] = self.clear_colour;
2092 let mut encoder = self
2093 .device
2094 .create_command_encoder(&wgpu::CommandEncoderDescriptor {
2095 label: Some("roxlap-gpu clear (deferred)"),
2096 });
2097 {
2098 let _rp = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
2099 label: Some("roxlap-gpu clear (deferred)"),
2100 color_attachments: &[Some(wgpu::RenderPassColorAttachment {
2101 view: &view,
2102 depth_slice: None,
2103 resolve_target: None,
2104 ops: wgpu::Operations {
2105 load: wgpu::LoadOp::Clear(wgpu::Color { r, g, b, a: 1.0 }),
2106 store: wgpu::StoreOp::Store,
2107 },
2108 })],
2109 depth_stencil_attachment: None,
2110 timestamp_writes: None,
2111 occlusion_query_set: None,
2112 multiview_mask: None,
2113 });
2114 }
2115 self.queue.submit(std::iter::once(encoder.finish()));
2116 self.pending_frame = Some((surf_tex, view));
2117 }
2118
2119 /// Present the frame stashed by the last deferred render
2120 /// ([`Self::render_scene`] / [`Self::render_clear_deferred`]). No-op
2121 /// if nothing is pending (e.g. the surface was lost mid-render).
2122 pub fn present(&mut self) {
2123 if let Some((surf_tex, _view)) = self.pending_frame.take() {
2124 surf_tex.present();
2125 }
2126 }
2127
2128 /// Block until the GPU has drained every submitted command (queue
2129 /// idle), dropping any not-yet-presented swapchain frame first. Call at
2130 /// shutdown — before the [`GpuRenderer`] (and its window) drop — so the
2131 /// device is torn down with no work in flight and no half-presented
2132 /// frame, instead of yanking the swapchain mid-submission (which leaves
2133 /// the driver/compositor compositing stale buffers — the "leftover
2134 /// triangles / flicker after an unclean exit" symptom). No-op on wasm
2135 /// (`poll(Wait)` is unavailable there; the browser reclaims the device).
2136 pub fn wait_idle(&mut self) {
2137 // Release the acquired-but-unpresented frame so its swapchain image
2138 // isn't held across teardown.
2139 self.pending_frame = None;
2140 #[cfg(not(target_arch = "wasm32"))]
2141 {
2142 self.device.poll(wgpu::PollType::wait_indefinitely()).ok();
2143 }
2144 }
2145
2146 /// Project a world point to window pixels under the marcher's
2147 /// vertical-FOV pinhole (the inverse of [`Self::pixel_ray`]), using
2148 /// the last-rendered frame's size + FOV. `None` before the first
2149 /// scene render or for a point at/behind the near plane.
2150 #[must_use]
2151 pub fn project_point(
2152 &self,
2153 cam_pos: [f32; 3],
2154 right: [f32; 3],
2155 down: [f32; 3],
2156 forward: [f32; 3],
2157 world: [f32; 3],
2158 ) -> Option<(f32, f32)> {
2159 let dda = self.scene_dda.as_ref()?;
2160 let (w, h) = dda.storage_size;
2161 if w == 0 || h == 0 || self.last_fov_y_rad <= 0.0 {
2162 return None;
2163 }
2164 let d = [
2165 world[0] - cam_pos[0],
2166 world[1] - cam_pos[1],
2167 world[2] - cam_pos[2],
2168 ];
2169 let cz = forward[0] * d[0] + forward[1] * d[1] + forward[2] * d[2];
2170 if cz < LINE_NEAR_Z {
2171 return None;
2172 }
2173 let cx = right[0] * d[0] + right[1] * d[1] + right[2] * d[2];
2174 let cy = down[0] * d[0] + down[1] * d[1] + down[2] * d[2];
2175 let half_h = (self.last_fov_y_rad * 0.5).tan();
2176 let half_w = half_h * (w as f32 / h as f32);
2177 let ndc_x = (cx / cz) / half_w;
2178 let ndc_y = -(cy / cz) / half_h;
2179 let sx = (ndc_x * 0.5 + 0.5) * w as f32;
2180 let sy = (0.5 - ndc_y * 0.5) * h as f32;
2181 Some((sx, sy))
2182 }
2183
2184 fn build_scene_dda(
2185 &self,
2186 width: u32,
2187 height: u32,
2188 logical_w: u32,
2189 logical_h: u32,
2190 surface_w: u32,
2191 surface_h: u32,
2192 surface_format: wgpu::TextureFormat,
2193 ) -> SceneDdaResources {
2194 // `width`/`height` are the **march** size (`logical × ssaa`) — the
2195 // scene + sprite + depth passes run at it. `logical_*` is the resolved
2196 // (retro) grid the resolve pass downfilters into and the blit reads.
2197 // `surface_*` is the swapchain the blit upscales onto. Framebuffer is a
2198 // packed-`rgba8unorm` storage buffer (row stride = march `width`).
2199 let framebuffer = self.device.create_buffer(&wgpu::BufferDescriptor {
2200 label: Some("roxlap-gpu scene_dda.framebuffer"),
2201 size: u64::from(width) * u64::from(height) * 4,
2202 // QE.7a - COPY_SRC so `read_frame_pixels` can stage the
2203 // identity-resolve path (ssaa 1, posterize off) for capture.
2204 usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC,
2205 mapped_at_creation: false,
2206 });
2207 // RP.1 — logical-resolution buffer the resolve pass writes; the blit
2208 // reads it (so the blit src is the *logical* size, not the march size).
2209 let resolve_buf = self.device.create_buffer(&wgpu::BufferDescriptor {
2210 label: Some("roxlap-gpu scene_dda.resolve_buf"),
2211 size: u64::from(logical_w) * u64::from(logical_h) * 4,
2212 // QE.7a - COPY_SRC so `read_frame_pixels` can stage it (capture).
2213 usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC,
2214 mapped_at_creation: false,
2215 });
2216 // Resolve uniform: `[src(march) w,h, dst(logical) w,h, ssaa,
2217 // levels r,g,b, dither, pad×3]` (48 B). Dims+ssaa written here; the
2218 // posterize fields (offset 20) are re-written per frame in render_scene.
2219 let resolve_dims = self.device.create_buffer(&wgpu::BufferDescriptor {
2220 label: Some("roxlap-gpu scene_dda.resolve_dims"),
2221 size: 48,
2222 usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
2223 mapped_at_creation: false,
2224 });
2225 self.queue.write_buffer(
2226 &resolve_dims,
2227 0,
2228 bytemuck::bytes_of(&[width, height, logical_w, logical_h, self.ssaa]),
2229 );
2230 // Blit uniform `Dims`: logical (src) size, swapchain (dst) size, then
2231 // `flip_x` + pad (RP.0 nearest upscale). The flip flag (offset 16) is
2232 // re-written per frame in `render_scene`; a render/surface resize
2233 // forces a full rebuild, so the sizes only need writing here.
2234 let blit_dims = self.device.create_buffer(&wgpu::BufferDescriptor {
2235 label: Some("roxlap-gpu scene_dda.blit_dims"),
2236 size: 32,
2237 usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
2238 mapped_at_creation: false,
2239 });
2240 self.queue.write_buffer(
2241 &blit_dims,
2242 0,
2243 bytemuck::bytes_of(&[
2244 logical_w,
2245 logical_h,
2246 surface_w,
2247 surface_h,
2248 u32::from(self.flip_x),
2249 0u32,
2250 0u32,
2251 0u32,
2252 ]),
2253 );
2254
2255 let uniform_buf = self.device.create_buffer(&wgpu::BufferDescriptor {
2256 label: Some("roxlap-gpu scene_dda.uniform"),
2257 size: std::mem::size_of::<SceneDdaUniform>() as u64,
2258 usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
2259 mapped_at_creation: false,
2260 });
2261
2262 // GPU.9 — per-pixel world-t depth (f32 bits as u32). Sized to
2263 // the storage texture; written by the scene pass when sprites
2264 // are active, read+tested by the sprite splatter.
2265 let depth_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
2266 label: Some("roxlap-gpu scene_dda.depth"),
2267 size: u64::from(width) * u64::from(height) * 4,
2268 // COPY_SRC so `read_depth_pixel` can stage it for picking.
2269 usage: wgpu::BufferUsages::STORAGE
2270 | wgpu::BufferUsages::COPY_DST
2271 | wgpu::BufferUsages::COPY_SRC,
2272 mapped_at_creation: false,
2273 });
2274 let depth_readback = self.device.create_buffer(&wgpu::BufferDescriptor {
2275 label: Some("roxlap-gpu scene_dda.depth_readback"),
2276 size: u64::from(width) * u64::from(height) * 4,
2277 usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
2278 mapped_at_creation: false,
2279 });
2280 // XS.4.3 — on sprite-shadow-capable devices, splice the sprite-cast
2281 // snippet over the `sprites_occlude` stub (binds the sprite registry at
2282 // 19..21 so terrain shadow rays test sprite volumes).
2283 let capable = self.sprite_shadows_capable;
2284 let dda_shader = self
2285 .device
2286 .create_shader_module(wgpu::ShaderModuleDescriptor {
2287 label: Some("scene_dda.wgsl"),
2288 source: wgpu::ShaderSource::Wgsl(scene_shader_source(capable).into()),
2289 });
2290 let mut dda_entries = vec![
2291 bgl_uniform_entry(0),
2292 bgl_storage_entry(1, true),
2293 bgl_storage_entry(2, true),
2294 bgl_storage_entry(3, true),
2295 bgl_storage_entry(4, true),
2296 bgl_storage_entry(5, true),
2297 bgl_storage_entry(6, true),
2298 bgl_storage_entry(7, true),
2299 // Framebuffer storage buffer (read-write; the scene +
2300 // sprite passes write packed pixels into it).
2301 bgl_storage_entry(8, false),
2302 // GPU.8 sky panorama + sampler.
2303 wgpu::BindGroupLayoutEntry {
2304 binding: 9,
2305 visibility: wgpu::ShaderStages::COMPUTE,
2306 ty: wgpu::BindingType::Texture {
2307 sample_type: wgpu::TextureSampleType::Float { filterable: true },
2308 view_dimension: wgpu::TextureViewDimension::D2,
2309 multisampled: false,
2310 },
2311 count: None,
2312 },
2313 wgpu::BindGroupLayoutEntry {
2314 binding: 10,
2315 visibility: wgpu::ShaderStages::COMPUTE,
2316 ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
2317 count: None,
2318 },
2319 // GPU.9 — read-write per-pixel depth buffer.
2320 bgl_storage_entry(11, false),
2321 // Occupancy pages 1..MAX_OCC_PAGES (page 0 is
2322 // binding 1). Unused pages bind a dummy buffer.
2323 bgl_storage_entry(12, true),
2324 bgl_storage_entry(13, true),
2325 bgl_storage_entry(14, true),
2326 // Per-grid cameras (runtime-sized; one per grid).
2327 bgl_storage_entry(15, true),
2328 // TV.6 — material palette + terrain colour→material map.
2329 bgl_storage_entry(16, true),
2330 bgl_storage_entry(17, true),
2331 // DL — per-grid point lights (18). Sun dir rides in
2332 // PerGridCamera (binding 15) to stay within the 16
2333 // storage-buffer limit.
2334 bgl_storage_entry(18, true),
2335 ];
2336 if capable {
2337 // XS.4.3 — sprite registry for the sprite-cast shadow march.
2338 dda_entries.push(bgl_storage_entry(19, true)); // sprite_instances
2339 dda_entries.push(bgl_storage_entry(20, true)); // sprite_models
2340 dda_entries.push(bgl_storage_entry(21, true)); // sprite_occupancy
2341 }
2342 let bgl_dda = self
2343 .device
2344 .create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
2345 label: Some("roxlap-gpu scene_dda.bgl"),
2346 entries: &dda_entries,
2347 });
2348 let dda_pl = self
2349 .device
2350 .create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
2351 label: Some("roxlap-gpu scene_dda.layout"),
2352 bind_group_layouts: &[Some(&bgl_dda)],
2353 immediate_size: 0,
2354 });
2355 let pipeline_dda = self
2356 .device
2357 .create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
2358 label: Some("roxlap-gpu scene_dda.pipeline"),
2359 layout: Some(&dda_pl),
2360 module: &dda_shader,
2361 entry_point: Some("render_scene"),
2362 compilation_options: wgpu::PipelineCompilationOptions::default(),
2363 cache: None,
2364 });
2365
2366 // RP.1 — box-downfilter resolve pass (framebuffer march → resolve_buf
2367 // logical). `ssaa == 1` is a 1×1 copy; the blit always reads resolve_buf.
2368 let resolve_shader = self
2369 .device
2370 .create_shader_module(wgpu::ShaderModuleDescriptor {
2371 label: Some("scene_resolve.wgsl"),
2372 source: wgpu::ShaderSource::Wgsl(
2373 include_str!("../shaders/scene_resolve.wgsl").into(),
2374 ),
2375 });
2376 let bgl_resolve = self
2377 .device
2378 .create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
2379 label: Some("roxlap-gpu scene_dda.resolve_bgl"),
2380 entries: &[
2381 bgl_storage_entry(0, true), // src framebuffer (read)
2382 bgl_storage_entry(1, false), // dst resolve_buf (read-write)
2383 bgl_uniform_entry(2), // resolve dims
2384 ],
2385 });
2386 let resolve_pl = self
2387 .device
2388 .create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
2389 label: Some("roxlap-gpu scene_dda.resolve_layout"),
2390 bind_group_layouts: &[Some(&bgl_resolve)],
2391 immediate_size: 0,
2392 });
2393 let pipeline_resolve =
2394 self.device
2395 .create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
2396 label: Some("roxlap-gpu scene_dda.resolve_pipeline"),
2397 layout: Some(&resolve_pl),
2398 module: &resolve_shader,
2399 entry_point: Some("main"),
2400 compilation_options: wgpu::PipelineCompilationOptions::default(),
2401 cache: None,
2402 });
2403 let resolve_bg = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
2404 label: Some("roxlap-gpu scene_dda.resolve_bg"),
2405 layout: &bgl_resolve,
2406 entries: &[
2407 wgpu::BindGroupEntry {
2408 binding: 0,
2409 resource: framebuffer.as_entire_binding(),
2410 },
2411 wgpu::BindGroupEntry {
2412 binding: 1,
2413 resource: resolve_buf.as_entire_binding(),
2414 },
2415 wgpu::BindGroupEntry {
2416 binding: 2,
2417 resource: resolve_dims.as_entire_binding(),
2418 },
2419 ],
2420 });
2421
2422 let blit_shader = self
2423 .device
2424 .create_shader_module(wgpu::ShaderModuleDescriptor {
2425 label: Some("scene_blit.wgsl"),
2426 source: wgpu::ShaderSource::Wgsl(include_str!("../shaders/scene_blit.wgsl").into()),
2427 });
2428 let bgl_blit = self
2429 .device
2430 .create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
2431 label: Some("roxlap-gpu scene_dda.blit_bgl"),
2432 entries: &[
2433 // Framebuffer storage buffer (read-only in the blit).
2434 wgpu::BindGroupLayoutEntry {
2435 binding: 0,
2436 visibility: wgpu::ShaderStages::FRAGMENT,
2437 ty: wgpu::BindingType::Buffer {
2438 ty: wgpu::BufferBindingType::Storage { read_only: true },
2439 has_dynamic_offset: false,
2440 min_binding_size: None,
2441 },
2442 count: None,
2443 },
2444 // Screen-size uniform for the pixel→index math.
2445 wgpu::BindGroupLayoutEntry {
2446 binding: 1,
2447 visibility: wgpu::ShaderStages::FRAGMENT,
2448 ty: wgpu::BindingType::Buffer {
2449 ty: wgpu::BufferBindingType::Uniform,
2450 has_dynamic_offset: false,
2451 min_binding_size: None,
2452 },
2453 count: None,
2454 },
2455 ],
2456 });
2457 let blit_pl = self
2458 .device
2459 .create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
2460 label: Some("roxlap-gpu scene_dda.blit_layout"),
2461 bind_group_layouts: &[Some(&bgl_blit)],
2462 immediate_size: 0,
2463 });
2464 let pipeline_blit = self
2465 .device
2466 .create_render_pipeline(&wgpu::RenderPipelineDescriptor {
2467 label: Some("roxlap-gpu scene_dda.blit_pipeline"),
2468 layout: Some(&blit_pl),
2469 vertex: wgpu::VertexState {
2470 module: &blit_shader,
2471 entry_point: Some("vs_main"),
2472 compilation_options: wgpu::PipelineCompilationOptions::default(),
2473 buffers: &[],
2474 },
2475 fragment: Some(wgpu::FragmentState {
2476 module: &blit_shader,
2477 entry_point: Some("fs_main"),
2478 compilation_options: wgpu::PipelineCompilationOptions::default(),
2479 targets: &[Some(wgpu::ColorTargetState {
2480 format: surface_format,
2481 blend: None,
2482 write_mask: wgpu::ColorWrites::ALL,
2483 })],
2484 }),
2485 primitive: wgpu::PrimitiveState::default(),
2486 depth_stencil: None,
2487 multisample: wgpu::MultisampleState::default(),
2488 multiview_mask: None,
2489 cache: None,
2490 });
2491 let blit_bg = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
2492 label: Some("roxlap-gpu scene_dda.blit_bg"),
2493 layout: &bgl_blit,
2494 entries: &[
2495 wgpu::BindGroupEntry {
2496 binding: 0,
2497 // RP.1 — blit reads the logical resolve buffer.
2498 resource: resolve_buf.as_entire_binding(),
2499 },
2500 wgpu::BindGroupEntry {
2501 binding: 1,
2502 resource: blit_dims.as_entire_binding(),
2503 },
2504 ],
2505 });
2506 // PF.5 (H6) — direct-blit variant reading the march framebuffer:
2507 // used when the resolve pass would be an identity copy (ssaa == 1,
2508 // posterize off ⇒ march size == logical size), letting render_scene
2509 // skip that full-screen pass entirely.
2510 let blit_bg_direct = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
2511 label: Some("roxlap-gpu scene_dda.blit_bg_direct"),
2512 layout: &bgl_blit,
2513 entries: &[
2514 wgpu::BindGroupEntry {
2515 binding: 0,
2516 resource: framebuffer.as_entire_binding(),
2517 },
2518 wgpu::BindGroupEntry {
2519 binding: 1,
2520 resource: blit_dims.as_entire_binding(),
2521 },
2522 ],
2523 });
2524
2525 // TV.6 — material palette + terrain map buffers, seeded from the
2526 // renderer's current scene-material state (so a map defined before the
2527 // scene pass was built still takes effect).
2528 let (materials_pal_buf, terrain_map_buf) = {
2529 use wgpu::util::DeviceExt;
2530 let pal = self
2531 .device
2532 .create_buffer_init(&wgpu::util::BufferInitDescriptor {
2533 label: Some("roxlap-gpu scene_dda.materials_pal"),
2534 contents: bytemuck::cast_slice(self.scene_materials.as_slice()),
2535 usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
2536 });
2537 // Fixed 256-row map (≤256 materials anyway) → no re-alloc when the
2538 // host changes the map after the scene pass is built.
2539 let mut rows = [[0u32; 2]; 256];
2540 for (slot, &row) in rows.iter_mut().zip(self.scene_terrain_map.iter()) {
2541 *slot = row;
2542 }
2543 let map = self
2544 .device
2545 .create_buffer_init(&wgpu::util::BufferInitDescriptor {
2546 label: Some("roxlap-gpu scene_dda.terrain_map"),
2547 contents: bytemuck::cast_slice(&rows),
2548 usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
2549 });
2550 (pal, map)
2551 };
2552
2553 SceneDdaResources {
2554 storage_size: (width, height),
2555 logical_size: (logical_w, logical_h),
2556 framebuffer,
2557 resolve_buf,
2558 uniform_buf,
2559 bgl_dda,
2560 pipeline_dda,
2561 pipeline_resolve,
2562 resolve_bg,
2563 resolve_dims,
2564 blit_bg,
2565 blit_bg_direct,
2566 pipeline_blit,
2567 blit_dims,
2568 depth_buffer,
2569 depth_readback,
2570 materials_pal_buf,
2571 terrain_map_buf,
2572 // XS.4.3 — 80-byte dummy (≥ one Instance) for the sprite-cast
2573 // bindings when capable but no sprite registry is bound this frame.
2574 sprite_cast_dummy: capable.then(|| {
2575 self.device.create_buffer(&wgpu::BufferDescriptor {
2576 label: Some("roxlap-gpu scene_dda.sprite_cast_dummy"),
2577 size: 80,
2578 usage: wgpu::BufferUsages::STORAGE,
2579 mapped_at_creation: false,
2580 })
2581 }),
2582 }
2583 }
2584
2585 /// GPU.10.1 — upload a sprite model registry + its instances for
2586 /// the DDA path. An empty instance slice clears all sprites.
2587 pub fn set_sprite_instances(
2588 &mut self,
2589 registry: &sprite_model::SpriteModelRegistry,
2590 instances: &[sprite_model::SpriteInstance],
2591 ) {
2592 if instances.is_empty() {
2593 self.sprite_registry = None;
2594 return;
2595 }
2596 self.sprite_registry = Some(sprite_model::SpriteRegistryResident::upload(
2597 &self.device,
2598 registry,
2599 instances,
2600 ));
2601 }
2602
2603 /// Incrementally append sprite instances **without** rebuilding the
2604 /// registry — the cheap streaming-spawn path (asteroids, projectiles).
2605 /// Returns the index of the first appended instance (`[base, base+N)`).
2606 ///
2607 /// Every appended instance must reference a model already registered
2608 /// by the [`Self::set_sprite_instances`] that established residency
2609 /// (model volumes are not re-uploaded here — build the full
2610 /// `SpriteModelRegistry` up front and seed it once, then stream
2611 /// instances). If no registry is resident yet, this performs the
2612 /// initial full upload and returns `0`.
2613 ///
2614 /// Cost is amortised O(1) per instance (the GPU instance buffer grows
2615 /// by powers of two), versus the full volume + buffer rebuild of
2616 /// [`Self::set_sprite_instances`].
2617 pub fn append_sprite_instances(
2618 &mut self,
2619 registry: &sprite_model::SpriteModelRegistry,
2620 instances: &[sprite_model::SpriteInstance],
2621 ) -> u32 {
2622 match self.sprite_registry.as_mut() {
2623 Some(reg) => reg.append_instances(&self.device, registry, instances),
2624 None => {
2625 self.set_sprite_instances(registry, instances);
2626 0
2627 }
2628 }
2629 }
2630
2631 /// Remove the sprite instance at `index` (swap-remove, O(1), no model
2632 /// re-upload). Returns `Some(old_last)` if a different instance was
2633 /// moved into `index` to fill the hole — its index changed from
2634 /// `old_last` to `index`, so a caller tracking instance handles must
2635 /// update that one. Returns `None` if `index` was the last element /
2636 /// out of range, or no registry is resident.
2637 pub fn remove_sprite_instance(&mut self, index: usize) -> Option<usize> {
2638 self.sprite_registry
2639 .as_mut()
2640 .and_then(|reg| reg.remove_instance(index))
2641 }
2642
2643 /// Flush queued `write_buffer` uploads by submitting an empty command
2644 /// stream. wgpu stages `write_buffer` data and flushes it on the next
2645 /// `Queue::submit`; calling this between batches of uploads (e.g. a
2646 /// flipbook's frames in [`Self::add_sprite_model`]) recycles the device
2647 /// staging pool so a big one-shot batch can't exhaust it (which would
2648 /// then crash egui-wgpu's own `write_buffer`).
2649 pub fn flush_writes(&self) {
2650 self.queue.submit(std::iter::empty::<wgpu::CommandBuffer>());
2651 }
2652
2653 /// Incrementally add model `chain_id` (its full LOD chain) from
2654 /// `registry` to the resident sprite registry **without**
2655 /// re-uploading the existing models — the streaming-in counterpart
2656 /// to [`Self::append_sprite_instances`]. Register the model on the
2657 /// CPU registry first (`add` / `add_lod`), then pass the returned
2658 /// `chain_id` here; afterwards instances may reference it.
2659 ///
2660 /// If no registry is resident yet, this instead performs the
2661 /// initial full upload of `registry` (all current models, zero
2662 /// instances) to establish residency. Cost is amortised O(new
2663 /// model voxels): the shared volume buffers carry slack and
2664 /// bump-append, rebuilding from the registry only on overflow.
2665 pub fn add_sprite_model(
2666 &mut self,
2667 registry: &sprite_model::SpriteModelRegistry,
2668 chain_id: u32,
2669 ) {
2670 match self.sprite_registry.as_mut() {
2671 Some(reg) => reg.add_model(&self.device, &self.queue, registry, chain_id),
2672 None => {
2673 self.sprite_registry = Some(sprite_model::SpriteRegistryResident::upload(
2674 &self.device,
2675 registry,
2676 &[],
2677 ));
2678 }
2679 }
2680 }
2681
2682 /// Remove a model (tombstone its LOD chain) from the resident sprite
2683 /// registry — the counterpart to [`Self::add_sprite_model`]. Frees its
2684 /// `colors`/`dirs` space for reuse by a later add; the smaller
2685 /// `occupancy`/`color_offsets` holes are reclaimed by
2686 /// [`Self::compact_sprite_models`]. Entry / chain ids stay stable, so
2687 /// other models' `chain_id`s remain valid.
2688 ///
2689 /// Instances of the removed model keep their slots but draw as nothing
2690 /// until the caller drops them via [`Self::remove_sprite_instance`].
2691 /// No-op if `chain_id` is unknown / already removed / no registry.
2692 pub fn remove_sprite_model(&mut self, chain_id: u32) {
2693 if let Some(reg) = self.sprite_registry.as_mut() {
2694 reg.remove_model(chain_id);
2695 }
2696 }
2697
2698 /// Reclaim the holes left by [`Self::remove_sprite_model`] by rebuilding
2699 /// the shared volume buffers from the live models only. `registry` must
2700 /// be the resident one. Cost is O(live volume) — call it when
2701 /// [`Self::dead_sprite_model_count`] is high (e.g. exceeds the live
2702 /// count), not every frame. No-op if no registry is resident.
2703 pub fn compact_sprite_models(&mut self, registry: &sprite_model::SpriteModelRegistry) {
2704 if let Some(reg) = self.sprite_registry.as_mut() {
2705 reg.compact(&self.device, &self.queue, registry);
2706 }
2707 }
2708
2709 /// Number of live (non-removed) sprite models (0 if none uploaded).
2710 #[must_use]
2711 pub fn sprite_model_count(&self) -> usize {
2712 self.sprite_registry
2713 .as_ref()
2714 .map_or(0, sprite_model::SpriteRegistryResident::live_model_count)
2715 }
2716
2717 /// Number of removed-but-not-yet-compacted sprite models — the
2718 /// fragmentation signal for deciding when to call
2719 /// [`Self::compact_sprite_models`].
2720 #[must_use]
2721 pub fn dead_sprite_model_count(&self) -> usize {
2722 self.sprite_registry
2723 .as_ref()
2724 .map_or(0, sprite_model::SpriteRegistryResident::dead_model_count)
2725 }
2726
2727 /// Number of resident sprite instances (0 if none uploaded).
2728 #[must_use]
2729 pub fn sprite_instance_count(&self) -> usize {
2730 self.sprite_registry
2731 .as_ref()
2732 .map_or(0, sprite_model::SpriteRegistryResident::instance_count)
2733 }
2734
2735 /// Re-pose the already-resident sprite instances in place (no model
2736 /// volume re-upload) — the cheap per-frame path for animated KFA
2737 /// limbs. `instances` must match the last [`Self::set_sprite_instances`]
2738 /// in length + order. No-op if no sprite registry is resident.
2739 pub fn update_sprite_instance_transforms(
2740 &mut self,
2741 instances: &[sprite_model::SpriteInstance],
2742 ) {
2743 if let Some(reg) = self.sprite_registry.as_mut() {
2744 reg.update_transforms(instances);
2745 }
2746 }
2747
2748 /// GPU.12 incremental — re-upload only LOD chain `chain_id`'s entries
2749 /// after an in-place edit of `registry` (carve / recolour), without
2750 /// rebuilding the whole sprite registry. `registry` must be the one
2751 /// last passed to [`Self::set_sprite_instances`] with chain
2752 /// `chain_id` already edited. No-op if no registry is resident.
2753 pub fn update_sprite_model(
2754 &mut self,
2755 registry: &sprite_model::SpriteModelRegistry,
2756 chain_id: u32,
2757 ) {
2758 if let Some(reg) = self.sprite_registry.as_mut() {
2759 reg.update_model(&self.device, &self.queue, registry, chain_id);
2760 }
2761 }
2762
2763 /// VCL.2 — repoint sprite instance `index` at LOD chain `chain_id`
2764 /// (the per-frame flipbook step for animated voxel clips). `registry`
2765 /// is the resident one; `chain_id`'s volume must already be uploaded
2766 /// (e.g. a clip's frames registered via [`Self::add_sprite_model`]).
2767 /// CPU-side rewrite picked up by the next frame's cull — no volume
2768 /// re-upload. No-op if no registry is resident.
2769 pub fn set_sprite_instance_model(
2770 &mut self,
2771 registry: &sprite_model::SpriteModelRegistry,
2772 index: usize,
2773 chain_id: u32,
2774 ) {
2775 if let Some(reg) = self.sprite_registry.as_mut() {
2776 reg.set_instance_model(registry, index, chain_id);
2777 }
2778 }
2779
2780 /// Set the per-instance `kv6colmul[256]` lighting tables (voxlap's
2781 /// `update_reflects` output, e.g. via `roxlap_core::sprite::
2782 /// sprite_colmul`), in the same order/length as the last
2783 /// [`Self::set_sprite_instances`]. The GPU sprite pass modulates each
2784 /// voxel by its surface normal's entry — matching the CPU rasteriser.
2785 /// No-op if no sprite registry is resident.
2786 pub fn set_sprite_instance_colmul(&mut self, tables: &[[u64; 256]]) {
2787 if let Some(reg) = self.sprite_registry.as_mut() {
2788 reg.set_instance_colmul(tables);
2789 }
2790 }
2791
2792 /// GPU.10.4 — set the LOD pixel threshold: a sprite steps to the
2793 /// next mip once a mip-0 voxel would project below `px` screen
2794 /// pixels. `1.0` is the natural "no sub-pixel voxels" default;
2795 /// larger values force LOD in closer (useful for inspection).
2796 /// Clamped to ≥ 0.25.
2797 pub fn set_sprite_lod_px(&mut self, px: f32) {
2798 self.sprite_lod_px = px.max(0.25);
2799 }
2800
2801 /// GPU.11.1 — set the scene-grid LOD scan distance (world units).
2802 /// A chunk entered at world-t `t` is marched at mip
2803 /// `floor(log2(max(t, msd) / msd))`, clamped to its grid's mip
2804 /// ladder. `0` disables LOD (always mip-0). Larger values push
2805 /// the coarser mips farther out — the axis-aligned-mip-beams
2806 /// mitigation lever (GPU.11.2). Default 64 (matches CPU
2807 /// `mip_scan_dist`).
2808 pub fn set_scene_mip_scan_dist(&mut self, dist: f32) {
2809 self.scene_mip_scan_dist = dist.max(0.0);
2810 }
2811
2812 /// Set per-face grid side-shading — voxlap's
2813 /// `setsideshades(top, bot, left, right, up, down)`. Each value is
2814 /// subtracted (as a u8, matching the CPU `gcsub` high byte) from a
2815 /// hit voxel's brightness byte before shading, so the scene-DDA pass
2816 /// darkens grid faces the same way the CPU rasteriser does. `[0; 6]`
2817 /// disables it (the default). The hit face is taken from the DDA's
2818 /// last-stepped axis + ray direction.
2819 pub fn set_scene_side_shades(&mut self, s: [i8; 6]) {
2820 // Reinterpret each i8 as u8 (voxlap stamps `sxx` into gcsub's
2821 // high byte verbatim), then pack (top, bot, left, right) /
2822 // (up, down, 0, 0) for the two uniform vec4s.
2823 let v = |i: usize| i32::from(s[i] as u8);
2824 self.scene_side_shades = [[v(0), v(1), v(2), v(3)], [v(4), v(5), 0, 0]];
2825 }
2826
2827 /// GPU.10.1 — build the instanced model-DDA pipeline (one thread
2828 /// per pixel). Lazily invoked the first frame a registry is present.
2829 fn build_sprite_model_dda(&self) -> SpriteModelDdaResources {
2830 // XS.4.2 — on sprite-shadow-capable devices, splice the terrain shadow
2831 // snippet over the stub (`shadow_occluded_world` becomes a real terrain
2832 // march; binds occupancy 16..23). Otherwise the stub keeps sprites
2833 // unshadowed and the BGL stays at the base 14 storage buffers.
2834 let capable = self.sprite_shadows_capable;
2835 let src = sprite_shader_source(capable);
2836 let shader = self
2837 .device
2838 .create_shader_module(wgpu::ShaderModuleDescriptor {
2839 label: Some("sprite_model_dda.wgsl"),
2840 source: wgpu::ShaderSource::Wgsl(src.into()),
2841 });
2842 let mut entries = vec![
2843 bgl_uniform_entry(0),
2844 bgl_storage_entry(1, true), // occupancy
2845 bgl_storage_entry(2, true), // colors
2846 bgl_storage_entry(3, true), // color_offsets
2847 bgl_storage_entry(4, true), // model_meta
2848 bgl_storage_entry(5, true), // instances
2849 bgl_storage_entry(6, true), // scene depth
2850 bgl_storage_entry(7, false), // framebuffer (read-write buffer)
2851 bgl_storage_entry(8, true), // tile_ranges
2852 bgl_storage_entry(9, true), // tile_instances
2853 bgl_storage_entry(10, true), // per-voxel dir
2854 bgl_storage_entry(11, true), // per-instance kv6colmul
2855 bgl_storage_entry(12, true), // TV — material palette
2856 bgl_storage_entry(13, true), // TV.3 — per-voxel material id
2857 bgl_storage_entry(15, true), // DL.7 — world point lights
2858 ];
2859 if capable {
2860 // XS.4.2 — terrain occupancy set for sprite RECEIVE shadows.
2861 entries.push(bgl_storage_entry(16, true)); // occ_page0
2862 entries.push(bgl_storage_entry(17, true)); // occ_page1
2863 entries.push(bgl_storage_entry(18, true)); // occ_page2
2864 entries.push(bgl_storage_entry(19, true)); // occ_page3
2865 entries.push(bgl_storage_entry(20, true)); // all_chunk_occupancy
2866 entries.push(bgl_storage_entry(21, true)); // all_slot_chunk_idx
2867 entries.push(bgl_storage_entry(22, true)); // grid_static_meta
2868 entries.push(bgl_storage_entry(23, true)); // grid_cameras
2869 }
2870 let bgl = self
2871 .device
2872 .create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
2873 label: Some("roxlap-gpu sprite_model_dda.bgl"),
2874 entries: &entries,
2875 });
2876 let pl = self
2877 .device
2878 .create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
2879 label: Some("roxlap-gpu sprite_model_dda.layout"),
2880 bind_group_layouts: &[Some(&bgl)],
2881 immediate_size: 0,
2882 });
2883 let pipeline = self
2884 .device
2885 .create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
2886 label: Some("roxlap-gpu sprite_model_dda.pipeline"),
2887 layout: Some(&pl),
2888 module: &shader,
2889 entry_point: Some("march"),
2890 compilation_options: wgpu::PipelineCompilationOptions::default(),
2891 cache: None,
2892 });
2893 let uniform_buf = self.device.create_buffer(&wgpu::BufferDescriptor {
2894 label: Some("roxlap-gpu sprite_model_dda.uniform"),
2895 size: std::mem::size_of::<SpriteModelUniform>() as u64,
2896 usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
2897 mapped_at_creation: false,
2898 });
2899 // TV — material palette, seeded from the current renderer state so a
2900 // table defined before the sprite pass was built still takes effect.
2901 let materials_buf = {
2902 use wgpu::util::DeviceExt;
2903 self.device
2904 .create_buffer_init(&wgpu::util::BufferInitDescriptor {
2905 label: Some("roxlap-gpu sprite_model_dda.materials"),
2906 contents: bytemuck::cast_slice(self.sprite_materials.as_slice()),
2907 usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
2908 })
2909 };
2910 SpriteModelDdaResources {
2911 bgl,
2912 pipeline,
2913 uniform_buf,
2914 materials_buf,
2915 }
2916 }
2917
2918 /// TV — set the global voxel-material palette for the GPU sprite pass.
2919 /// Mirrors the renderer's [`MaterialTable`](roxlap_formats::material::MaterialTable):
2920 /// every sprite/clip instance's `material` id indexes it for opacity +
2921 /// blend mode. Cheap (2 KB); call it whenever the palette changes (or
2922 /// each frame). While every material is opaque the shader stays on the
2923 /// unchanged first-hit path.
2924 pub fn set_sprite_materials(&mut self, table: &roxlap_formats::material::MaterialTable) {
2925 let (palette, any_translucent) = material_palette(table);
2926 self.sprite_materials = palette;
2927 self.sprite_has_translucent = any_translucent;
2928 if let Some(smd) = &self.sprite_model_dda {
2929 self.queue.write_buffer(
2930 &smd.materials_buf,
2931 0,
2932 bytemuck::cast_slice(self.sprite_materials.as_slice()),
2933 );
2934 }
2935 }
2936
2937 /// TV.6 — set the scene (terrain) material palette + colour→material map
2938 /// for the multi-grid scene pass. Matching-colour terrain voxels render
2939 /// translucent (and/or emissive, EV.2); an empty map / all-opaque
2940 /// non-emissive palette renders unchanged. The map is capped at 256 rows
2941 /// (the fixed buffer size).
2942 pub fn set_scene_terrain_materials(
2943 &mut self,
2944 table: &roxlap_formats::material::MaterialTable,
2945 map: &[(Rgb, u8)],
2946 ) {
2947 let (palette, _) = material_palette(table);
2948 self.scene_materials = palette;
2949 self.scene_terrain_map = map
2950 .iter()
2951 .take(256)
2952 .map(|&(c, m)| [c.0 & 0x00ff_ffff, u32::from(m)])
2953 .collect();
2954 // EV.2 — the material path also activates for emissive mappings
2955 // (an opaque glowing palette must leave the first-hit fast path).
2956 self.scene_terrain_translucent = map.iter().any(|&(_, m)| {
2957 let mm = table.get(m);
2958 !mm.is_opaque() || mm.emissive > 0
2959 });
2960 if let Some(dda) = &self.scene_dda {
2961 self.queue.write_buffer(
2962 &dda.materials_pal_buf,
2963 0,
2964 bytemuck::cast_slice(self.scene_materials.as_slice()),
2965 );
2966 if !self.scene_terrain_map.is_empty() {
2967 self.queue.write_buffer(
2968 &dda.terrain_map_buf,
2969 0,
2970 bytemuck::cast_slice(&self.scene_terrain_map),
2971 );
2972 }
2973 }
2974 }
2975}
2976
2977/// GPU.11 — headless scene-DDA renderer for tests + offline visual
2978/// gates. Owns the `scene_dda.wgsl` compute pipeline with no surface
2979/// and no blit pass; renders a [`GpuSceneResident`] to an in-memory
2980/// RGBA framebuffer via texture readback. The per-substage visual
2981/// gate (render reference scenes, diff PPMs) and the GPU.11.1 mip
2982/// render-diff both ride on this.
2983pub struct HeadlessSceneRenderer {
2984 width: u32,
2985 height: u32,
2986 /// Framebuffer storage buffer (packed `rgba8unorm`, tight rows) —
2987 /// matches the buffer-output `scene_dda.wgsl` (see its note).
2988 framebuffer: wgpu::Buffer,
2989 depth_buffer: wgpu::Buffer,
2990 uniform_buf: wgpu::Buffer,
2991 _sky_texture: wgpu::Texture,
2992 sky_view: wgpu::TextureView,
2993 sky_sampler: wgpu::Sampler,
2994 bgl: wgpu::BindGroupLayout,
2995 pipeline: wgpu::ComputePipeline,
2996 readback: wgpu::Buffer,
2997 /// Per-face side-shades for the gate render (default none). Packed
2998 /// `[(top,bot,left,right), (up,down,_,_)]`; set via
2999 /// [`Self::set_side_shades`].
3000 side_shades: [[i32; 4]; 2],
3001 /// DL — dynamic lights for the render (already grid-local, like the
3002 /// surface path). Default = none (baked-only). Set via
3003 /// [`Self::set_scene_lights`]; lets tests exercise the lit path.
3004 lights: SceneLights,
3005}
3006
3007impl HeadlessSceneRenderer {
3008 /// Build the compute pipeline + output/readback resources for a
3009 /// `width × height` framebuffer. Validates `scene_dda.wgsl` and
3010 /// the [`scene::GridStaticMeta`] std430 layout at pipeline /
3011 /// bind-group time.
3012 #[must_use]
3013 pub fn new(device: &wgpu::Device, queue: &wgpu::Queue, width: u32, height: u32) -> Self {
3014 let framebuffer = device.create_buffer(&wgpu::BufferDescriptor {
3015 label: Some("roxlap-gpu headless.framebuffer"),
3016 size: u64::from(width) * u64::from(height) * 4,
3017 usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC,
3018 mapped_at_creation: false,
3019 });
3020
3021 let uniform_buf = device.create_buffer(&wgpu::BufferDescriptor {
3022 label: Some("roxlap-gpu headless.uniform"),
3023 size: std::mem::size_of::<SceneDdaUniform>() as u64,
3024 usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
3025 mapped_at_creation: false,
3026 });
3027 let depth_buffer = device.create_buffer(&wgpu::BufferDescriptor {
3028 label: Some("roxlap-gpu headless.depth"),
3029 size: u64::from(width) * u64::from(height) * 4,
3030 usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
3031 mapped_at_creation: false,
3032 });
3033
3034 let default_sky_pixel = [120u8, 150, 220, 255];
3035 let (sky_texture, sky_view) = create_sky_texture(device, 1, 1, &default_sky_pixel);
3036 // Upload the default sky texel (create_sky_texture only allocates
3037 // — the texel must be written or the shader samples black, which
3038 // is why a grid-less headless render came back black).
3039 queue.write_texture(
3040 wgpu::TexelCopyTextureInfo {
3041 texture: &sky_texture,
3042 mip_level: 0,
3043 origin: wgpu::Origin3d::ZERO,
3044 aspect: wgpu::TextureAspect::All,
3045 },
3046 &default_sky_pixel,
3047 wgpu::TexelCopyBufferLayout {
3048 offset: 0,
3049 bytes_per_row: Some(4),
3050 rows_per_image: Some(1),
3051 },
3052 wgpu::Extent3d {
3053 width: 1,
3054 height: 1,
3055 depth_or_array_layers: 1,
3056 },
3057 );
3058 let sky_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
3059 label: Some("roxlap-gpu headless.sky_sampler"),
3060 address_mode_u: wgpu::AddressMode::Repeat,
3061 address_mode_v: wgpu::AddressMode::Repeat,
3062 mag_filter: wgpu::FilterMode::Linear,
3063 min_filter: wgpu::FilterMode::Linear,
3064 ..Default::default()
3065 });
3066
3067 let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
3068 label: Some("scene_dda.wgsl (headless)"),
3069 // QE.8 — assembled source (common snippet + stub variant);
3070 // the raw file is no longer standalone-valid WGSL.
3071 source: wgpu::ShaderSource::Wgsl(crate::shader_src::scene_shader_source(false).into()),
3072 });
3073 let bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
3074 label: Some("roxlap-gpu headless.bgl"),
3075 entries: &[
3076 bgl_uniform_entry(0),
3077 bgl_storage_entry(1, true),
3078 bgl_storage_entry(2, true),
3079 bgl_storage_entry(3, true),
3080 bgl_storage_entry(4, true),
3081 bgl_storage_entry(5, true),
3082 bgl_storage_entry(6, true),
3083 bgl_storage_entry(7, true),
3084 // Framebuffer storage buffer (read-write).
3085 bgl_storage_entry(8, false),
3086 wgpu::BindGroupLayoutEntry {
3087 binding: 9,
3088 visibility: wgpu::ShaderStages::COMPUTE,
3089 ty: wgpu::BindingType::Texture {
3090 sample_type: wgpu::TextureSampleType::Float { filterable: true },
3091 view_dimension: wgpu::TextureViewDimension::D2,
3092 multisampled: false,
3093 },
3094 count: None,
3095 },
3096 wgpu::BindGroupLayoutEntry {
3097 binding: 10,
3098 visibility: wgpu::ShaderStages::COMPUTE,
3099 ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
3100 count: None,
3101 },
3102 bgl_storage_entry(11, false),
3103 bgl_storage_entry(12, true),
3104 bgl_storage_entry(13, true),
3105 bgl_storage_entry(14, true),
3106 // Per-grid cameras (runtime-sized; one per grid).
3107 bgl_storage_entry(15, true),
3108 // TV.6 — material palette + terrain map (opaque dummies here).
3109 bgl_storage_entry(16, true),
3110 bgl_storage_entry(17, true),
3111 // DL — per-grid point lights (18). Sun dir rides in
3112 // PerGridCamera (binding 15).
3113 bgl_storage_entry(18, true),
3114 ],
3115 });
3116 let pl = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
3117 label: Some("roxlap-gpu headless.layout"),
3118 bind_group_layouts: &[Some(&bgl)],
3119 immediate_size: 0,
3120 });
3121 let pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
3122 label: Some("roxlap-gpu headless.pipeline"),
3123 layout: Some(&pl),
3124 module: &shader,
3125 entry_point: Some("render_scene"),
3126 compilation_options: wgpu::PipelineCompilationOptions::default(),
3127 cache: None,
3128 });
3129
3130 // Readback is a tight buffer-to-buffer copy (no 256-byte row
3131 // padding, unlike the old texture-to-buffer path).
3132 let readback = device.create_buffer(&wgpu::BufferDescriptor {
3133 label: Some("roxlap-gpu headless.readback"),
3134 size: u64::from(width) * u64::from(height) * 4,
3135 usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
3136 mapped_at_creation: false,
3137 });
3138
3139 Self {
3140 width,
3141 height,
3142 framebuffer,
3143 depth_buffer,
3144 uniform_buf,
3145 _sky_texture: sky_texture,
3146 sky_view,
3147 sky_sampler,
3148 bgl,
3149 pipeline,
3150 readback,
3151 side_shades: [[0; 4]; 2],
3152 lights: SceneLights::default(),
3153 }
3154 }
3155
3156 /// Set per-face side-shades for subsequent [`Self::render`] calls —
3157 /// voxlap `setsideshades(top, bot, left, right, up, down)`, each an
3158 /// i8 stamped as u8 (matching the engine path). Lets the gate test
3159 /// the GPU side-shade darkening.
3160 pub fn set_side_shades(&mut self, s: [i8; 6]) {
3161 let v = |i: usize| i32::from(s[i] as u8);
3162 self.side_shades = [[v(0), v(1), v(2), v(3)], [v(4), v(5), 0, 0]];
3163 }
3164
3165 /// Render `scene` from `cameras` (one per grid) and read the
3166 /// framebuffer back as `width*height` packed `0xAABBGGRR` pixels
3167 /// (R in the low byte). Fog is disabled. `mip_scan_dist` drives
3168 /// the GPU.11.1 scene-grid LOD (`0` = always mip-0). Blocks on
3169 /// readback.
3170 ///
3171 /// # Panics
3172 /// If `cameras.len() != scene.grid_count`.
3173 /// Headless render with identity per-grid world transforms (shadows stay
3174 /// intra-grid). See [`Self::render_with_transforms`] for the cross-grid
3175 /// (XS.3) variant.
3176 #[must_use]
3177 #[allow(clippy::too_many_arguments)]
3178 pub fn render(
3179 &self,
3180 device: &wgpu::Device,
3181 queue: &wgpu::Queue,
3182 scene: &GpuSceneResident,
3183 cameras: &[Camera],
3184 fov_y_rad: f32,
3185 max_outer_steps: u32,
3186 mip_scan_dist: f32,
3187 ) -> Vec<u32> {
3188 self.render_with_transforms(
3189 device,
3190 queue,
3191 scene,
3192 cameras,
3193 &[],
3194 fov_y_rad,
3195 max_outer_steps,
3196 mip_scan_dist,
3197 )
3198 }
3199
3200 /// XS.3 — headless render with explicit per-grid world transforms, so the
3201 /// scene shader can lift a shadow ray to world space and test it against
3202 /// every grid (cross-grid shadows). Empty `grid_world` ⇒ identity.
3203 #[must_use]
3204 #[allow(clippy::too_many_arguments)]
3205 pub fn render_with_transforms(
3206 &self,
3207 device: &wgpu::Device,
3208 queue: &wgpu::Queue,
3209 scene: &GpuSceneResident,
3210 cameras: &[Camera],
3211 grid_world: &[GridWorldTransform],
3212 fov_y_rad: f32,
3213 max_outer_steps: u32,
3214 mip_scan_dist: f32,
3215 ) -> Vec<u32> {
3216 assert_eq!(
3217 cameras.len(),
3218 scene.grid_count as usize,
3219 "headless render: {} cameras for {} grids",
3220 cameras.len(),
3221 scene.grid_count,
3222 );
3223
3224 let mut cam_vec: Vec<SceneDdaPerGridCamera> = cameras
3225 .iter()
3226 .map(SceneDdaPerGridCamera::from_camera)
3227 .collect();
3228 // XS.3 — stamp world transforms for cross-grid shadows (identity if absent).
3229 for (c, t) in cam_vec.iter_mut().zip(grid_world.iter()) {
3230 c.set_world_transform(t);
3231 }
3232 // TV.6 — opaque dummies for the material palette + terrain map
3233 // bindings (headless renders opaque-only: terrain_has_translucent=0).
3234 let (dummy_pal, dummy_map) = {
3235 use wgpu::util::DeviceExt;
3236 let pal: Vec<MaterialGpu> = vec![
3237 MaterialGpu {
3238 alpha: 1.0,
3239 mode: 0,
3240 emissive: 0.0,
3241 _pad: 0,
3242 };
3243 256
3244 ];
3245 let p = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
3246 label: Some("roxlap-gpu headless.materials_pal"),
3247 contents: bytemuck::cast_slice(&pal),
3248 usage: wgpu::BufferUsages::STORAGE,
3249 });
3250 let m = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
3251 label: Some("roxlap-gpu headless.terrain_map"),
3252 contents: bytemuck::cast_slice(&[[0u32; 2]]),
3253 usage: wgpu::BufferUsages::STORAGE,
3254 });
3255 (p, m)
3256 };
3257 // DL — pack any dynamic lights (default none ⇒ the baked-only path,
3258 // matching the oracle goldens). Injects sun dir into cam_vec.sun_dir
3259 // and builds the point-light buffer (binding 18). Shared with the
3260 // surface path.
3261 let dl = self.lights.clone();
3262 inject_grid_sun_dirs(&dl, &mut cam_vec);
3263 let (packed_lights, sun_flags, point_count) =
3264 pack_scene_lights(&dl, scene.grid_count as usize);
3265 let dummy_point_lights = upload_grid_point_lights(device, &packed_lights);
3266 let grid_cameras = upload_grid_cameras(device, &cam_vec);
3267 let uniform = SceneDdaUniform {
3268 fov_y_rad,
3269 grid_count: scene.grid_count,
3270 max_outer_steps,
3271 _pad0: 0,
3272 screen_size: [self.width, self.height],
3273 _pad1: [0; 2],
3274 // Fog off: near/far past any reachable t → factor 0.
3275 fog_color: [0.0, 0.0, 0.0, 1.0e29],
3276 fog_far: 1.0e30,
3277 write_depth: 0,
3278 occ_page_words: scene.occupancy_page_words,
3279 occ_num_pages: scene.occupancy_num_pages,
3280 mip_scan_dist,
3281 terrain_has_translucent: 0, // headless gate: opaque only
3282 terrain_map_count: 0,
3283 _pad4: 0,
3284 // Sky direction from the first grid camera (the world frame
3285 // in these tests); a default forward camera when there are
3286 // none (grid_count == 0) so the sky lookup stays valid.
3287 sky_cam: SceneDdaPerGridCamera::from_camera(&cameras.first().copied().unwrap_or(
3288 Camera {
3289 position: [0.0; 3],
3290 right: [1.0, 0.0, 0.0],
3291 down: [0.0, 0.0, 1.0],
3292 forward: [0.0, 1.0, 0.0],
3293 fov_y_rad,
3294 },
3295 )),
3296 side_shades0: self.side_shades[0],
3297 side_shades1: self.side_shades[1],
3298 // DL — light parameters (default = no lights ⇒ sun_flags 0).
3299 sun_color: [
3300 dl.sun_color[0],
3301 dl.sun_color[1],
3302 dl.sun_color[2],
3303 dl.sun_intensity,
3304 ],
3305 ambient_color: [
3306 dl.ambient[0],
3307 dl.ambient[1],
3308 dl.ambient[2],
3309 dl.shadow_strength,
3310 ],
3311 sun_flags,
3312 point_light_count: point_count,
3313 shadow_max_steps: dl.shadow_max_steps,
3314 _pad5: 0,
3315 shadow_bias: dl.shadow_bias,
3316 shadow_max_dist: dl.shadow_max_dist,
3317 _pad6: [0.0; 2],
3318 shadow_tint: [dl.shadow_tint[0], dl.shadow_tint[1], dl.shadow_tint[2], 0.0],
3319 style_bands: dl.style_bands,
3320 sprite_cast_count: 0, // headless renderer has no sprite pass
3321 _pad7: [0; 2],
3322 };
3323 queue.write_buffer(&self.uniform_buf, 0, bytemuck::bytes_of(&uniform));
3324
3325 let bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
3326 label: Some("roxlap-gpu headless.bg"),
3327 layout: &self.bgl,
3328 entries: &[
3329 wgpu::BindGroupEntry {
3330 binding: 0,
3331 resource: self.uniform_buf.as_entire_binding(),
3332 },
3333 wgpu::BindGroupEntry {
3334 binding: 1,
3335 resource: scene.occupancy_pages[0].as_entire_binding(),
3336 },
3337 wgpu::BindGroupEntry {
3338 binding: 2,
3339 resource: scene.all_color_offsets.as_entire_binding(),
3340 },
3341 wgpu::BindGroupEntry {
3342 binding: 3,
3343 resource: scene.all_colors.as_entire_binding(),
3344 },
3345 wgpu::BindGroupEntry {
3346 binding: 4,
3347 resource: scene.all_chunk_colors_base.as_entire_binding(),
3348 },
3349 wgpu::BindGroupEntry {
3350 binding: 5,
3351 resource: scene.all_chunk_occupancy.as_entire_binding(),
3352 },
3353 wgpu::BindGroupEntry {
3354 binding: 6,
3355 resource: scene.grid_static_meta.as_entire_binding(),
3356 },
3357 wgpu::BindGroupEntry {
3358 binding: 7,
3359 resource: scene.all_slot_chunk_idx.as_entire_binding(),
3360 },
3361 wgpu::BindGroupEntry {
3362 binding: 8,
3363 resource: self.framebuffer.as_entire_binding(),
3364 },
3365 wgpu::BindGroupEntry {
3366 binding: 9,
3367 resource: wgpu::BindingResource::TextureView(&self.sky_view),
3368 },
3369 wgpu::BindGroupEntry {
3370 binding: 10,
3371 resource: wgpu::BindingResource::Sampler(&self.sky_sampler),
3372 },
3373 wgpu::BindGroupEntry {
3374 binding: 11,
3375 resource: self.depth_buffer.as_entire_binding(),
3376 },
3377 wgpu::BindGroupEntry {
3378 binding: 12,
3379 resource: scene.occupancy_pages[1].as_entire_binding(),
3380 },
3381 wgpu::BindGroupEntry {
3382 binding: 13,
3383 resource: scene.occupancy_pages[2].as_entire_binding(),
3384 },
3385 wgpu::BindGroupEntry {
3386 binding: 14,
3387 resource: scene.occupancy_pages[3].as_entire_binding(),
3388 },
3389 wgpu::BindGroupEntry {
3390 binding: 15,
3391 resource: grid_cameras.as_entire_binding(),
3392 },
3393 wgpu::BindGroupEntry {
3394 binding: 16,
3395 resource: dummy_pal.as_entire_binding(),
3396 },
3397 wgpu::BindGroupEntry {
3398 binding: 17,
3399 resource: dummy_map.as_entire_binding(),
3400 },
3401 // DL — dummy per-grid point lights (18). Sun dir rides in
3402 // PerGridCamera (binding 15).
3403 wgpu::BindGroupEntry {
3404 binding: 18,
3405 resource: dummy_point_lights.as_entire_binding(),
3406 },
3407 ],
3408 });
3409
3410 let mut enc =
3411 device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
3412 {
3413 let mut pass = enc.begin_compute_pass(&wgpu::ComputePassDescriptor {
3414 label: Some("roxlap-gpu headless.pass"),
3415 timestamp_writes: None,
3416 });
3417 pass.set_pipeline(&self.pipeline);
3418 pass.set_bind_group(0, &bg, &[]);
3419 pass.dispatch_workgroups(self.width.div_ceil(8), self.height.div_ceil(8), 1);
3420 }
3421 enc.copy_buffer_to_buffer(
3422 &self.framebuffer,
3423 0,
3424 &self.readback,
3425 0,
3426 u64::from(self.width) * u64::from(self.height) * 4,
3427 );
3428 queue.submit(Some(enc.finish()));
3429
3430 let slice = self.readback.slice(..);
3431 let (tx, rx) = std::sync::mpsc::channel();
3432 slice.map_async(wgpu::MapMode::Read, move |r| {
3433 let _ = tx.send(r);
3434 });
3435 device.poll(wgpu::PollType::wait_indefinitely()).ok();
3436 rx.recv().expect("map_async channel").expect("map_async");
3437
3438 let data = slice.get_mapped_range();
3439 // Tight `width*height` packed pixels — the shader's
3440 // `pack4x8unorm(vec4(r,g,b,a))` already yields `0xAABBGGRR`
3441 // little-endian, so a straight u32 read reconstructs each pixel.
3442 let out: Vec<u32> = data
3443 .chunks_exact(4)
3444 .map(|px| u32::from_le_bytes([px[0], px[1], px[2], px[3]]))
3445 .collect();
3446 drop(data);
3447 self.readback.unmap();
3448 out
3449 }
3450}
3451
3452fn bgl_uniform_entry(binding: u32) -> wgpu::BindGroupLayoutEntry {
3453 wgpu::BindGroupLayoutEntry {
3454 binding,
3455 visibility: wgpu::ShaderStages::COMPUTE,
3456 ty: wgpu::BindingType::Buffer {
3457 ty: wgpu::BufferBindingType::Uniform,
3458 has_dynamic_offset: false,
3459 min_binding_size: None,
3460 },
3461 count: None,
3462 }
3463}
3464
3465fn bgl_storage_entry(binding: u32, read_only: bool) -> wgpu::BindGroupLayoutEntry {
3466 wgpu::BindGroupLayoutEntry {
3467 binding,
3468 visibility: wgpu::ShaderStages::COMPUTE,
3469 ty: wgpu::BindingType::Buffer {
3470 ty: wgpu::BufferBindingType::Storage { read_only },
3471 has_dynamic_offset: false,
3472 min_binding_size: None,
3473 },
3474 count: None,
3475 }
3476}
3477
3478/// Create a fresh sky panorama texture sized `width × height` with
3479/// the initial pixel data uploaded via `write_texture`. Used by
3480/// `GpuRenderer::new` (1×1 default) and `set_sky_panorama` (host-
3481/// supplied panorama).
3482fn create_sky_texture(
3483 device: &wgpu::Device,
3484 width: u32,
3485 height: u32,
3486 _initial_pixels: &[u8],
3487) -> (wgpu::Texture, wgpu::TextureView) {
3488 let tex = device.create_texture(&wgpu::TextureDescriptor {
3489 label: Some("roxlap-gpu sky_texture"),
3490 size: wgpu::Extent3d {
3491 width,
3492 height,
3493 depth_or_array_layers: 1,
3494 },
3495 mip_level_count: 1,
3496 sample_count: 1,
3497 dimension: wgpu::TextureDimension::D2,
3498 format: wgpu::TextureFormat::Rgba8Unorm,
3499 usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
3500 view_formats: &[],
3501 });
3502 let view = tex.create_view(&wgpu::TextureViewDescriptor::default());
3503 (tex, view)
3504}
3505
3506/// GPU.4 needs to upload a whole grid (~hundreds of MiB) as a few
3507/// storage buffers. wgpu's default `max_storage_buffer_binding_size`
3508/// is 128 MiB, which is just enough for the demo's 32×32 ground
3509/// occupancy (~128 MiB) but not the colour array. We request as
3510/// much as the adapter is willing to give — most desktop GPUs cap
3511/// individual storage buffers at 2-4 GiB; iGPUs often offer the
3512/// full system memory.
3513pub(crate) fn pick_required_limits(adapter_limits: &wgpu::Limits) -> wgpu::Limits {
3514 wgpu::Limits {
3515 max_storage_buffer_binding_size: adapter_limits.max_storage_buffer_binding_size,
3516 max_buffer_size: adapter_limits.max_buffer_size,
3517 // Occupancy paging adds up to MAX_OCC_PAGES-1 extra storage
3518 // bindings; with the scene's other buffers + the GPU.9 depth
3519 // buffer the scene_dda stage needs 16. XS.4 GPU sprite shadows
3520 // need more (the sprite pass binds the terrain occupancy set on
3521 // top of its own — up to `SPRITE_SHADOW_MIN_STORAGE_BUFFERS`), so
3522 // request that many when the adapter offers them; capable devices
3523 // light up sprite shadows, others fall back (still ≥16 for the
3524 // base renderer). Both NVK and lavapipe advertise ≫16.
3525 max_storage_buffers_per_shader_stage: adapter_limits
3526 .max_storage_buffers_per_shader_stage
3527 .min(SPRITE_SHADOW_MIN_STORAGE_BUFFERS),
3528 ..wgpu::Limits::default()
3529 }
3530}
3531
3532/// XS.4 — storage buffers per shader stage needed for GPU sprite shadows. The
3533/// sprite pass binds its own 14 + the terrain occupancy set (occupancy pages
3534/// 0..3, chunk occupancy, slot index, grid meta, per-grid cameras) to march
3535/// terrain shadows. Devices granting fewer fall back to unshadowed GPU sprites.
3536pub(crate) const SPRITE_SHADOW_MIN_STORAGE_BUFFERS: u32 = 22;
3537
3538fn pick_present_mode(modes: &[wgpu::PresentMode]) -> wgpu::PresentMode {
3539 // Prefer Mailbox > Immediate > Fifo. Fifo is the universal
3540 // fallback and the only one Wayland-on-Mesa always offers.
3541 for &m in &[wgpu::PresentMode::Mailbox, wgpu::PresentMode::Immediate] {
3542 if modes.contains(&m) {
3543 return m;
3544 }
3545 }
3546 wgpu::PresentMode::Fifo
3547}