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