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