Skip to main content

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) => (&reg.instances, &reg.model_meta, &reg.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}