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