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
46pub use camera::Camera;
47pub use decompress::{decompress_chunk, ChunkUpload, BEDROCK_RGB, CHUNK_Z};
48pub use grid::{bounding_box_of, GpuGridResident, GridUpload};
49#[cfg(not(target_arch = "wasm32"))]
50pub use headless::HeadlessGpu;
51pub use resident::GpuChunkResident;
52pub use scene::{
53    GpuSceneResident, GridRuntimeTransform, GridStaticMeta, RefreshOutcome, SceneUpload,
54};
55pub use sprite_model::{
56    build_sprite_model, build_sprite_model_with_materials, sprite_model_from_clip_frame,
57    sprite_model_from_clip_frame_with_materials, sprite_model_from_voxel_frame,
58    sprite_model_from_voxel_frame_with_materials, SpriteInstance, SpriteInstanceTransform,
59    SpriteModel, SpriteModelRegistry, SpriteRegistryResident,
60};
61
62use std::sync::Arc;
63
64use bytemuck::{Pod, Zeroable};
65use raw_window_handle::{HasDisplayHandle, HasWindowHandle};
66
67/// Caller-controllable knobs for [`GpuRenderer::new`]. Defaults
68/// target "highest-performance GPU, prefer Mailbox/Immediate over
69/// vsync" — i.e. the same configuration the GPU.0 probe used to
70/// measure the FPS ceiling.
71#[derive(Debug, Clone, Copy)]
72pub struct GpuRendererSettings {
73    pub power_preference: PowerPreference,
74    /// Initial clear colour cycled by GPU.1's empty render path.
75    /// The voxel-rendering substages overwrite this entirely.
76    pub clear_colour: [f64; 3],
77    /// Prefer mailbox/immediate when offered; falls back to FIFO if
78    /// the surface only supports it (Wayland under Mesa often does).
79    pub uncapped_present: bool,
80}
81
82#[derive(Debug, Clone, Copy)]
83pub enum PowerPreference {
84    Low,
85    High,
86}
87
88impl Default for GpuRendererSettings {
89    fn default() -> Self {
90        Self {
91            power_preference: PowerPreference::High,
92            clear_colour: [0.06, 0.08, 0.12],
93            uncapped_present: true,
94        }
95    }
96}
97
98/// Errors `GpuRenderer::new` surfaces to the host. The host's
99/// expected flow is "try this, fall back to the CPU path on Err".
100#[derive(Debug)]
101pub enum GpuInitError {
102    CreateSurface(wgpu::CreateSurfaceError),
103    NoAdapter,
104    RequestDevice(wgpu::RequestDeviceError),
105}
106
107impl std::fmt::Display for GpuInitError {
108    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
109        match self {
110            Self::CreateSurface(e) => write!(f, "create_surface failed: {e}"),
111            Self::NoAdapter => write!(
112                f,
113                "no compatible adapter — does this system have a Vulkan/Metal/DX12 driver?"
114            ),
115            Self::RequestDevice(e) => write!(f, "request_device failed: {e}"),
116        }
117    }
118}
119
120impl std::error::Error for GpuInitError {
121    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
122        match self {
123            Self::CreateSurface(e) => Some(e),
124            Self::RequestDevice(e) => Some(e),
125            Self::NoAdapter => None,
126        }
127    }
128}
129
130impl From<wgpu::CreateSurfaceError> for GpuInitError {
131    fn from(value: wgpu::CreateSurfaceError) -> Self {
132        Self::CreateSurface(value)
133    }
134}
135
136impl From<wgpu::RequestDeviceError> for GpuInitError {
137    fn from(value: wgpu::RequestDeviceError) -> Self {
138        Self::RequestDevice(value)
139    }
140}
141
142/// WGPU-backed renderer. Owns the device, queue, and surface
143/// bound to the host's window. [`Self::render`] is the GPU.1
144/// clear-to-colour path; [`Self::render_chunk`] is GPU.3's
145/// single-chunk DDA marcher.
146///
147/// The window is consumed only at construction — `wgpu`'s
148/// `Surface<'static>` keeps its own `Arc` clone of the handle, so
149/// the renderer holds no window field of its own.
150/// A world-space line segment for [`GpuRenderer::draw_lines_deferred`].
151/// `color` is straight RGBA in `0..=1` (the alpha drives the over-blend);
152/// `width_px` is the screen-space thickness; `depth_test` occludes the
153/// segment behind nearer marched geometry.
154#[derive(Clone, Copy, Debug)]
155pub struct GpuLine {
156    pub a: [f32; 3],
157    pub b: [f32; 3],
158    pub color: [f32; 4],
159    pub width_px: f32,
160    pub depth_test: bool,
161}
162
163/// World camera basis for projecting [`GpuLine`] endpoints — the same
164/// pinhole the scene-DDA pass marches with (`right`/`down`/`forward`
165/// orthonormal, `pos` in world voxel units).
166#[derive(Clone, Copy, Debug)]
167pub struct GpuLineCamera {
168    pub pos: [f32; 3],
169    pub right: [f32; 3],
170    pub down: [f32; 3],
171    pub forward: [f32; 3],
172}
173
174/// Near plane (camera-forward distance) below which a [`GpuLine`] endpoint
175/// is clipped, so the pinhole divide stays finite.
176const LINE_NEAR_Z: f32 = 0.0625;
177/// Depth-test slack (euclidean world distance) so a line resting on the
178/// surface it traces doesn't z-fight the marched geometry.
179const LINE_DEPTH_BIAS: f32 = 0.5;
180
181/// One expanded-quad vertex (`build_line_vertices` output). `pos` is NDC;
182/// `depth` is the euclidean world distance of the source endpoint (the
183/// marcher's `best_t` metric); `depth_test` is `1.0`/`0.0`.
184#[repr(C)]
185#[derive(Clone, Copy, Pod, Zeroable)]
186struct LineVertex {
187    pos: [f32; 2],
188    depth: f32,
189    depth_test: f32,
190    color: [f32; 4],
191}
192
193/// `line.wgsl` / `image.wgsl` fragment uniform (std140; padded to 32 bytes
194/// so the uniform's struct stride is a 16-byte multiple).
195#[repr(C)]
196#[derive(Clone, Copy, Pod, Zeroable)]
197struct LineParams {
198    /// Target (swapchain) size — the range of the fragment's `clip.xy`.
199    screen_w: u32,
200    screen_h: u32,
201    depth_bias: f32,
202    no_depth: u32,
203    /// 1 when the viewport flip is on. The depth buffer is written
204    /// unflipped (the blit mirrors at read time), but these passes flip the
205    /// vertex NDC X, so the fragment must mirror its depth lookup to match.
206    flip_x: u32,
207    /// RP.0 — the **render** (logical) size the depth buffer is stored at.
208    /// The fragment scales its swapchain `clip.xy` into this grid for the
209    /// depth lookup. Equal to `screen_*` under `Native` (identity).
210    depth_w: u32,
211    depth_h: u32,
212    _pad: u32,
213}
214
215/// Lazy-built debug-line pipeline (L3.2). The bind group is rebuilt each
216/// draw (it references the current `scene_dda.depth_buffer`, which the
217/// swapchain resize recreates); the pipeline / layout / uniform persist.
218struct LineResources {
219    pipeline: wgpu::RenderPipeline,
220    bgl: wgpu::BindGroupLayout,
221    uniform_buf: wgpu::Buffer,
222    /// 1-word stand-in bound when no scene depth exists (sprite-only /
223    /// empty scene); `no_depth = 1` keeps the shader from indexing it.
224    dummy_depth: wgpu::Buffer,
225}
226
227/// Project + expand world-space [`GpuLine`]s into screen-space quad
228/// vertices (6 per visible segment) for `line.wgsl`. Mirrors the
229/// scene-DDA pinhole (`forward + ndc_x·half_w·right − ndc_y·half_h·down`)
230/// so lines land on the marched geometry, carrying each endpoint's
231/// euclidean world distance as the depth-test key (= the marcher's
232/// `best_t`). Segments fully behind the near plane are dropped; the rest
233/// are clipped to it.
234fn build_line_vertices(
235    cam: &GpuLineCamera,
236    lines: &[GpuLine],
237    w: u32,
238    h: u32,
239    fov_y: f32,
240    flip_x: bool,
241) -> Vec<LineVertex> {
242    let aspect = w as f32 / h as f32;
243    let half_h = (fov_y * 0.5).tan();
244    let half_w = half_h * aspect;
245    let (wf, hf) = (w as f32, h as f32);
246
247    let cam_coords = |p: [f32; 3]| -> [f32; 3] {
248        let d = [p[0] - cam.pos[0], p[1] - cam.pos[1], p[2] - cam.pos[2]];
249        [
250            cam.right[0] * d[0] + cam.right[1] * d[1] + cam.right[2] * d[2],
251            cam.down[0] * d[0] + cam.down[1] * d[1] + cam.down[2] * d[2],
252            cam.forward[0] * d[0] + cam.forward[1] * d[1] + cam.forward[2] * d[2],
253        ]
254    };
255    // Camera-space point → (NDC xy, euclidean depth). NDC y is up (+1 top),
256    // matching WebGPU clip space; depth is the marcher's world-t metric.
257    let project = |q: [f32; 3]| -> ([f32; 2], f32) {
258        let inv = 1.0 / q[2];
259        let nx = q[0] * inv / half_w;
260        let ny = -q[1] * inv / half_h;
261        let depth = (q[0] * q[0] + q[1] * q[1] + q[2] * q[2]).sqrt();
262        ([nx, ny], depth)
263    };
264
265    let mut out = Vec::with_capacity(lines.len() * 6);
266    for line in lines {
267        let ca = cam_coords(line.a);
268        let cb = cam_coords(line.b);
269        let (cfa, cfb) = (ca[2], cb[2]);
270        if cfa < LINE_NEAR_Z && cfb < LINE_NEAR_Z {
271            continue;
272        }
273        // Near-clip in segment-parameter space on the forward component.
274        let (mut t0, mut t1) = (0.0f32, 1.0f32);
275        let dz = cfb - cfa;
276        if dz.abs() > f32::EPSILON {
277            let tn = (LINE_NEAR_Z - cfa) / dz;
278            if dz > 0.0 {
279                t0 = t0.max(tn);
280            } else {
281                t1 = t1.min(tn);
282            }
283        }
284        if t0 > t1 {
285            continue;
286        }
287        let lerp3 = |t: f32| {
288            [
289                ca[0] + (cb[0] - ca[0]) * t,
290                ca[1] + (cb[1] - ca[1]) * t,
291                ca[2] + (cb[2] - ca[2]) * t,
292            ]
293        };
294        let (n0, d0) = project(lerp3(t0));
295        let (n1, d1) = project(lerp3(t1));
296
297        // Expand in pixel space for a uniform screen-space thickness.
298        let to_px = |n: [f32; 2]| [(n[0] * 0.5 + 0.5) * wf, (0.5 - n[1] * 0.5) * hf];
299        let to_ndc = |p: [f32; 2]| [p[0] / wf * 2.0 - 1.0, 1.0 - p[1] / hf * 2.0];
300        let p0 = to_px(n0);
301        let p1 = to_px(n1);
302        let (dx, dy) = (p1[0] - p0[0], p1[1] - p0[1]);
303        let len = (dx * dx + dy * dy).sqrt().max(1e-6);
304        let half = line.width_px.max(1.0) * 0.5;
305        let (ex, ey) = (-dy / len * half, dx / len * half);
306
307        let c0a = to_ndc([p0[0] + ex, p0[1] + ey]);
308        let c0b = to_ndc([p0[0] - ex, p0[1] - ey]);
309        let c1a = to_ndc([p1[0] + ex, p1[1] + ey]);
310        let c1b = to_ndc([p1[0] - ex, p1[1] - ey]);
311        let dt = if line.depth_test { 1.0 } else { 0.0 };
312        // Mirror the overlay's NDC x to match the flipped scene blit.
313        let vert = |pos: [f32; 2], depth: f32| LineVertex {
314            pos: [if flip_x { -pos[0] } else { pos[0] }, pos[1]],
315            depth,
316            depth_test: dt,
317            color: line.color,
318        };
319        // Two triangles, cull disabled so winding is irrelevant.
320        out.push(vert(c0a, d0));
321        out.push(vert(c0b, d0));
322        out.push(vert(c1a, d1));
323        out.push(vert(c1a, d1));
324        out.push(vert(c0b, d0));
325        out.push(vert(c1b, d1));
326    }
327    out
328}
329
330/// A world-space 2D image-sprite quad for [`GpuRenderer::draw_images_deferred`].
331/// `corners` are the four world points `TL, TR, BL, BR` (UVs `(0,0) (1,0)
332/// (0,1) (1,1)`); `image` indexes a texture uploaded via
333/// [`GpuRenderer::upload_image`]; `tint` is straight RGBA in `0..=1`
334/// (multiplied into every texel); `depth_test` occludes the quad behind
335/// nearer marched geometry. The facade resolves orientation + back-face
336/// culling, so this is pure geometry.
337#[derive(Clone, Copy, Debug)]
338pub struct GpuImageQuad {
339    pub corners: [[f32; 3]; 4],
340    pub image: usize,
341    pub tint: [f32; 4],
342    pub depth_test: bool,
343    /// Texels with alpha below this (`0..=1`) are discarded in the FS.
344    /// `0.0` keeps the plain over-blend.
345    pub alpha_cutoff: f32,
346}
347
348/// One expanded textured-quad vertex (`build_image_vertices` output).
349/// `ndc` is the projected NDC xy; `w` is the source `forward` depth, fed
350/// back into a homogeneous clip position so the rasterizer interpolates
351/// `uv` perspective-correctly; `depth` is the euclidean world distance
352/// (the marcher's `best_t`) for the manual depth test.
353#[repr(C)]
354#[derive(Clone, Copy, Pod, Zeroable)]
355struct ImageVertex {
356    ndc: [f32; 2],
357    w: f32,
358    depth: f32,
359    depth_test: f32,
360    cutoff: f32,
361    uv: [f32; 2],
362    tint: [f32; 4],
363}
364
365/// Lazy-built image-sprite pipeline (mirrors [`LineResources`]). The
366/// per-draw bind group adds the quad's texture + a sampler to the line
367/// pass's uniform + scene-depth bindings.
368struct ImageResources {
369    pipeline: wgpu::RenderPipeline,
370    bgl: wgpu::BindGroupLayout,
371    uniform_buf: wgpu::Buffer,
372    dummy_depth: wgpu::Buffer,
373    sampler: wgpu::Sampler,
374}
375
376/// A retained image-sprite texture (uploaded via
377/// [`GpuRenderer::upload_image`], referenced by [`GpuImageQuad::image`]).
378struct ImageResident {
379    view: wgpu::TextureView,
380    // Held so the view stays valid + the texture shows in profiler dumps.
381    _texture: wgpu::Texture,
382}
383
384/// Camera-space textured-quad vertex (near-clip working set): the
385/// `(right, down, forward)` components + the texture `uv`.
386#[derive(Clone, Copy)]
387struct ImgClipV {
388    cam: [f32; 3],
389    uv: [f32; 2],
390}
391
392/// Clip a convex camera-space polygon against the near plane
393/// (`forward >= LINE_NEAR_Z`), interpolating UVs at each crossing.
394fn clip_near_image(poly: &[ImgClipV]) -> Vec<ImgClipV> {
395    let n = poly.len();
396    let mut out: Vec<ImgClipV> = Vec::with_capacity(n + 1);
397    for i in 0..n {
398        let cur = poly[i];
399        let prev = poly[(i + n - 1) % n];
400        let cur_in = cur.cam[2] >= LINE_NEAR_Z;
401        let prev_in = prev.cam[2] >= LINE_NEAR_Z;
402        if cur_in != prev_in {
403            let t = (LINE_NEAR_Z - prev.cam[2]) / (cur.cam[2] - prev.cam[2]);
404            out.push(ImgClipV {
405                cam: [
406                    prev.cam[0] + (cur.cam[0] - prev.cam[0]) * t,
407                    prev.cam[1] + (cur.cam[1] - prev.cam[1]) * t,
408                    LINE_NEAR_Z,
409                ],
410                uv: [
411                    prev.uv[0] + (cur.uv[0] - prev.uv[0]) * t,
412                    prev.uv[1] + (cur.uv[1] - prev.uv[1]) * t,
413                ],
414            });
415        }
416        if cur_in {
417            out.push(cur);
418        }
419    }
420    out
421}
422
423/// Project + near-clip a world-space [`GpuImageQuad`] into perspective-correct
424/// textured-quad vertices for `image.wgsl`. Mirrors the scene-DDA pinhole
425/// (the same one [`build_line_vertices`] uses), carrying each vertex's
426/// euclidean world distance as the depth-test key. Quads fully behind the
427/// near plane produce no vertices.
428fn build_image_vertices(
429    cam: &GpuLineCamera,
430    quad: &GpuImageQuad,
431    w: u32,
432    h: u32,
433    fov_y: f32,
434    flip_x: bool,
435) -> Vec<ImageVertex> {
436    let aspect = w as f32 / h as f32;
437    let half_h = (fov_y * 0.5).tan();
438    let half_w = half_h * aspect;
439    let dt = if quad.depth_test { 1.0 } else { 0.0 };
440
441    let cam_coords = |p: [f32; 3]| -> [f32; 3] {
442        let d = [p[0] - cam.pos[0], p[1] - cam.pos[1], p[2] - cam.pos[2]];
443        [
444            cam.right[0] * d[0] + cam.right[1] * d[1] + cam.right[2] * d[2],
445            cam.down[0] * d[0] + cam.down[1] * d[1] + cam.down[2] * d[2],
446            cam.forward[0] * d[0] + cam.forward[1] * d[1] + cam.forward[2] * d[2],
447        ]
448    };
449    let project = |v: ImgClipV| -> ImageVertex {
450        let (cx, cy, cz) = (v.cam[0], v.cam[1], v.cam[2]);
451        let nx = cx / (cz * half_w);
452        ImageVertex {
453            // Mirror NDC x to match the flipped scene blit.
454            ndc: [if flip_x { -nx } else { nx }, -cy / (cz * half_h)],
455            w: cz,
456            depth: (cx * cx + cy * cy + cz * cz).sqrt(),
457            depth_test: dt,
458            cutoff: quad.alpha_cutoff,
459            uv: v.uv,
460            tint: quad.tint,
461        }
462    };
463
464    // Per-corner UV: TL(0,0) TR(1,0) BL(0,1) BR(1,1).
465    let uvs = [[0.0, 0.0], [1.0, 0.0], [0.0, 1.0], [1.0, 1.0]];
466    let verts: Vec<ImgClipV> = quad
467        .corners
468        .iter()
469        .zip(uvs)
470        .map(|(c, uv)| ImgClipV {
471            cam: cam_coords(*c),
472            uv,
473        })
474        .collect();
475
476    let mut out = Vec::with_capacity(12);
477    for tri in [[0usize, 1, 2], [1, 3, 2]] {
478        let poly = [verts[tri[0]], verts[tri[1]], verts[tri[2]]];
479        let clipped = clip_near_image(&poly);
480        if clipped.len() < 3 {
481            continue;
482        }
483        for i in 1..clipped.len() - 1 {
484            out.push(project(clipped[0]));
485            out.push(project(clipped[i]));
486            out.push(project(clipped[i + 1]));
487        }
488    }
489    out
490}
491
492/// RP.2 — flat posterize config for the resolve pass uniform. `levels[c] <= 1`
493/// leaves that channel untouched; `dither` is `0`=none, `1`=Bayer4×4,
494/// `2`=blue-noise (IGN). Mirror of `roxlap_render::PosterizeConfig`.
495#[derive(Clone, Copy, Debug)]
496pub struct PosterizeGpu {
497    pub levels: [u32; 3],
498    pub dither: u32,
499}
500
501/// RP.0 — logical render resolution policy for the scene marcher, decoupled
502/// from the swapchain size. Mirror of `roxlap_render::RenderResolution` (kept
503/// here so `roxlap-gpu` has no upward dependency). See [`GpuRenderer::render_dims`].
504#[derive(Clone, Copy, Debug, PartialEq, Default)]
505pub enum RenderResolution {
506    /// Logical == swapchain. Default; byte-identical to pre-RP rendering.
507    #[default]
508    Native,
509    /// Fixed logical grid, nearest-upscaled to the swapchain.
510    Fixed { w: u32, h: u32 },
511    /// Logical = `round(swapchain * factor)`, clamped to `>= 1px`.
512    Scale(f32),
513}
514
515impl RenderResolution {
516    /// Resolve to concrete logical pixels given the swapchain (native) size.
517    #[must_use]
518    fn logical_for(self, native: (u32, u32)) -> (u32, u32) {
519        let (nw, nh) = (native.0.max(1), native.1.max(1));
520        match self {
521            Self::Native => (nw, nh),
522            Self::Fixed { w, h } => (w.max(1), h.max(1)),
523            Self::Scale(f) => {
524                let s = f.max(1e-3);
525                #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
526                let lw = ((nw as f32) * s).round() as u32;
527                #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
528                let lh = ((nh as f32) * s).round() as u32;
529                (lw.max(1), lh.max(1))
530            }
531        }
532    }
533}
534
535#[allow(clippy::struct_excessive_bools)] // independent per-frame flags, not a state enum
536pub struct GpuRenderer {
537    surface: wgpu::Surface<'static>,
538    surface_config: wgpu::SurfaceConfiguration,
539    device: wgpu::Device,
540    queue: wgpu::Queue,
541    adapter_info: String,
542    clear_colour: [f64; 3],
543    frame_count: u32,
544    /// Mirror the marched scene horizontally on present (the scene blit
545    /// samples `width-1-x`, and line/image overlays mirror their NDC x).
546    /// The egui pass is unaffected. See [`Self::set_flip_x`].
547    flip_x: bool,
548    /// RP.0 — logical render resolution. The scene/sprite passes march at
549    /// [`Self::render_dims`] (≤ the swapchain under a fixed value) into a
550    /// render-sized framebuffer + depth buffer; the blit nearest-upscales it
551    /// to the swapchain. `Native` keeps `render_dims == swapchain` ⇒ the
552    /// pre-RP straight blit, byte-identical.
553    render_res: RenderResolution,
554    /// RP.1 — supersampling factor. `1` = off (march at logical size). `>1`
555    /// marches at `logical × ssaa` into the framebuffer/depth and a resolve
556    /// compute pass box-downfilters back to logical before the blit.
557    ssaa: u32,
558    /// RP.2 — reduced-palette post applied in the resolve pass (at logical
559    /// resolution). `None` = off (`levels = [1,1,1]` ⇒ the RP.1 box-avg only).
560    posterize: Option<PosterizeGpu>,
561    /// Lazy-built on first [`Self::render_chunk`] call; rebuilt when
562    /// the swapchain resizes (storage texture must match).
563    chunk_dda: Option<ChunkDdaResources>,
564    /// Lazy-built on first [`Self::render_grid`] call; same resize
565    /// trigger as `chunk_dda`. The two paths share the same blit
566    /// pipeline structure but bind different storage layouts.
567    grid_dda: Option<GridDdaResources>,
568    /// Lazy-built on first [`Self::render_scene`] call. Holds the
569    /// multi-grid pipeline + per-grid camera uniforms.
570    scene_dda: Option<SceneDdaResources>,
571    /// TV.6 — global voxel-material palette mirrored to the scene pass (256
572    /// entries, default all-opaque), set via [`Self::set_scene_materials`].
573    scene_materials: Box<[MaterialGpu; 256]>,
574    /// TV.6 — terrain colour→material map (`[rgb, material_id]` rows) +
575    /// whether any mapped material is translucent (the shader gate).
576    scene_terrain_map: Vec<[u32; 2]>,
577    scene_terrain_translucent: bool,
578    /// Whether the *current* deferred frame ran a scene pass that wrote
579    /// `scene_dda.depth_buffer`. [`Self::render_scene`] sets it; the
580    /// color-only [`Self::render_clear_deferred`] clears it. Without this,
581    /// depth-tested overlays (`draw_lines_deferred` / `draw_image`) drawn
582    /// over an empty/cleared scene would test against the *previous*
583    /// scene's stale depth and clip incorrectly.
584    scene_depth_valid: bool,
585    /// GPU.8 — panoramic sky texture + sampler. Created at
586    /// `new` as a 1×1 mid-grey default; [`Self::set_sky_panorama`]
587    /// replaces it. The scene-DDA bind group references this each
588    /// frame.
589    sky_texture: wgpu::Texture,
590    sky_view: wgpu::TextureView,
591    sky_sampler: wgpu::Sampler,
592    /// GPU.8 fog state. `color` is BGRA-style premultiplied (each
593    /// channel in [0, 1]); `near` is the world-t distance at which
594    /// fog starts kicking in; `far` is the distance at which it's
595    /// fully opaque. The shader does
596    /// `mix(hit, fog, smoothstep(near, far, t))`.
597    fog_color: [f32; 3],
598    fog_near: f32,
599    fog_far: f32,
600    /// GPU.10 — sprites rendered as DDA-marched voxel models (the
601    /// precise path; the GPU.9 compute splatter it replaced was
602    /// retired in 10.5). Holds the concatenated model registry + the
603    /// per-frame instance array; set via [`Self::set_sprite_instances`].
604    sprite_registry: Option<sprite_model::SpriteRegistryResident>,
605    /// Lazy-built pipeline + uniform for the model-DDA pass.
606    sprite_model_dda: Option<SpriteModelDdaResources>,
607    /// TV — global voxel-material palette mirrored to the sprite pass (256
608    /// entries, default all-opaque), set via [`Self::set_sprite_materials`].
609    /// `sprite_has_translucent` gates the shader's accumulate path.
610    sprite_materials: Box<[MaterialGpu; 256]>,
611    sprite_has_translucent: bool,
612    /// XS.4 — whether this device grants enough storage buffers per shader
613    /// stage for GPU sprite shadows (the cross-pass occupancy bindings push a
614    /// pass past the baseline 16). `false` ⇒ GPU sprites render unshadowed (the
615    /// pre-XS.4 path); the CPU backend always has sprite shadows. Computed once
616    /// at init from the granted device limits (see
617    /// [`SPRITE_SHADOW_MIN_STORAGE_BUFFERS`]).
618    sprite_shadows_capable: bool,
619    /// GPU.10.4 — LOD aggressiveness: step a sprite to the next mip
620    /// once a mip-0 voxel projects below this many screen pixels.
621    /// Defaults to 4.0 (the empirical sweet spot); the host can tune
622    /// via [`Self::set_sprite_lod_px`].
623    sprite_lod_px: f32,
624    /// GPU.11.1 — scene-grid LOD scan distance (world units). A chunk
625    /// entered at world-t `t` is marched at the mip level
626    /// `floor(log2(max(t, msd) / msd))`, clamped to the grid's mip
627    /// ladder. `0` disables LOD (always mip-0). Tunable via
628    /// [`Self::set_scene_mip_scan_dist`] — the axis-aligned-mip-beams
629    /// mitigation (GPU.11.2) pushes it outward if banding appears.
630    scene_mip_scan_dist: f32,
631    /// Per-face grid side-shades (voxlap setsideshades), packed for the
632    /// scene-DDA uniform: `[0]=(top,bot,left,right)`, `[1]=(up,down,_,_)`.
633    /// Each is the u8 shade intensity. `[[0;4];2]` = no shading. Set via
634    /// [`Self::set_scene_side_shades`].
635    scene_side_shades: [[i32; 4]; 2],
636    /// DL — per-frame dynamic lights (sun + point lights), already
637    /// transformed into each grid's local frame by the facade. Set via
638    /// [`Self::set_scene_lights`]; [`SceneLights::default`] = no lights
639    /// (the pre-DL render). Consumed by `render_scene` each frame.
640    scene_lights: SceneLights,
641    /// Vertical FOV (radians) the last `render_scene` marched with —
642    /// cached so [`Self::pixel_ray`] reconstructs the matching view ray
643    /// for picking. `0` until the first scene render.
644    last_fov_y_rad: f32,
645    /// The acquired-but-not-yet-presented swapchain frame from the most
646    /// recent deferred render ([`Self::render_scene`] /
647    /// [`Self::render_clear_deferred`]). [`Self::present`] shows it as
648    /// is; [`Self::paint_egui`] overlays egui first. Lets a host slot a
649    /// UI pass between the marcher and present. `None` between present
650    /// and the next render.
651    pending_frame: Option<(wgpu::SurfaceTexture, wgpu::TextureView)>,
652    /// Lazy-built debug-line pipeline (L3.2) — built on the first
653    /// [`Self::draw_lines_deferred`] call.
654    line_resources: Option<LineResources>,
655    /// Persistent debug-line vertex buffer (L3.3) — grown on demand and
656    /// reused across frames so a per-frame overlay (hundreds of segments)
657    /// costs one `write_buffer`, not a fresh allocation. `line_vbuf_cap`
658    /// is its capacity in bytes.
659    line_vbuf: Option<wgpu::Buffer>,
660    line_vbuf_cap: u64,
661    /// Lazy-built image-sprite pipeline — built on the first
662    /// [`Self::draw_images_deferred`] call.
663    image_resources: Option<ImageResources>,
664    /// Persistent image-sprite vertex buffer, grown on demand and reused
665    /// across frames (like [`Self::line_vbuf`]).
666    image_vbuf: Option<wgpu::Buffer>,
667    image_vbuf_cap: u64,
668    /// Retained image-sprite textures, indexed by the id
669    /// [`Self::upload_image`] returns. A dropped slot is `None` and is
670    /// re-used by a later upload.
671    images: Vec<Option<ImageResident>>,
672    /// Lazy-built `egui-wgpu` paint pipeline; created on the first
673    /// [`Self::paint_egui`] call (`hud` feature).
674    #[cfg(feature = "hud")]
675    egui_renderer: Option<egui_wgpu::Renderer>,
676}
677
678/// Per-renderer chunk-DDA pipeline state. The compute shader writes
679/// into the storage texture; a fullscreen-triangle render pass
680/// nearest-neighbour blits it to the swapchain.
681struct ChunkDdaResources {
682    storage_size: (u32, u32),
683    storage_view: wgpu::TextureView,
684    uniform_buf: wgpu::Buffer,
685    bgl_dda: wgpu::BindGroupLayout,
686    pipeline_dda: wgpu::ComputePipeline,
687    blit_bg: wgpu::BindGroup,
688    pipeline_blit: wgpu::RenderPipeline,
689    // wgpu BindGroups internally Arc their resources, but we keep
690    // the handle so the sampler shows up in profiler dumps.
691    _sampler: wgpu::Sampler,
692}
693
694struct GridDdaResources {
695    storage_size: (u32, u32),
696    storage_view: wgpu::TextureView,
697    uniform_buf: wgpu::Buffer,
698    bgl_dda: wgpu::BindGroupLayout,
699    pipeline_dda: wgpu::ComputePipeline,
700    blit_bg: wgpu::BindGroup,
701    pipeline_blit: wgpu::RenderPipeline,
702    _sampler: wgpu::Sampler,
703}
704
705struct SceneDdaResources {
706    /// RP.1 — the **march** framebuffer size (`logical × ssaa`); the scene +
707    /// sprite + depth passes run at this. Used for the rebuild check.
708    storage_size: (u32, u32),
709    /// RP.1 — the **logical** (resolved) size: `resolve_buf` + the blit src.
710    logical_size: (u32, u32),
711    /// Framebuffer as a packed-`rgba8unorm` storage **buffer** (row
712    /// stride = march width), written by the scene + sprite compute passes
713    /// and read by the resolve pass. A buffer (not a storage texture) dodges
714    /// Chrome-Dawn's tiled write-texture layout (which produced a
715    /// 128×256-tiled image); linear + explicit stride is portable.
716    framebuffer: wgpu::Buffer,
717    uniform_buf: wgpu::Buffer,
718    bgl_dda: wgpu::BindGroupLayout,
719    pipeline_dda: wgpu::ComputePipeline,
720    /// RP.1/RP.2 — box-downfilter + posterize compute pass
721    /// (`scene_resolve.wgsl`): framebuffer(march) → resolve_buf(logical). The
722    /// bind group retains the resolve buffer (not stored separately).
723    pipeline_resolve: wgpu::ComputePipeline,
724    resolve_bg: wgpu::BindGroup,
725    /// Resolve uniform `[src w,h, dst w,h, ssaa, levels r,g,b, dither, pad×3]`.
726    /// Retained so the posterize fields are re-written per frame (RP.2).
727    resolve_dims: wgpu::Buffer,
728    /// Blit bind group — binds `resolve_buf` (logical) + `blit_dims`.
729    blit_bg: wgpu::BindGroup,
730    pipeline_blit: wgpu::RenderPipeline,
731    /// Blit uniform `Dims`: `[src(logical) w,h, dst(swapchain) w,h, flip_x,
732    /// pad×3]`. Retained so the flip flag (offset 16) is re-written per frame.
733    blit_dims: wgpu::Buffer,
734    /// GPU.9 — per-pixel world-t depth (f32 bits as u32), sized
735    /// `width * height * 4`. The scene pass writes it when sprites
736    /// are present; the sprite model-DDA pass reads + composites
737    /// against it.
738    depth_buffer: wgpu::Buffer,
739    /// Picking — a `COPY_DST | MAP_READ` staging copy of `depth_buffer`
740    /// so the host can read back the per-pixel world-t after a frame
741    /// (e.g. click → which voxel). Same size as `depth_buffer`.
742    depth_readback: wgpu::Buffer,
743    /// TV.6 — global voxel-material palette (256 `MaterialGpu`, binding 16),
744    /// seeded from `scene_materials`, rewritten by [`GpuRenderer::set_scene_materials`].
745    materials_pal_buf: wgpu::Buffer,
746    /// TV.6 — terrain colour→material map (`[rgb, material_id]` rows, binding
747    /// 17); ≥1 element (wgpu rejects a zero-sized storage binding).
748    terrain_map_buf: wgpu::Buffer,
749    /// XS.4.3 — placeholder bound at the sprite-cast bindings (19..21) on a
750    /// capable device when no sprite registry exists (or this frame has no
751    /// sprites). `sprite_cast_count == 0` keeps the shader from indexing it.
752    /// `None` on non-capable devices (those bindings aren't in the BGL).
753    sprite_cast_dummy: Option<wgpu::Buffer>,
754}
755
756/// GPU.10.0 — single-sprite model-DDA pipeline: one thread per pixel
757/// marches the model voxel volume and composites against the scene
758/// depth buffer.
759struct SpriteModelDdaResources {
760    bgl: wgpu::BindGroupLayout,
761    pipeline: wgpu::ComputePipeline,
762    uniform_buf: wgpu::Buffer,
763    /// TV — global voxel-material palette (256 `MaterialGpu`, binding 12),
764    /// seeded from the renderer's `sprite_materials` and rewritten by
765    /// [`GpuRenderer::set_sprite_materials`].
766    materials_buf: wgpu::Buffer,
767}
768
769/// Per-frame uniform for the model-DDA pass. Mirrors `Uniform` in
770/// `sprite_model_dda.wgsl` (std140). Per-model + per-instance data
771/// now live in storage buffers; this holds only the camera, fog, and
772/// instance count.
773#[repr(C)]
774#[derive(Clone, Copy, Pod, Zeroable)]
775struct SpriteModelUniform {
776    cam_pos: [f32; 3],
777    _p0: f32,
778    cam_right: [f32; 3],
779    _p1: f32,
780    cam_down: [f32; 3],
781    _p2: f32,
782    cam_forward: [f32; 3],
783    _p3: f32,
784    fog_color: [f32; 4],
785    screen_size: [u32; 2],
786    instance_count: u32,
787    fog_far: f32,
788    fov_y_rad: f32,
789    tiles_x: u32,
790    tile_size: u32,
791    /// TV — 1 if any palette material is translucent: gates the shader's
792    /// accumulate path. 0 ⇒ the unchanged nearest-hit opaque path.
793    has_translucent: u32,
794    // ── DL.4 — dynamic lighting for sprites (world space; all-zero ⇒
795    // unchanged flat-lit sprites). No sprite shadows (deferred). ──
796    /// World-space unit direction TO the sun (xyz; w unused).
797    sun_dir: [f32; 4],
798    /// `rgb` = sun colour, `w` = sun intensity.
799    sun_color: [f32; 4],
800    /// `rgb` = ambient multiplier on the sprite's albedo, `w` unused.
801    ambient_color: [f32; 4],
802    /// bit0 = sun enabled, bit2 = dynamic lighting active (use the lit path).
803    sun_flags: u32,
804    point_light_count: u32,
805    _pad_dl: [u32; 2],
806    // ── DL.6 — stylized sprite lighting (cel + ramp + flat per voxel) ──
807    /// `rgb` = cool unlit end of the sun ramp; `w` unused.
808    shadow_tint: [f32; 4],
809    /// Cel band count; 0 = smooth.
810    style_bands: u32,
811    // ── XS.4.2 — GPU sprite-shadow (receive) params. Mirror the scene pass's
812    // paging + shadow uniform fields so the sprite pass's duplicated terrain
813    // occupancy march reads the exact same ABI. All zero ⇒ no sprite shadows
814    // (the capability fallback / pre-XS.4 path). ──
815    occ_num_pages: u32,
816    occ_page_words: u32,
817    grid_count: u32,
818    max_outer_steps: u32,
819    shadow_max_steps: u32,
820    shadow_bias: f32,
821    shadow_max_dist: f32,
822    /// Fraction of a caster's light removed in shadow (`in_shadow = 1 - this`).
823    shadow_strength: f32,
824    _pad_xs: [u32; 3],
825}
826
827/// GPU.10.3 — sprite screen-tile edge in pixels for instance binning.
828const SPRITE_TILE_SIZE: u32 = 16;
829
830/// One material in the GPU sprite material palette (binding 12). Mirrors
831/// `Mat` in `sprite_model_dda.wgsl` (std430, 8 bytes). TV stage.
832#[repr(C)]
833#[derive(Clone, Copy, Pod, Zeroable)]
834struct MaterialGpu {
835    /// Opacity / additive intensity, normalised to `0..=1`.
836    alpha: f32,
837    /// [`roxlap_formats::material::BlendMode`] discriminant.
838    mode: u32,
839}
840
841/// Convert the global [`MaterialTable`](roxlap_formats::material::MaterialTable)
842/// into the GPU palette + a flag of whether any material is non-opaque (the
843/// shader gate — an all-opaque palette runs the unchanged first-hit path).
844fn material_palette(
845    table: &roxlap_formats::material::MaterialTable,
846) -> (Box<[MaterialGpu; 256]>, bool) {
847    let mut out = Box::new(
848        [MaterialGpu {
849            alpha: 1.0,
850            mode: 0,
851        }; 256],
852    );
853    let mut any_translucent = false;
854    for (id, slot) in out.iter_mut().enumerate() {
855        let m = table.get(id as u8);
856        slot.alpha = f32::from(m.alpha) / 255.0;
857        slot.mode = u32::from(m.mode.as_u8());
858        if !m.is_opaque() {
859            any_translucent = true;
860        }
861    }
862    (out, any_translucent)
863}
864
865// ───────────────────────── DL — dynamic lighting ─────────────────────────
866// Stage DL (GPU-only). The scene-DDA pass gains a runtime sun + point
867// lights + stylized hard shadows. The host passes lights already
868// transformed into each grid's local frame (mirroring the per-grid
869// cameras); the shader works entirely in grid-local space. DL.0 wires the
870// buffers + uniform fields + bindings; the shader receives them but does
871// not yet read them (the hit-site shading lands in DL.1+).
872
873/// Max point lights honoured per frame. Excess are dropped with a warning
874/// (never silently truncated). The per-grid buffer is sized
875/// `grid_count * point_count`.
876pub const MAX_POINT_LIGHTS: usize = 32;
877/// Max simultaneous shadow casters (the sun counts as one). Lights flagged
878/// to cast beyond this are demoted to shadowless with a warning. Enforced
879/// in DL.3 (shadow stage); declared here so the budget is one constant.
880pub const MAX_SHADOW_CASTERS: usize = 4;
881
882/// A point light in a grid's **local** space, as handed to
883/// [`GpuRenderer::set_scene_lights`]. The facade transforms world-space
884/// [`roxlap_render::PointLight`]s into each grid's frame.
885#[derive(Clone, Copy, Debug)]
886pub struct GpuLight {
887    /// Grid-local position (voxel units).
888    pub position: [f32; 3],
889    /// Hard cutoff distance, world/voxel units.
890    pub radius: f32,
891    /// Linear RGB, `0..1`.
892    pub color: [f32; 3],
893    pub intensity: f32,
894    pub casts_shadow: bool,
895}
896
897/// The whole per-frame light environment, already transformed per grid.
898/// `grid_sun_dirs` and `grid_point_lights` are indexed by grid (outer
899/// length == `grid_count`); empty ⇒ that light type is off. Set each frame
900/// via [`GpuRenderer::set_scene_lights`]; [`Default`] = no lights (the
901/// pre-DL render).
902#[derive(Clone, Default)]
903pub struct SceneLights {
904    /// Whether a dynamic-lighting rig is active this frame. `false` (the
905    /// default) ⇒ the shader takes the unchanged baked-only path
906    /// (byte-identical to pre-DL). `true` ⇒ the lit path runs (ambient
907    /// term + sun + point lights), even with no sun/points set, so the
908    /// `ambient` multiplier still applies.
909    pub enabled: bool,
910    /// Per-grid unit direction **to** the sun (grid-local). Empty ⇒ no sun.
911    pub grid_sun_dirs: Vec<[f32; 3]>,
912    pub sun_color: [f32; 3],
913    pub sun_intensity: f32,
914    pub sun_casts_shadow: bool,
915    /// Per-grid point lights (grid-local). Outer len == `grid_count`; the
916    /// inner len (the point count) is the same for every grid.
917    pub grid_point_lights: Vec<Vec<GpuLight>>,
918    /// Multiplier on the baked ambient byte.
919    pub ambient: [f32; 3],
920    pub shadow_strength: f32,
921    pub shadow_bias: f32,
922    pub shadow_max_dist: f32,
923    pub shadow_max_steps: u32,
924    /// DL.4 — **world-space** unit direction to the sun, for the sprite
925    /// pass (sprites render in world space, not grid-local). `[0;3]` ⇒ no
926    /// sun. Empty `grid_sun_dirs` and a zero `world_sun_dir` both mean
927    /// "no sun" for their respective passes.
928    pub world_sun_dir: [f32; 3],
929    /// DL.4 — world-space point lights for the sprite pass (positions in
930    /// world coords; same colour/intensity/radius as the per-grid copies).
931    pub world_points: Vec<GpuLight>,
932    /// DL.6 — stylized cel banding: `0` = smooth, `≥1` = quantize the
933    /// diffuse to `bands + 1` levels + gradient-map the sun key.
934    pub style_bands: u32,
935    /// DL.6 — cool shadow/ambient tint (the stylized ramp's unlit end).
936    pub shadow_tint: [f32; 3],
937}
938
939/// One point light packed for the GPU (binding 18, std430, 48 bytes).
940/// Mirrors `PointLight` in `scene_dda.wgsl`.
941#[repr(C)]
942#[derive(Clone, Copy, Pod, Zeroable)]
943struct GpuPointLight {
944    pos: [f32; 3],
945    radius: f32,
946    color: [f32; 3],
947    intensity: f32,
948    casts_shadow: u32,
949    _pad: [u32; 3],
950}
951
952/// Build the per-grid point-light storage buffer (binding 18), grid-major:
953/// grid `g`'s lights occupy `[g*count .. (g+1)*count]`. Pads to one zeroed
954/// element when empty (wgpu rejects a zero-sized storage binding).
955fn upload_grid_point_lights(device: &wgpu::Device, lights: &[GpuPointLight]) -> wgpu::Buffer {
956    use wgpu::util::DeviceExt;
957    let one = [GpuPointLight::zeroed()];
958    let src: &[GpuPointLight] = if lights.is_empty() { &one } else { lights };
959    device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
960        label: Some("roxlap-gpu scene_dda.grid_point_lights"),
961        contents: bytemuck::cast_slice(src),
962        usage: wgpu::BufferUsages::STORAGE,
963    })
964}
965
966/// DL — pack `lights` for the scene-DDA pass, shared by the surface and
967/// headless paths. Injects each grid's sun direction into
968/// `cam_vec[g].sun_dir` (binding 15), builds the grid-major point-light
969/// buffer (binding 18), and returns `(point_light_buffer, sun_flags,
970/// point_count)`. `sun_flags`: bit0 = sun enabled, bit1 = sun casts shadow,
971/// bit2 = dynamic lighting active. Over-cap point lights are dropped with a
972/// warning (never silently truncated).
973fn pack_scene_lights(
974    device: &wgpu::Device,
975    lights: &SceneLights,
976    grid_count: usize,
977    cam_vec: &mut [SceneDdaPerGridCamera],
978) -> (wgpu::Buffer, u32, u32) {
979    let sun_enabled = !lights.grid_sun_dirs.is_empty();
980    if sun_enabled {
981        for (g, cam) in cam_vec.iter_mut().enumerate() {
982            let d = lights.grid_sun_dirs.get(g).copied().unwrap_or([0.0; 3]);
983            cam.sun_dir = [d[0], d[1], d[2], 0.0];
984        }
985    }
986    // Point-light count per grid (same across grids); capped + warned.
987    let mut point_count = lights
988        .grid_point_lights
989        .first()
990        .map_or(0, std::vec::Vec::len);
991    if point_count > MAX_POINT_LIGHTS {
992        eprintln!(
993            "roxlap-gpu: {point_count} point lights > MAX_POINT_LIGHTS ({MAX_POINT_LIGHTS}); dropping the excess"
994        );
995        point_count = MAX_POINT_LIGHTS;
996    }
997    // MAX_SHADOW_CASTERS cap (locked decision #5): the sun (if it casts) is
998    // the first caster; keep at most MAX_SHADOW_CASTERS shadow casters total
999    // and demote the rest to shadowless — never silently. The point list is
1000    // identical across grids (only positions differ), so decide per index
1001    // once from the representative (grid-0) row.
1002    let mut budget = MAX_SHADOW_CASTERS;
1003    if sun_enabled && lights.sun_casts_shadow {
1004        budget = budget.saturating_sub(1);
1005    }
1006    let mut allow_shadow = vec![false; point_count];
1007    let mut demoted = 0usize;
1008    if let Some(rep) = lights.grid_point_lights.first() {
1009        for (i, slot) in allow_shadow.iter_mut().enumerate() {
1010            if rep.get(i).is_some_and(|l| l.casts_shadow) {
1011                if budget > 0 {
1012                    *slot = true;
1013                    budget -= 1;
1014                } else {
1015                    demoted += 1;
1016                }
1017            }
1018        }
1019    }
1020    if demoted > 0 {
1021        eprintln!(
1022            "roxlap-gpu: {demoted} shadow-casting point lights > MAX_SHADOW_CASTERS ({MAX_SHADOW_CASTERS}); demoting the excess to shadowless"
1023        );
1024    }
1025    // Grid-major point-light buffer: grid g at [g*count .. (g+1)*count].
1026    let mut packed: Vec<GpuPointLight> = Vec::with_capacity(grid_count * point_count);
1027    for g in 0..grid_count {
1028        let row = lights.grid_point_lights.get(g);
1029        for (i, &allow) in allow_shadow.iter().enumerate() {
1030            let p = row.and_then(|r| r.get(i));
1031            packed.push(p.map_or(GpuPointLight::zeroed(), |l| GpuPointLight {
1032                pos: l.position,
1033                radius: l.radius,
1034                color: l.color,
1035                intensity: l.intensity,
1036                casts_shadow: u32::from(l.casts_shadow && allow),
1037                _pad: [0; 3],
1038            }));
1039        }
1040    }
1041    let buf = upload_grid_point_lights(device, &packed);
1042    let sun_flags = u32::from(sun_enabled)
1043        | (u32::from(sun_enabled && lights.sun_casts_shadow) << 1)
1044        | (u32::from(lights.enabled) << 2);
1045    (buf, sun_flags, point_count as u32)
1046}
1047
1048/// Build the per-grid camera storage buffer bound at `scene_dda.wgsl`
1049/// binding 15 (read-only). One [`SceneDdaPerGridCamera`] per grid; the
1050/// shader only indexes `0..grid_count`. An empty scene pads to one
1051/// zeroed element (wgpu rejects a zero-sized storage binding). This
1052/// replaces the old fixed `[…; 16]` uniform array, so a scene can hold
1053/// any number of grids — the only ceiling is the device's storage size.
1054fn upload_grid_cameras(device: &wgpu::Device, cams: &[SceneDdaPerGridCamera]) -> wgpu::Buffer {
1055    use wgpu::util::DeviceExt;
1056    let one = [SceneDdaPerGridCamera::zeroed()];
1057    let src: &[SceneDdaPerGridCamera] = if cams.is_empty() { &one } else { cams };
1058    device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
1059        label: Some("roxlap-gpu scene_dda.grid_cameras"),
1060        contents: bytemuck::cast_slice(src),
1061        usage: wgpu::BufferUsages::STORAGE,
1062    })
1063}
1064
1065// The scene_dda bind group + layout wire occupancy pages 1..=3 at
1066// bindings 12..=14 explicitly; keep that in lockstep with the page
1067// count. Bump the bindings (here, in the WGSL, and in the bind
1068// group) if MAX_OCC_PAGES changes.
1069const _: () = assert!(scene::MAX_OCC_PAGES == 4);
1070
1071#[repr(C)]
1072#[derive(Clone, Copy, Pod, Zeroable)]
1073struct SceneDdaPerGridCamera {
1074    pos: [f32; 3],
1075    _pad0: f32,
1076    right: [f32; 3],
1077    _pad1: f32,
1078    down: [f32; 3],
1079    _pad2: f32,
1080    forward: [f32; 3],
1081    _pad3: f32,
1082    /// DL — unit direction TO the sun in this grid's local frame (xyz; w
1083    /// unused). Packed here rather than a separate per-grid storage buffer
1084    /// because the device's `max_storage_buffers_per_shader_stage` (16) is
1085    /// already saturated. Zero ⇒ no sun (the uniform's `sun_flags` gates).
1086    sun_dir: [f32; 4],
1087    /// XS.3 — this grid's world transform, for cross-grid shadows: a shadow
1088    /// ray (grid-local in the grid being shaded) is lifted to world space and
1089    /// tested against every grid. `world_origin` (xyz) is the grid origin;
1090    /// `rot0/1/2` (xyz) are the local→world rotation columns (world images of
1091    /// grid-local axes x/y/z). Packed here for the same buffer-limit reason.
1092    world_origin: [f32; 4],
1093    rot0: [f32; 4],
1094    rot1: [f32; 4],
1095    rot2: [f32; 4],
1096}
1097
1098impl SceneDdaPerGridCamera {
1099    fn from_camera(c: &Camera) -> Self {
1100        Self {
1101            pos: c.position,
1102            _pad0: 0.0,
1103            right: c.right,
1104            _pad1: 0.0,
1105            down: c.down,
1106            _pad2: 0.0,
1107            forward: c.forward,
1108            _pad3: 0.0,
1109            sun_dir: [0.0; 4],
1110            // Identity world transform by default; the per-grid build
1111            // (`grid_cameras`) overwrites it with the grid's real transform.
1112            world_origin: [0.0; 4],
1113            rot0: [1.0, 0.0, 0.0, 0.0],
1114            rot1: [0.0, 1.0, 0.0, 0.0],
1115            rot2: [0.0, 0.0, 1.0, 0.0],
1116        }
1117    }
1118
1119    /// XS.3 — stamp this grid's world transform (for cross-grid shadows).
1120    /// `rot_cols[i]` is the world image of grid-local axis `i` (the
1121    /// local→world rotation's columns).
1122    fn set_world_transform(&mut self, t: &GridWorldTransform) {
1123        self.world_origin = [t.origin[0], t.origin[1], t.origin[2], 0.0];
1124        self.rot0 = [t.rot_cols[0][0], t.rot_cols[0][1], t.rot_cols[0][2], 0.0];
1125        self.rot1 = [t.rot_cols[1][0], t.rot_cols[1][1], t.rot_cols[1][2], 0.0];
1126        self.rot2 = [t.rot_cols[2][0], t.rot_cols[2][1], t.rot_cols[2][2], 0.0];
1127    }
1128}
1129
1130/// XS.3 — a grid's world transform for cross-grid shadows: world origin +
1131/// the local→world rotation columns (`rot_cols[i]` = world image of grid-local
1132/// axis `i`). Built host-side per frame from the grid's `GridTransform` and
1133/// handed to [`SceneRenderer::render_scene`] alongside the per-grid cameras.
1134#[derive(Clone, Copy)]
1135pub struct GridWorldTransform {
1136    pub origin: [f32; 3],
1137    pub rot_cols: [[f32; 3]; 3],
1138}
1139
1140impl Default for GridWorldTransform {
1141    fn default() -> Self {
1142        Self {
1143            origin: [0.0; 3],
1144            rot_cols: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
1145        }
1146    }
1147}
1148
1149#[repr(C)]
1150#[derive(Clone, Copy, Pod, Zeroable)]
1151struct SceneDdaUniform {
1152    fov_y_rad: f32,
1153    grid_count: u32,
1154    max_outer_steps: u32,
1155    _pad0: u32,
1156    screen_size: [u32; 2],
1157    _pad1: [u32; 2],
1158    /// GPU.8 — `[r, g, b, fog_near]`. The `near` distance is packed
1159    /// into the colour's alpha channel to keep std140 alignment
1160    /// tidy (a bare `f32` after the `vec4` would force extra pads).
1161    fog_color: [f32; 4],
1162    fog_far: f32,
1163    /// GPU.9 — `1` when the sprite pass is active (scene pass then
1164    /// records `best_t` into the depth buffer), `0` otherwise.
1165    write_depth: u32,
1166    /// Occupancy paging: words per storage page (see
1167    /// `scene::split_occupancy_pages`). Only consulted by the shader
1168    /// when `occ_num_pages > 1`.
1169    occ_page_words: u32,
1170    /// Number of real occupancy pages (1 on multi-GiB GPUs → the
1171    /// shader takes a branch-free single-page read).
1172    occ_num_pages: u32,
1173    /// GPU.11.1 — scene-grid LOD scan distance (world units). A chunk
1174    /// entered at world-t `t` marches at mip
1175    /// `floor(log2(max(t, msd) / msd))`, clamped to the grid's mip
1176    /// count. `0` disables LOD (always mip-0).
1177    mip_scan_dist: f32,
1178    /// TV.6 — `1` if any terrain material is translucent (gates the
1179    /// accumulate path; `0` ⇒ unchanged opaque first-hit march).
1180    terrain_has_translucent: u32,
1181    /// TV.6 — number of `(rgb, material_id)` entries in the terrain map.
1182    terrain_map_count: u32,
1183    _pad4: u32,
1184    /// World camera used only to derive the per-pixel sky direction —
1185    /// always valid, so a `grid_count == 0` (sprite-only / empty) scene
1186    /// still paints a proper sky instead of a degenerate `(0,0,1)`
1187    /// (whose `atan2(0,0)` sky lookup samples black).
1188    sky_cam: SceneDdaPerGridCamera,
1189    /// Per-face side-shade intensities (voxlap setsideshades), each the
1190    /// u8 shade subtracted from a voxel's brightness byte at a hit.
1191    /// `side_shades0 = (top, bot, left, right)`,
1192    /// `side_shades1 = (up, down, _, _)`. All-zero = no shading.
1193    side_shades0: [i32; 4],
1194    side_shades1: [i32; 4],
1195    // ── DL — dynamic lighting (appended; all-zero ⇒ pre-DL render) ──
1196    /// `rgb` = sun colour, `w` = sun intensity.
1197    sun_color: [f32; 4],
1198    /// `rgb` = ambient multiplier on the baked byte, `w` = shadow strength.
1199    ambient_color: [f32; 4],
1200    /// Bit 0 = sun enabled, bit 1 = sun casts shadow.
1201    sun_flags: u32,
1202    /// Number of point lights per grid (rows in the binding-18 buffer).
1203    point_light_count: u32,
1204    /// Shadow-ray step budget (DL.3).
1205    shadow_max_steps: u32,
1206    _pad5: u32,
1207    /// Shadow-ray origin bias along the surface normal (voxel units).
1208    shadow_bias: f32,
1209    /// Sun shadow-ray length cap (world units).
1210    shadow_max_dist: f32,
1211    _pad6: [f32; 2],
1212    /// DL.6 — stylized ramp's cool shadow tint (rgb; w unused).
1213    shadow_tint: [f32; 4],
1214    /// DL.6 — cel band count; 0 = smooth (no banding / gradient map).
1215    style_bands: u32,
1216    /// XS.4.3 — visible sprite-instance count for the scene pass's
1217    /// sprite-cast shadow march (sprites cast onto terrain). `0` ⇒ no sprite
1218    /// casters (the loop is skipped); only consulted by the capable variant.
1219    sprite_cast_count: u32,
1220    _pad7: [u32; 2],
1221}
1222
1223#[repr(C)]
1224#[derive(Clone, Copy, Pod, Zeroable)]
1225struct GridDdaUniform {
1226    camera_pos: [f32; 3],
1227    _pad0: f32,
1228    camera_right: [f32; 3],
1229    _pad1: f32,
1230    camera_down: [f32; 3],
1231    _pad2: f32,
1232    camera_forward: [f32; 3],
1233    fov_y_rad: f32,
1234    screen_size: [u32; 2],
1235    vsid: u32,
1236    max_outer_steps: u32,
1237    chunks_dims: [u32; 3],
1238    _pad3: u32,
1239    origin_chunk: [i32; 3],
1240    _pad4: u32,
1241}
1242
1243#[repr(C)]
1244#[derive(Clone, Copy, Pod, Zeroable)]
1245struct ChunkDdaUniform {
1246    camera_pos: [f32; 3],
1247    _pad0: f32,
1248    camera_right: [f32; 3],
1249    _pad1: f32,
1250    camera_down: [f32; 3],
1251    _pad2: f32,
1252    camera_forward: [f32; 3],
1253    fov_y_rad: f32,
1254    screen_size: [u32; 2],
1255    vsid: u32,
1256    max_scan_dist: u32,
1257}
1258
1259impl GpuRenderer {
1260    /// Stand up the device + surface + swapchain on `window`. Async
1261    /// because `wgpu::Adapter`/`Device` requests are.
1262    ///
1263    /// `window` is any [`raw-window-handle`] provider (winit, SDL,
1264    /// GLFW, …) wrapped in an `Arc`; `size` is its initial physical
1265    /// framebuffer size in pixels — passed explicitly so the renderer
1266    /// stays decoupled from any one windowing library's size API.
1267    ///
1268    /// [`raw-window-handle`]: raw_window_handle
1269    ///
1270    /// # Errors
1271    /// Returns [`GpuInitError`] if surface creation, adapter
1272    /// selection, or device request fails. Hosts treat any error as
1273    /// "fall back to the CPU path".
1274    pub async fn new<W>(
1275        window: Arc<W>,
1276        size: (u32, u32),
1277        settings: GpuRendererSettings,
1278    ) -> Result<Self, GpuInitError>
1279    where
1280        W: HasWindowHandle + HasDisplayHandle + Send + Sync + 'static,
1281    {
1282        let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
1283        let surface = instance.create_surface(window.clone())?;
1284        let adapter = Self::request_adapter(&instance, Some(&surface), settings).await?;
1285        let (device, queue) = Self::request_device(&adapter).await?;
1286        Ok(Self::finish_init(
1287            &adapter, device, queue, surface, size, settings,
1288        ))
1289    }
1290
1291    /// wasm/WebGPU: build the renderer against an HTML `canvas`. No
1292    /// `Send + Sync` bound — wgpu's surface/device/queue are `!Send` on
1293    /// the `+atomics` shared-memory wasm build, and the browser host is
1294    /// single-threaded (`Rc<RefCell<…>>`). The native generic-`W` entry
1295    /// (which carries the bound) isn't reachable on wasm.
1296    ///
1297    /// Probes for an adapter **before** `create_surface`: on wasm,
1298    /// creating the surface calls `canvas.getContext("webgpu")`, which
1299    /// permanently locks the canvas's context type. If we bound it and
1300    /// then found no adapter, a CPU/WebGL2 fallback on the *same* canvas
1301    /// (the facade clones the handle, but it's the same DOM element)
1302    /// would fail with "no webgl2 context". Probing first leaves the
1303    /// canvas pristine when WebGPU is unavailable.
1304    ///
1305    /// # Errors
1306    /// See [`Self::new`].
1307    #[cfg(target_arch = "wasm32")]
1308    pub async fn new_from_canvas(
1309        canvas: web_sys::HtmlCanvasElement,
1310        size: (u32, u32),
1311        settings: GpuRendererSettings,
1312    ) -> Result<Self, GpuInitError> {
1313        let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
1314        // Probe adapter AND device before binding the canvas — both
1315        // `requestAdapter` and `requestDevice` can fail on wasm, and
1316        // `create_surface` permanently locks the canvas to a WebGPU
1317        // context. Creating the surface last keeps the canvas pristine
1318        // for the CPU/WebGL2 fallback on any GPU-init failure.
1319        let adapter = Self::request_adapter(&instance, None, settings).await?;
1320        let (device, queue) = Self::request_device(&adapter).await?;
1321        let surface = instance.create_surface(wgpu::SurfaceTarget::Canvas(canvas))?;
1322        Ok(Self::finish_init(
1323            &adapter, device, queue, surface, size, settings,
1324        ))
1325    }
1326
1327    /// Pick a GPU adapter at the settings' power preference. `None`
1328    /// `compatible_surface` is used on the wasm canvas path so the probe
1329    /// doesn't bind the canvas's context (see [`Self::new_from_canvas`]);
1330    /// WebGPU exposes a single surface-independent adapter, so this is
1331    /// safe there.
1332    async fn request_adapter(
1333        instance: &wgpu::Instance,
1334        compatible_surface: Option<&wgpu::Surface<'static>>,
1335        settings: GpuRendererSettings,
1336    ) -> Result<wgpu::Adapter, GpuInitError> {
1337        let power_preference = match settings.power_preference {
1338            PowerPreference::Low => wgpu::PowerPreference::LowPower,
1339            PowerPreference::High => wgpu::PowerPreference::HighPerformance,
1340        };
1341        instance
1342            .request_adapter(&wgpu::RequestAdapterOptions {
1343                power_preference,
1344                compatible_surface,
1345                force_fallback_adapter: false,
1346            })
1347            .await
1348            .map_err(|_| GpuInitError::NoAdapter)
1349    }
1350
1351    /// Request the device + queue from `adapter`. Pulled out of
1352    /// [`Self::finish_init`] so the wasm canvas path can validate the
1353    /// device **before** `create_surface` binds the canvas's WebGPU
1354    /// context — if the device request fails (e.g. a browser that
1355    /// rejects a wgpu-sent limit), the canvas stays pristine for the
1356    /// CPU/WebGL2 fallback instead of being poisoned.
1357    async fn request_device(
1358        adapter: &wgpu::Adapter,
1359    ) -> Result<(wgpu::Device, wgpu::Queue), GpuInitError> {
1360        Ok(adapter
1361            .request_device(&wgpu::DeviceDescriptor {
1362                label: Some("roxlap-gpu device"),
1363                required_features: wgpu::Features::empty(),
1364                required_limits: pick_required_limits(&adapter.limits()),
1365                experimental_features: wgpu::ExperimentalFeatures::disabled(),
1366                memory_hints: wgpu::MemoryHints::default(),
1367                trace: wgpu::Trace::Off,
1368            })
1369            .await?)
1370    }
1371
1372    /// Shared swapchain → sky/sampler setup, run after the adapter +
1373    /// device + surface exist (the surface comes from a window handle on
1374    /// native, or an HTML canvas on wasm — created last on wasm so a
1375    /// failed device request never touches the canvas).
1376    fn finish_init(
1377        adapter: &wgpu::Adapter,
1378        device: wgpu::Device,
1379        queue: wgpu::Queue,
1380        surface: wgpu::Surface<'static>,
1381        size: (u32, u32),
1382        settings: GpuRendererSettings,
1383    ) -> Self {
1384        let info = adapter.get_info();
1385        let adapter_info = format!(
1386            "{name} ({backend:?}, {device_type:?})",
1387            name = info.name,
1388            backend = info.backend,
1389            device_type = info.device_type,
1390        );
1391
1392        let caps = surface.get_capabilities(adapter);
1393        // Pick a NON-sRGB, 8-bit swapchain format. Voxlap colours are
1394        // already sRGB-encoded (the slab bytes are display-ready,
1395        // matching what the CPU softbuffer path writes straight to the
1396        // framebuffer with no conversion); an sRGB swapchain would
1397        // re-apply the gamma curve, washing the look out. We also
1398        // *prefer 8-bit BGRA/RGBA* over any other non-sRGB format: some
1399        // adapters (e.g. NVK) advertise a 16-bit-unorm format first,
1400        // and wgpu 29 gates `create_view` on 16-bit-norm formats behind
1401        // the `TEXTURE_FORMAT_16BIT_NORM` device feature (which we don't
1402        // enable, to stay WebGPU-portable). Falls back to the first
1403        // non-sRGB format, then `caps.formats[0]`.
1404        let surface_format = caps
1405            .formats
1406            .iter()
1407            .copied()
1408            .find(|f| {
1409                matches!(
1410                    f,
1411                    wgpu::TextureFormat::Bgra8Unorm | wgpu::TextureFormat::Rgba8Unorm
1412                )
1413            })
1414            .or_else(|| caps.formats.iter().copied().find(|f| !f.is_srgb()))
1415            .unwrap_or(caps.formats[0]);
1416        let present_mode = if settings.uncapped_present {
1417            pick_present_mode(&caps.present_modes)
1418        } else {
1419            wgpu::PresentMode::Fifo
1420        };
1421        // GPU.11.2 — surface the present mode: `Fifo` is vsync-capped
1422        // (FPS pinned to refresh rate → compute optimisations like the
1423        // mip LOD won't show up in the FPS counter). Mailbox/Immediate
1424        // are uncapped. Wayland under Mesa frequently offers only Fifo.
1425        eprintln!(
1426            "roxlap-gpu: present mode = {present_mode:?} (available: {:?})",
1427            caps.present_modes,
1428        );
1429        let (init_w, init_h) = size;
1430        let surface_config = wgpu::SurfaceConfiguration {
1431            usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
1432            format: surface_format,
1433            width: init_w.max(1),
1434            height: init_h.max(1),
1435            present_mode,
1436            alpha_mode: caps.alpha_modes[0],
1437            view_formats: vec![],
1438            desired_maximum_frame_latency: 2,
1439        };
1440        surface.configure(&device, &surface_config);
1441
1442        // GPU.8 default sky: a 1×1 mid-grey texture. Hosts replace
1443        // it via `set_sky_panorama` with a real equirectangular
1444        // panorama; the default stops the shader sampling
1445        // uninitialised memory before that happens.
1446        let default_sky_pixel = [0x80u8, 0x80, 0x80, 0xff];
1447        let (sky_texture, sky_view) = create_sky_texture(&device, 1, 1, &default_sky_pixel);
1448        queue.write_texture(
1449            wgpu::TexelCopyTextureInfo {
1450                texture: &sky_texture,
1451                mip_level: 0,
1452                origin: wgpu::Origin3d::ZERO,
1453                aspect: wgpu::TextureAspect::All,
1454            },
1455            &default_sky_pixel,
1456            wgpu::TexelCopyBufferLayout {
1457                offset: 0,
1458                bytes_per_row: Some(4),
1459                rows_per_image: Some(1),
1460            },
1461            wgpu::Extent3d {
1462                width: 1,
1463                height: 1,
1464                depth_or_array_layers: 1,
1465            },
1466        );
1467        let sky_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
1468            label: Some("roxlap-gpu sky_sampler"),
1469            // Voxlap-convention panorama: u = elevation [0, 1]
1470            // (Repeat is a no-op since values don't go outside),
1471            // v = azimuth (wraps 360° — Repeat is required).
1472            address_mode_u: wgpu::AddressMode::Repeat,
1473            address_mode_v: wgpu::AddressMode::Repeat,
1474            address_mode_w: wgpu::AddressMode::ClampToEdge,
1475            mag_filter: wgpu::FilterMode::Linear,
1476            min_filter: wgpu::FilterMode::Linear,
1477            mipmap_filter: wgpu::MipmapFilterMode::Nearest,
1478            ..Default::default()
1479        });
1480
1481        // XS.4 — did the device grant enough storage buffers per stage for the
1482        // GPU sprite-shadow cross-pass bindings? If not, sprites render
1483        // unshadowed (the CPU backend still has full sprite shadows).
1484        let sprite_shadows_capable = device.limits().max_storage_buffers_per_shader_stage
1485            >= SPRITE_SHADOW_MIN_STORAGE_BUFFERS;
1486
1487        Self {
1488            surface,
1489            surface_config,
1490            device,
1491            queue,
1492            adapter_info,
1493            clear_colour: settings.clear_colour,
1494            frame_count: 0,
1495            flip_x: false,
1496            render_res: RenderResolution::Native,
1497            ssaa: 1,
1498            posterize: None,
1499            chunk_dda: None,
1500            grid_dda: None,
1501            scene_dda: None,
1502            scene_materials: Box::new(
1503                [MaterialGpu {
1504                    alpha: 1.0,
1505                    mode: 0,
1506                }; 256],
1507            ),
1508            scene_terrain_map: Vec::new(),
1509            scene_terrain_translucent: false,
1510            scene_depth_valid: false,
1511            sky_texture,
1512            sky_view,
1513            sky_sampler,
1514            // Fog disabled by default — voxlap's CPU rasterizer
1515            // also runs without fog in the scene-demo, so matching
1516            // it means no GPU fog out of the box. Hosts can opt in
1517            // via `set_fog` (e.g. for atmospheric far-LOD masking).
1518            fog_color: [0.66, 0.74, 0.88],
1519            fog_near: 0.0,
1520            fog_far: 1.0e30,
1521            sprite_registry: None,
1522            sprite_model_dda: None,
1523            sprite_shadows_capable,
1524            sprite_materials: Box::new(
1525                [MaterialGpu {
1526                    alpha: 1.0,
1527                    mode: 0,
1528                }; 256],
1529            ),
1530            sprite_has_translucent: false,
1531            // GPU.10.4 — default LOD threshold: step to a coarser mip
1532            // once a voxel projects below 4 px. Empirically the best
1533            // quality/cost tradeoff; the host can override.
1534            sprite_lod_px: 4.0,
1535            // GPU.11.1 — matches the CPU demo's mip_scan_dist=64.
1536            scene_mip_scan_dist: 64.0,
1537            scene_side_shades: [[0; 4]; 2],
1538            scene_lights: SceneLights::default(),
1539            last_fov_y_rad: 0.0,
1540            pending_frame: None,
1541            line_resources: None,
1542            line_vbuf: None,
1543            line_vbuf_cap: 0,
1544            image_resources: None,
1545            image_vbuf: None,
1546            image_vbuf_cap: 0,
1547            images: Vec::new(),
1548            #[cfg(feature = "hud")]
1549            egui_renderer: None,
1550        }
1551    }
1552
1553    /// Synchronous wrapper for hosts that don't have an async
1554    /// runtime. Internally `pollster::block_on`s [`Self::new`].
1555    ///
1556    /// # Errors
1557    /// See [`Self::new`].
1558    #[cfg(not(target_arch = "wasm32"))]
1559    pub fn new_blocking<W>(
1560        window: Arc<W>,
1561        size: (u32, u32),
1562        settings: GpuRendererSettings,
1563    ) -> Result<Self, GpuInitError>
1564    where
1565        W: HasWindowHandle + HasDisplayHandle + Send + Sync + 'static,
1566    {
1567        pollster::block_on(Self::new(window, size, settings))
1568    }
1569
1570    /// Human-readable adapter description — name + backend +
1571    /// device type. The demo host prints this in the title bar.
1572    pub fn adapter_info(&self) -> &str {
1573        &self.adapter_info
1574    }
1575
1576    /// Borrow the underlying wgpu device — hosts use this to build
1577    /// chunk uploads (`GpuChunkResident::upload(gpu.device(), …)`).
1578    pub fn device(&self) -> &wgpu::Device {
1579        &self.device
1580    }
1581
1582    /// XS.4 — whether this device can run GPU sprite shadows (it granted
1583    /// enough storage buffers per shader stage for the cross-pass occupancy
1584    /// bindings). `false` ⇒ GPU sprites render unshadowed; the CPU backend
1585    /// always has sprite shadows. Lets the facade/host report the fallback.
1586    #[must_use]
1587    pub fn sprite_shadows_capable(&self) -> bool {
1588        self.sprite_shadows_capable
1589    }
1590
1591    /// Borrow the wgpu queue — hosts use this for read-back paths
1592    /// (`GpuChunkResident::read_voxel_blocking(gpu.device(), gpu.queue(), …)`).
1593    pub fn queue(&self) -> &wgpu::Queue {
1594        &self.queue
1595    }
1596
1597    /// GPU.8 — upload an equirectangular panorama as the scene's
1598    /// sky texture. `rgba` is row-major, `width × height` pixels,
1599    /// 4 bytes per pixel (R, G, B, A). The shader samples it with
1600    /// `u = atan2(dir.x, dir.y) / (2π) + 0.5` (azimuth) and
1601    /// `v = acos(-dir.z) / π` (elevation), matching standard
1602    /// equirectangular layout (top of image = zenith for voxlap's
1603    /// `+z = down` basis).
1604    /// Mirror the marched scene (and its line/image overlays) horizontally
1605    /// on present, leaving the egui overlay upright. See [`Self::flip_x`].
1606    pub fn set_flip_x(&mut self, flip: bool) {
1607        self.flip_x = flip;
1608    }
1609
1610    ///
1611    /// # Panics
1612    /// If `rgba.len() != (width * height * 4) as usize`.
1613    pub fn set_sky_panorama(&mut self, rgba: &[u8], width: u32, height: u32) {
1614        assert_eq!(
1615            rgba.len(),
1616            (width as usize) * (height as usize) * 4,
1617            "set_sky_panorama: expected w*h*4 bytes, got {}",
1618            rgba.len(),
1619        );
1620        let (tex, view) = create_sky_texture(&self.device, width, height, rgba);
1621        // Upload pixel data via `queue.write_texture` so we don't
1622        // have to map the buffer manually.
1623        self.queue.write_texture(
1624            wgpu::TexelCopyTextureInfo {
1625                texture: &tex,
1626                mip_level: 0,
1627                origin: wgpu::Origin3d::ZERO,
1628                aspect: wgpu::TextureAspect::All,
1629            },
1630            rgba,
1631            wgpu::TexelCopyBufferLayout {
1632                offset: 0,
1633                bytes_per_row: Some(width * 4),
1634                rows_per_image: Some(height),
1635            },
1636            wgpu::Extent3d {
1637                width,
1638                height,
1639                depth_or_array_layers: 1,
1640            },
1641        );
1642        self.sky_texture = tex;
1643        self.sky_view = view;
1644    }
1645
1646    /// GPU.8 — set the fog blend. `color` is per-channel [0, 1];
1647    /// `near`/`far` are world-space ray distances in voxel units.
1648    /// Hits with `t < near` show their full colour; hits with
1649    /// `t > far` show `color` exclusively; in between is a
1650    /// smoothstep blend.
1651    pub fn set_fog(&mut self, color: [f32; 3], near: f32, far: f32) {
1652        self.fog_color = color;
1653        self.fog_near = near;
1654        self.fog_far = far.max(near + 1.0);
1655    }
1656
1657    /// Re-configure the swapchain to a new physical size. Call from
1658    /// `WindowEvent::Resized`. Drops the chunk-DDA storage texture
1659    /// so [`Self::render_chunk`] rebuilds it at the new size.
1660    pub fn resize(&mut self, width: u32, height: u32) {
1661        if width == 0 || height == 0 {
1662            return;
1663        }
1664        self.surface_config.width = width;
1665        self.surface_config.height = height;
1666        self.surface.configure(&self.device, &self.surface_config);
1667        self.chunk_dda = None;
1668        self.grid_dda = None;
1669        self.scene_dda = None;
1670    }
1671
1672    /// RP.0 — set the logical render resolution. Rebuilds the scene-DDA
1673    /// resources on the next [`Self::render_scene`] when the render size
1674    /// changes.
1675    pub fn set_render_resolution(&mut self, res: RenderResolution) {
1676        self.render_res = res;
1677    }
1678
1679    /// RP.1 — set the supersampling factor (clamped to `1..=4`). `1` = off.
1680    pub fn set_ssaa(&mut self, factor: u8) {
1681        self.ssaa = u32::from(factor).clamp(1, 4);
1682    }
1683
1684    /// RP.2 — set (or clear) the posterize post. Applied per-frame via the
1685    /// resolve uniform, so no pipeline rebuild is needed.
1686    pub fn set_posterize(&mut self, cfg: Option<PosterizeGpu>) {
1687        self.posterize = cfg;
1688    }
1689
1690    /// RP.0 — the logical (retro) grid size the scene resolves to before the
1691    /// upscale, resolved against the swapchain size. `logical_dims ==
1692    /// surface_dims` under [`RenderResolution::Native`].
1693    #[must_use]
1694    pub fn logical_dims(&self) -> (u32, u32) {
1695        self.render_res.logical_for(self.surface_dims())
1696    }
1697
1698    /// RP.1 — the resolution the scene/sprite passes actually march at:
1699    /// `logical_dims × ssaa`. The framebuffer + depth buffer are sized to this.
1700    #[must_use]
1701    pub fn render_dims(&self) -> (u32, u32) {
1702        let (lw, lh) = self.logical_dims();
1703        (lw * self.ssaa, lh * self.ssaa)
1704    }
1705
1706    /// RP.0 — the swapchain (native window) size.
1707    #[must_use]
1708    pub fn surface_dims(&self) -> (u32, u32) {
1709        (self.surface_config.width, self.surface_config.height)
1710    }
1711
1712    /// Acquire the next swapchain frame, or `None` to skip this frame.
1713    /// wgpu 29's `get_current_texture` returns a
1714    /// [`wgpu::CurrentSurfaceTexture`] status enum (was
1715    /// `Result<_, SurfaceError>`): an outdated/lost surface reconfigures
1716    /// and skips, transient statuses just skip.
1717    fn acquire_frame(&self) -> Option<wgpu::SurfaceTexture> {
1718        use wgpu::CurrentSurfaceTexture as C;
1719        match self.surface.get_current_texture() {
1720            C::Success(t) | C::Suboptimal(t) => Some(t),
1721            C::Outdated | C::Lost => {
1722                self.surface.configure(&self.device, &self.surface_config);
1723                None
1724            }
1725            C::Timeout | C::Occluded | C::Validation => None,
1726        }
1727    }
1728
1729    /// GPU.1 render: single render pass clearing the swapchain to a
1730    /// slowly drifting colour, then presenting. Voxels arrive in
1731    /// GPU.3+.
1732    pub fn render(&mut self) {
1733        let Some(surf_tex) = self.acquire_frame() else {
1734            return;
1735        };
1736        let view = surf_tex
1737            .texture
1738            .create_view(&wgpu::TextureViewDescriptor::default());
1739
1740        // Slow colour drift so the user can tell the GPU path is
1741        // actually presenting frames vs. e.g. a frozen window.
1742        // Wrap at 2π/0.005 frames (~1257) so the cast stays exact.
1743        let phase = f64::from(self.frame_count % 1257) * 0.005;
1744        let [r, g, b] = self.clear_colour;
1745        let drift = (phase.sin() * 0.04 + 0.04).clamp(0.0, 0.1);
1746        let clear = wgpu::Color {
1747            r: (r + drift).clamp(0.0, 1.0),
1748            g: (g + drift * 0.5).clamp(0.0, 1.0),
1749            b: (b + drift * 0.25).clamp(0.0, 1.0),
1750            a: 1.0,
1751        };
1752
1753        let mut encoder = self
1754            .device
1755            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
1756                label: Some("roxlap-gpu encoder"),
1757            });
1758        {
1759            let _rp = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1760                label: Some("roxlap-gpu clear"),
1761                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1762                    view: &view,
1763                    depth_slice: None,
1764                    resolve_target: None,
1765                    ops: wgpu::Operations {
1766                        load: wgpu::LoadOp::Clear(clear),
1767                        store: wgpu::StoreOp::Store,
1768                    },
1769                })],
1770                depth_stencil_attachment: None,
1771                timestamp_writes: None,
1772                occlusion_query_set: None,
1773                multiview_mask: None,
1774            });
1775        }
1776        self.queue.submit(std::iter::once(encoder.finish()));
1777        surf_tex.present();
1778        self.frame_count = self.frame_count.wrapping_add(1);
1779    }
1780
1781    /// GPU.3 single-chunk render. Dispatches `chunk_dda.wgsl`
1782    /// against `resident`'s storage buffers, then blits the
1783    /// low-res storage texture to the swapchain. `camera.position`
1784    /// is in **chunk-local** voxel units (host translates from
1785    /// world coords). `max_scan_dist` caps the per-pixel DDA loop —
1786    /// scene-demo wires `+` / `-` through this each frame.
1787    ///
1788    /// # Panics
1789    /// Internally `expect`s the chunk-DDA resources to be built —
1790    /// they are constructed at the top of this function if missing.
1791    /// Cannot fire in normal control flow.
1792    pub fn render_chunk(
1793        &mut self,
1794        resident: &GpuChunkResident,
1795        camera: &Camera,
1796        max_scan_dist: u32,
1797    ) {
1798        let Some(surf_tex) = self.acquire_frame() else {
1799            return;
1800        };
1801        let surf_view = surf_tex
1802            .texture
1803            .create_view(&wgpu::TextureViewDescriptor::default());
1804
1805        let surface_w = self.surface_config.width;
1806        let surface_h = self.surface_config.height;
1807        let surface_format = self.surface_config.format;
1808
1809        // Lazy-build chunk-DDA resources; rebuild when the swapchain
1810        // grew or shrank.
1811        let needs_build = match &self.chunk_dda {
1812            Some(r) => r.storage_size != (surface_w, surface_h),
1813            None => true,
1814        };
1815        if needs_build {
1816            self.chunk_dda = Some(self.build_chunk_dda(surface_w, surface_h, surface_format));
1817        }
1818        let dda = self.chunk_dda.as_ref().expect("just built");
1819
1820        // Update uniforms.
1821        let uniform = ChunkDdaUniform {
1822            camera_pos: camera.position,
1823            _pad0: 0.0,
1824            camera_right: camera.right,
1825            _pad1: 0.0,
1826            camera_down: camera.down,
1827            _pad2: 0.0,
1828            camera_forward: camera.forward,
1829            fov_y_rad: camera.fov_y_rad,
1830            screen_size: [surface_w, surface_h],
1831            vsid: resident.vsid,
1832            max_scan_dist,
1833        };
1834        self.queue
1835            .write_buffer(&dda.uniform_buf, 0, bytemuck::bytes_of(&uniform));
1836
1837        // Per-frame DDA bind group — references the chunk's buffers
1838        // so we rebuild every frame (the resident can change between
1839        // calls).
1840        let dda_bg = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
1841            label: Some("roxlap-gpu chunk_dda.bg"),
1842            layout: &dda.bgl_dda,
1843            entries: &[
1844                wgpu::BindGroupEntry {
1845                    binding: 0,
1846                    resource: dda.uniform_buf.as_entire_binding(),
1847                },
1848                wgpu::BindGroupEntry {
1849                    binding: 1,
1850                    resource: resident.occupancy.as_entire_binding(),
1851                },
1852                wgpu::BindGroupEntry {
1853                    binding: 2,
1854                    resource: resident.color_offsets.as_entire_binding(),
1855                },
1856                wgpu::BindGroupEntry {
1857                    binding: 3,
1858                    resource: resident.colors.as_entire_binding(),
1859                },
1860                wgpu::BindGroupEntry {
1861                    binding: 4,
1862                    resource: wgpu::BindingResource::TextureView(&dda.storage_view),
1863                },
1864            ],
1865        });
1866
1867        let mut encoder = self
1868            .device
1869            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
1870                label: Some("roxlap-gpu chunk encoder"),
1871            });
1872        {
1873            let mut cpass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
1874                label: Some("roxlap-gpu chunk_dda compute"),
1875                timestamp_writes: None,
1876            });
1877            cpass.set_pipeline(&dda.pipeline_dda);
1878            cpass.set_bind_group(0, &dda_bg, &[]);
1879            cpass.dispatch_workgroups(surface_w.div_ceil(8), surface_h.div_ceil(8), 1);
1880        }
1881        {
1882            let mut rpass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1883                label: Some("roxlap-gpu chunk_dda blit"),
1884                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1885                    view: &surf_view,
1886                    depth_slice: None,
1887                    resolve_target: None,
1888                    ops: wgpu::Operations {
1889                        load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
1890                        store: wgpu::StoreOp::Store,
1891                    },
1892                })],
1893                depth_stencil_attachment: None,
1894                timestamp_writes: None,
1895                occlusion_query_set: None,
1896                multiview_mask: None,
1897            });
1898            rpass.set_pipeline(&dda.pipeline_blit);
1899            rpass.set_bind_group(0, &dda.blit_bg, &[]);
1900            rpass.draw(0..3, 0..1);
1901        }
1902        self.queue.submit(std::iter::once(encoder.finish()));
1903        surf_tex.present();
1904        self.frame_count = self.frame_count.wrapping_add(1);
1905    }
1906
1907    fn build_chunk_dda(
1908        &self,
1909        width: u32,
1910        height: u32,
1911        surface_format: wgpu::TextureFormat,
1912    ) -> ChunkDdaResources {
1913        let storage_tex = self.device.create_texture(&wgpu::TextureDescriptor {
1914            label: Some("roxlap-gpu chunk_dda.storage"),
1915            size: wgpu::Extent3d {
1916                width,
1917                height,
1918                depth_or_array_layers: 1,
1919            },
1920            mip_level_count: 1,
1921            sample_count: 1,
1922            dimension: wgpu::TextureDimension::D2,
1923            format: wgpu::TextureFormat::Rgba8Unorm,
1924            usage: wgpu::TextureUsages::STORAGE_BINDING | wgpu::TextureUsages::TEXTURE_BINDING,
1925            view_formats: &[],
1926        });
1927        let storage_view = storage_tex.create_view(&wgpu::TextureViewDescriptor::default());
1928
1929        let uniform_buf = self.device.create_buffer(&wgpu::BufferDescriptor {
1930            label: Some("roxlap-gpu chunk_dda.uniform"),
1931            size: std::mem::size_of::<ChunkDdaUniform>() as u64,
1932            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
1933            mapped_at_creation: false,
1934        });
1935
1936        let dda_shader = self
1937            .device
1938            .create_shader_module(wgpu::ShaderModuleDescriptor {
1939                label: Some("chunk_dda.wgsl"),
1940                source: wgpu::ShaderSource::Wgsl(include_str!("../shaders/chunk_dda.wgsl").into()),
1941            });
1942        let bgl_dda = self
1943            .device
1944            .create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
1945                label: Some("roxlap-gpu chunk_dda.bgl"),
1946                entries: &[
1947                    bgl_uniform_entry(0),
1948                    bgl_storage_entry(1, true),
1949                    bgl_storage_entry(2, true),
1950                    bgl_storage_entry(3, true),
1951                    wgpu::BindGroupLayoutEntry {
1952                        binding: 4,
1953                        visibility: wgpu::ShaderStages::COMPUTE,
1954                        ty: wgpu::BindingType::StorageTexture {
1955                            access: wgpu::StorageTextureAccess::WriteOnly,
1956                            format: wgpu::TextureFormat::Rgba8Unorm,
1957                            view_dimension: wgpu::TextureViewDimension::D2,
1958                        },
1959                        count: None,
1960                    },
1961                ],
1962            });
1963        let dda_pl = self
1964            .device
1965            .create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
1966                label: Some("roxlap-gpu chunk_dda.layout"),
1967                bind_group_layouts: &[Some(&bgl_dda)],
1968                immediate_size: 0,
1969            });
1970        let pipeline_dda = self
1971            .device
1972            .create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
1973                label: Some("roxlap-gpu chunk_dda.pipeline"),
1974                layout: Some(&dda_pl),
1975                module: &dda_shader,
1976                entry_point: Some("render_chunk"),
1977                compilation_options: wgpu::PipelineCompilationOptions::default(),
1978                cache: None,
1979            });
1980
1981        // Fullscreen-triangle blit upscales the storage texture into
1982        // the swapchain. Nearest filter keeps the retro pixel look.
1983        let blit_shader = self
1984            .device
1985            .create_shader_module(wgpu::ShaderModuleDescriptor {
1986                label: Some("blit.wgsl"),
1987                source: wgpu::ShaderSource::Wgsl(include_str!("../shaders/blit.wgsl").into()),
1988            });
1989        let bgl_blit = self
1990            .device
1991            .create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
1992                label: Some("roxlap-gpu chunk_dda.blit_bgl"),
1993                entries: &[
1994                    wgpu::BindGroupLayoutEntry {
1995                        binding: 0,
1996                        visibility: wgpu::ShaderStages::FRAGMENT,
1997                        ty: wgpu::BindingType::Texture {
1998                            sample_type: wgpu::TextureSampleType::Float { filterable: false },
1999                            view_dimension: wgpu::TextureViewDimension::D2,
2000                            multisampled: false,
2001                        },
2002                        count: None,
2003                    },
2004                    wgpu::BindGroupLayoutEntry {
2005                        binding: 1,
2006                        visibility: wgpu::ShaderStages::FRAGMENT,
2007                        ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::NonFiltering),
2008                        count: None,
2009                    },
2010                ],
2011            });
2012        let blit_pl = self
2013            .device
2014            .create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
2015                label: Some("roxlap-gpu chunk_dda.blit_layout"),
2016                bind_group_layouts: &[Some(&bgl_blit)],
2017                immediate_size: 0,
2018            });
2019        let pipeline_blit = self
2020            .device
2021            .create_render_pipeline(&wgpu::RenderPipelineDescriptor {
2022                label: Some("roxlap-gpu chunk_dda.blit_pipeline"),
2023                layout: Some(&blit_pl),
2024                vertex: wgpu::VertexState {
2025                    module: &blit_shader,
2026                    entry_point: Some("vs_main"),
2027                    compilation_options: wgpu::PipelineCompilationOptions::default(),
2028                    buffers: &[],
2029                },
2030                fragment: Some(wgpu::FragmentState {
2031                    module: &blit_shader,
2032                    entry_point: Some("fs_main"),
2033                    compilation_options: wgpu::PipelineCompilationOptions::default(),
2034                    targets: &[Some(wgpu::ColorTargetState {
2035                        format: surface_format,
2036                        blend: None,
2037                        write_mask: wgpu::ColorWrites::ALL,
2038                    })],
2039                }),
2040                primitive: wgpu::PrimitiveState::default(),
2041                depth_stencil: None,
2042                multisample: wgpu::MultisampleState::default(),
2043                multiview_mask: None,
2044                cache: None,
2045            });
2046        let sampler = self.device.create_sampler(&wgpu::SamplerDescriptor {
2047            label: Some("roxlap-gpu chunk_dda.blit_sampler"),
2048            address_mode_u: wgpu::AddressMode::ClampToEdge,
2049            address_mode_v: wgpu::AddressMode::ClampToEdge,
2050            address_mode_w: wgpu::AddressMode::ClampToEdge,
2051            mag_filter: wgpu::FilterMode::Nearest,
2052            min_filter: wgpu::FilterMode::Nearest,
2053            mipmap_filter: wgpu::MipmapFilterMode::Nearest,
2054            ..Default::default()
2055        });
2056        let blit_bg = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
2057            label: Some("roxlap-gpu chunk_dda.blit_bg"),
2058            layout: &bgl_blit,
2059            entries: &[
2060                wgpu::BindGroupEntry {
2061                    binding: 0,
2062                    resource: wgpu::BindingResource::TextureView(&storage_view),
2063                },
2064                wgpu::BindGroupEntry {
2065                    binding: 1,
2066                    resource: wgpu::BindingResource::Sampler(&sampler),
2067                },
2068            ],
2069        });
2070
2071        ChunkDdaResources {
2072            storage_size: (width, height),
2073            storage_view,
2074            uniform_buf,
2075            bgl_dda,
2076            pipeline_dda,
2077            blit_bg,
2078            pipeline_blit,
2079            _sampler: sampler,
2080        }
2081    }
2082
2083    /// GPU.4 render — outer DDA over chunk indices + inner DDA into
2084    /// non-empty chunks. `camera.position` is in **grid-local**
2085    /// voxel units. `max_outer_steps` caps how many chunks the
2086    /// outer DDA may traverse per ray (scene-demo wires `+ / -`
2087    /// through this).
2088    ///
2089    /// # Panics
2090    /// Internally `expect`s the grid-DDA resources to be built;
2091    /// they are constructed at the top of this function if missing.
2092    pub fn render_grid(&mut self, grid: &GpuGridResident, camera: &Camera, max_outer_steps: u32) {
2093        let Some(surf_tex) = self.acquire_frame() else {
2094            return;
2095        };
2096        let surf_view = surf_tex
2097            .texture
2098            .create_view(&wgpu::TextureViewDescriptor::default());
2099
2100        let surface_w = self.surface_config.width;
2101        let surface_h = self.surface_config.height;
2102        let surface_format = self.surface_config.format;
2103
2104        let needs_build = match &self.grid_dda {
2105            Some(r) => r.storage_size != (surface_w, surface_h),
2106            None => true,
2107        };
2108        if needs_build {
2109            self.grid_dda = Some(self.build_grid_dda(surface_w, surface_h, surface_format));
2110        }
2111        let dda = self.grid_dda.as_ref().expect("just built");
2112
2113        let uniform = GridDdaUniform {
2114            camera_pos: camera.position,
2115            _pad0: 0.0,
2116            camera_right: camera.right,
2117            _pad1: 0.0,
2118            camera_down: camera.down,
2119            _pad2: 0.0,
2120            camera_forward: camera.forward,
2121            fov_y_rad: camera.fov_y_rad,
2122            screen_size: [surface_w, surface_h],
2123            vsid: grid.vsid,
2124            max_outer_steps,
2125            chunks_dims: grid.chunks_dims,
2126            _pad3: 0,
2127            origin_chunk: grid.origin_chunk,
2128            _pad4: 0,
2129        };
2130        self.queue
2131            .write_buffer(&dda.uniform_buf, 0, bytemuck::bytes_of(&uniform));
2132
2133        let dda_bg = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
2134            label: Some("roxlap-gpu grid_dda.bg"),
2135            layout: &dda.bgl_dda,
2136            entries: &[
2137                wgpu::BindGroupEntry {
2138                    binding: 0,
2139                    resource: dda.uniform_buf.as_entire_binding(),
2140                },
2141                wgpu::BindGroupEntry {
2142                    binding: 1,
2143                    resource: grid.occupancy.as_entire_binding(),
2144                },
2145                wgpu::BindGroupEntry {
2146                    binding: 2,
2147                    resource: grid.color_offsets.as_entire_binding(),
2148                },
2149                wgpu::BindGroupEntry {
2150                    binding: 3,
2151                    resource: grid.colors.as_entire_binding(),
2152                },
2153                wgpu::BindGroupEntry {
2154                    binding: 4,
2155                    resource: grid.chunk_colors_base.as_entire_binding(),
2156                },
2157                wgpu::BindGroupEntry {
2158                    binding: 5,
2159                    resource: grid.chunk_occupancy.as_entire_binding(),
2160                },
2161                wgpu::BindGroupEntry {
2162                    binding: 6,
2163                    resource: wgpu::BindingResource::TextureView(&dda.storage_view),
2164                },
2165            ],
2166        });
2167
2168        let mut encoder = self
2169            .device
2170            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
2171                label: Some("roxlap-gpu grid encoder"),
2172            });
2173        {
2174            let mut cpass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
2175                label: Some("roxlap-gpu grid_dda compute"),
2176                timestamp_writes: None,
2177            });
2178            cpass.set_pipeline(&dda.pipeline_dda);
2179            cpass.set_bind_group(0, &dda_bg, &[]);
2180            cpass.dispatch_workgroups(surface_w.div_ceil(8), surface_h.div_ceil(8), 1);
2181        }
2182        {
2183            let mut rpass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
2184                label: Some("roxlap-gpu grid_dda blit"),
2185                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
2186                    view: &surf_view,
2187                    depth_slice: None,
2188                    resolve_target: None,
2189                    ops: wgpu::Operations {
2190                        load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
2191                        store: wgpu::StoreOp::Store,
2192                    },
2193                })],
2194                depth_stencil_attachment: None,
2195                timestamp_writes: None,
2196                occlusion_query_set: None,
2197                multiview_mask: None,
2198            });
2199            rpass.set_pipeline(&dda.pipeline_blit);
2200            rpass.set_bind_group(0, &dda.blit_bg, &[]);
2201            rpass.draw(0..3, 0..1);
2202        }
2203        self.queue.submit(std::iter::once(encoder.finish()));
2204        surf_tex.present();
2205        self.frame_count = self.frame_count.wrapping_add(1);
2206    }
2207
2208    fn build_grid_dda(
2209        &self,
2210        width: u32,
2211        height: u32,
2212        surface_format: wgpu::TextureFormat,
2213    ) -> GridDdaResources {
2214        let storage_tex = self.device.create_texture(&wgpu::TextureDescriptor {
2215            label: Some("roxlap-gpu grid_dda.storage"),
2216            size: wgpu::Extent3d {
2217                width,
2218                height,
2219                depth_or_array_layers: 1,
2220            },
2221            mip_level_count: 1,
2222            sample_count: 1,
2223            dimension: wgpu::TextureDimension::D2,
2224            format: wgpu::TextureFormat::Rgba8Unorm,
2225            usage: wgpu::TextureUsages::STORAGE_BINDING | wgpu::TextureUsages::TEXTURE_BINDING,
2226            view_formats: &[],
2227        });
2228        let storage_view = storage_tex.create_view(&wgpu::TextureViewDescriptor::default());
2229
2230        let uniform_buf = self.device.create_buffer(&wgpu::BufferDescriptor {
2231            label: Some("roxlap-gpu grid_dda.uniform"),
2232            size: std::mem::size_of::<GridDdaUniform>() as u64,
2233            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
2234            mapped_at_creation: false,
2235        });
2236
2237        let dda_shader = self
2238            .device
2239            .create_shader_module(wgpu::ShaderModuleDescriptor {
2240                label: Some("grid_dda.wgsl"),
2241                source: wgpu::ShaderSource::Wgsl(include_str!("../shaders/grid_dda.wgsl").into()),
2242            });
2243        let bgl_dda = self
2244            .device
2245            .create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
2246                label: Some("roxlap-gpu grid_dda.bgl"),
2247                entries: &[
2248                    bgl_uniform_entry(0),
2249                    bgl_storage_entry(1, true),
2250                    bgl_storage_entry(2, true),
2251                    bgl_storage_entry(3, true),
2252                    bgl_storage_entry(4, true),
2253                    bgl_storage_entry(5, true),
2254                    wgpu::BindGroupLayoutEntry {
2255                        binding: 6,
2256                        visibility: wgpu::ShaderStages::COMPUTE,
2257                        ty: wgpu::BindingType::StorageTexture {
2258                            access: wgpu::StorageTextureAccess::WriteOnly,
2259                            format: wgpu::TextureFormat::Rgba8Unorm,
2260                            view_dimension: wgpu::TextureViewDimension::D2,
2261                        },
2262                        count: None,
2263                    },
2264                ],
2265            });
2266        let dda_pl = self
2267            .device
2268            .create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
2269                label: Some("roxlap-gpu grid_dda.layout"),
2270                bind_group_layouts: &[Some(&bgl_dda)],
2271                immediate_size: 0,
2272            });
2273        let pipeline_dda = self
2274            .device
2275            .create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
2276                label: Some("roxlap-gpu grid_dda.pipeline"),
2277                layout: Some(&dda_pl),
2278                module: &dda_shader,
2279                entry_point: Some("render_grid"),
2280                compilation_options: wgpu::PipelineCompilationOptions::default(),
2281                cache: None,
2282            });
2283
2284        let blit_shader = self
2285            .device
2286            .create_shader_module(wgpu::ShaderModuleDescriptor {
2287                label: Some("blit.wgsl"),
2288                source: wgpu::ShaderSource::Wgsl(include_str!("../shaders/blit.wgsl").into()),
2289            });
2290        let bgl_blit = self
2291            .device
2292            .create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
2293                label: Some("roxlap-gpu grid_dda.blit_bgl"),
2294                entries: &[
2295                    wgpu::BindGroupLayoutEntry {
2296                        binding: 0,
2297                        visibility: wgpu::ShaderStages::FRAGMENT,
2298                        ty: wgpu::BindingType::Texture {
2299                            sample_type: wgpu::TextureSampleType::Float { filterable: false },
2300                            view_dimension: wgpu::TextureViewDimension::D2,
2301                            multisampled: false,
2302                        },
2303                        count: None,
2304                    },
2305                    wgpu::BindGroupLayoutEntry {
2306                        binding: 1,
2307                        visibility: wgpu::ShaderStages::FRAGMENT,
2308                        ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::NonFiltering),
2309                        count: None,
2310                    },
2311                ],
2312            });
2313        let blit_pl = self
2314            .device
2315            .create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
2316                label: Some("roxlap-gpu grid_dda.blit_layout"),
2317                bind_group_layouts: &[Some(&bgl_blit)],
2318                immediate_size: 0,
2319            });
2320        let pipeline_blit = self
2321            .device
2322            .create_render_pipeline(&wgpu::RenderPipelineDescriptor {
2323                label: Some("roxlap-gpu grid_dda.blit_pipeline"),
2324                layout: Some(&blit_pl),
2325                vertex: wgpu::VertexState {
2326                    module: &blit_shader,
2327                    entry_point: Some("vs_main"),
2328                    compilation_options: wgpu::PipelineCompilationOptions::default(),
2329                    buffers: &[],
2330                },
2331                fragment: Some(wgpu::FragmentState {
2332                    module: &blit_shader,
2333                    entry_point: Some("fs_main"),
2334                    compilation_options: wgpu::PipelineCompilationOptions::default(),
2335                    targets: &[Some(wgpu::ColorTargetState {
2336                        format: surface_format,
2337                        blend: None,
2338                        write_mask: wgpu::ColorWrites::ALL,
2339                    })],
2340                }),
2341                primitive: wgpu::PrimitiveState::default(),
2342                depth_stencil: None,
2343                multisample: wgpu::MultisampleState::default(),
2344                multiview_mask: None,
2345                cache: None,
2346            });
2347        let sampler = self.device.create_sampler(&wgpu::SamplerDescriptor {
2348            label: Some("roxlap-gpu grid_dda.blit_sampler"),
2349            address_mode_u: wgpu::AddressMode::ClampToEdge,
2350            address_mode_v: wgpu::AddressMode::ClampToEdge,
2351            address_mode_w: wgpu::AddressMode::ClampToEdge,
2352            mag_filter: wgpu::FilterMode::Nearest,
2353            min_filter: wgpu::FilterMode::Nearest,
2354            mipmap_filter: wgpu::MipmapFilterMode::Nearest,
2355            ..Default::default()
2356        });
2357        let blit_bg = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
2358            label: Some("roxlap-gpu grid_dda.blit_bg"),
2359            layout: &bgl_blit,
2360            entries: &[
2361                wgpu::BindGroupEntry {
2362                    binding: 0,
2363                    resource: wgpu::BindingResource::TextureView(&storage_view),
2364                },
2365                wgpu::BindGroupEntry {
2366                    binding: 1,
2367                    resource: wgpu::BindingResource::Sampler(&sampler),
2368                },
2369            ],
2370        });
2371
2372        GridDdaResources {
2373            storage_size: (width, height),
2374            storage_view,
2375            uniform_buf,
2376            bgl_dda,
2377            pipeline_dda,
2378            blit_bg,
2379            pipeline_blit,
2380            _sampler: sampler,
2381        }
2382    }
2383
2384    /// GPU.5 render — multi-grid scene marcher. `cameras[i]` is the
2385    /// world camera transformed into grid `i`'s local frame
2386    /// (caller-supplied; see scene-demo's `redraw_gpu` for the
2387    /// glam-based transform). `fov_y_rad` is the shared vertical
2388    /// FOV; `max_outer_steps` caps per-ray chunk-DDA work for each
2389    /// grid.
2390    ///
2391    /// # Panics
2392    /// If `cameras.len() != scene.grid_count`.
2393    /// `cameras[i]` is grid `i`'s world camera transformed into that
2394    /// grid's local frame (the grid marcher works in grid-local space).
2395    /// `sprite_camera` is the **world** camera: instanced sprites carry
2396    /// world-space positions/transforms, so they must project through
2397    /// the untransformed world camera — not `cameras[0]`, which is only
2398    /// the world camera when grid 0 is at identity.
2399    pub fn render_scene(
2400        &mut self,
2401        scene: &GpuSceneResident,
2402        cameras: &[Camera],
2403        // XS.3 — per-grid world transforms (parallel to `cameras`) for
2404        // cross-grid shadows. Empty ⇒ identity (shadows stay intra-grid).
2405        grid_world: &[GridWorldTransform],
2406        sprite_camera: &Camera,
2407        fov_y_rad: f32,
2408        max_outer_steps: u32,
2409    ) {
2410        assert_eq!(
2411            cameras.len(),
2412            scene.grid_count as usize,
2413            "render_scene: {} cameras supplied, scene has {} grids",
2414            cameras.len(),
2415            scene.grid_count,
2416        );
2417        self.last_fov_y_rad = fov_y_rad; // cached for pixel_ray (picking)
2418
2419        // Deferred present: drop any frame a prior render left
2420        // un-presented (a host that skipped present/paint_egui) so we
2421        // never hold two outstanding swapchain textures.
2422        self.pending_frame = None;
2423        let Some(surf_tex) = self.acquire_frame() else {
2424            return;
2425        };
2426        let surf_view = surf_tex
2427            .texture
2428            .create_view(&wgpu::TextureViewDescriptor::default());
2429
2430        let surface_w = self.surface_config.width;
2431        let surface_h = self.surface_config.height;
2432        let surface_format = self.surface_config.format;
2433        // RP.0/RP.1 — the scene + sprite + depth passes march at the *render*
2434        // size (`logical × ssaa`); a resolve pass box-downfilters to the
2435        // logical grid; the blit nearest-upscales to the swapchain. The
2436        // framebuffer/depth/occupancy + per-pixel projection key off the render
2437        // (march) size. `Native` + `ssaa==1` ⇒ render == logical == surface.
2438        let (logical_w, logical_h) = self.logical_dims();
2439        let (render_w, render_h) = self.render_dims();
2440
2441        let needs_build = match &self.scene_dda {
2442            Some(r) => {
2443                r.storage_size != (render_w, render_h) || r.logical_size != (logical_w, logical_h)
2444            }
2445            None => true,
2446        };
2447        if needs_build {
2448            self.scene_dda = Some(self.build_scene_dda(
2449                render_w,
2450                render_h,
2451                logical_w,
2452                logical_h,
2453                surface_w,
2454                surface_h,
2455                surface_format,
2456            ));
2457        }
2458        // GPU.9 — materialise the sprite pipeline the first frame
2459        // sprites are present (before the immutable `dda` borrow).
2460        // GPU.10.0 — build the model-DDA pipeline the first frame a
2461        // sprite registry is present.
2462        if self.sprite_registry.is_some() && self.sprite_model_dda.is_none() {
2463            self.sprite_model_dda = Some(self.build_sprite_model_dda());
2464        }
2465        // GPU.10.3 — frustum-cull + screen-tile-bin the sprite instances
2466        // (needs &mut self for buffer growth, so before the immutable
2467        // scene_dda borrow). Captures (visible_count, tiles_x); None when
2468        // nothing is in view.
2469        let sprite_pass: Option<(u32, u32)> = if let Some(reg) = self.sprite_registry.as_mut() {
2470            if reg.instance_capacity > 0 {
2471                // World camera — sprite positions/transforms are world-
2472                // space (independent of any grid's transform).
2473                let cam = sprite_camera;
2474                // Aspect + tile binning are in render (logical) space — the
2475                // sprite pass writes the render-sized framebuffer/depth.
2476                #[allow(clippy::cast_precision_loss)]
2477                let aspect = render_w as f32 / render_h as f32;
2478                let half_h = (fov_y_rad * 0.5).tan();
2479                let frustum = sprite_model::ViewFrustum {
2480                    pos: cam.position,
2481                    right: cam.right,
2482                    down: cam.down,
2483                    forward: cam.forward,
2484                    half_w: half_h * aspect,
2485                    half_h,
2486                    far: 1.0e9,
2487                };
2488                let (visible, tiles_x, _tiles_y) = reg.cull_bin_upload(
2489                    &self.device,
2490                    &self.queue,
2491                    &frustum,
2492                    render_w,
2493                    render_h,
2494                    SPRITE_TILE_SIZE,
2495                    self.sprite_lod_px,
2496                );
2497                (visible > 0).then_some((visible, tiles_x))
2498            } else {
2499                None
2500            }
2501        } else {
2502            None
2503        };
2504        let dda = self.scene_dda.as_ref().expect("just built");
2505
2506        // Refresh the blit's flip flag each frame (offset 16, after the
2507        // src + dst vec2 sizes), so toggling the flip applies without a
2508        // resize. The src/dst sizes themselves are written at build time
2509        // (a render/surface size change forces a rebuild).
2510        self.queue.write_buffer(
2511            &dda.blit_dims,
2512            16,
2513            bytemuck::bytes_of(&[u32::from(self.flip_x), 0u32]),
2514        );
2515        // RP.2 — refresh the resolve pass's posterize fields each frame (offset
2516        // 20, after src/dst dims + ssaa). `None` ⇒ `levels = [1,1,1]`, `dither
2517        // = 0` ⇒ the resolve does box-downfilter only (RP.1).
2518        let (plevels, pdither) = match self.posterize {
2519            Some(p) => (p.levels, p.dither),
2520            None => ([1u32; 3], 0u32),
2521        };
2522        self.queue.write_buffer(
2523            &dda.resolve_dims,
2524            20,
2525            bytemuck::bytes_of(&[plevels[0], plevels[1], plevels[2], pdither]),
2526        );
2527
2528        // Pack per-grid cameras into a runtime-sized storage buffer
2529        // (binding 15) — no fixed cap on grid count.
2530        let mut cam_vec: Vec<SceneDdaPerGridCamera> = cameras
2531            .iter()
2532            .map(SceneDdaPerGridCamera::from_camera)
2533            .collect();
2534        // XS.3 — stamp each grid's world transform for cross-grid shadows.
2535        for (c, t) in cam_vec.iter_mut().zip(grid_world.iter()) {
2536            c.set_world_transform(t);
2537        }
2538
2539        // DL — pack the per-frame lights (already grid-local). The per-grid
2540        // sun direction rides in each `PerGridCamera.sun_dir` (binding 15);
2541        // point lights go in one new storage buffer (binding 18). All-zero
2542        // ⇒ the pre-DL render. Shared with the headless path.
2543        let lights = self.scene_lights.clone();
2544        let (grid_point_lights, sun_flags, point_count) = pack_scene_lights(
2545            &self.device,
2546            &lights,
2547            scene.grid_count as usize,
2548            &mut cam_vec,
2549        );
2550        let grid_cameras = upload_grid_cameras(&self.device, &cam_vec);
2551
2552        let uniform = SceneDdaUniform {
2553            fov_y_rad,
2554            grid_count: scene.grid_count,
2555            max_outer_steps,
2556            _pad0: 0,
2557            screen_size: [render_w, render_h],
2558            _pad1: [0; 2],
2559            fog_color: [
2560                self.fog_color[0],
2561                self.fog_color[1],
2562                self.fog_color[2],
2563                self.fog_near,
2564            ],
2565            fog_far: self.fog_far,
2566            // L3.1: always write scene depth. Costs one storage store per
2567            // pixel, and the depth is needed for sprite z-test, sprite-less
2568            // `pick_depth`, and `draw_lines` occlusion alike.
2569            write_depth: 1,
2570            occ_page_words: scene.occupancy_page_words,
2571            occ_num_pages: scene.occupancy_num_pages,
2572            mip_scan_dist: self.scene_mip_scan_dist,
2573            terrain_has_translucent: u32::from(self.scene_terrain_translucent),
2574            terrain_map_count: self.scene_terrain_map.len() as u32,
2575            _pad4: 0,
2576            // Sky direction comes from the world (sprite) camera, so a
2577            // grid-less sprite-only scene still paints a real sky.
2578            sky_cam: SceneDdaPerGridCamera::from_camera(sprite_camera),
2579            side_shades0: self.scene_side_shades[0],
2580            side_shades1: self.scene_side_shades[1],
2581            sun_color: [
2582                lights.sun_color[0],
2583                lights.sun_color[1],
2584                lights.sun_color[2],
2585                lights.sun_intensity,
2586            ],
2587            ambient_color: [
2588                lights.ambient[0],
2589                lights.ambient[1],
2590                lights.ambient[2],
2591                lights.shadow_strength,
2592            ],
2593            sun_flags,
2594            point_light_count: point_count,
2595            shadow_max_steps: lights.shadow_max_steps,
2596            _pad5: 0,
2597            shadow_bias: lights.shadow_bias,
2598            shadow_max_dist: lights.shadow_max_dist,
2599            _pad6: [0.0; 2],
2600            shadow_tint: [
2601                lights.shadow_tint[0],
2602                lights.shadow_tint[1],
2603                lights.shadow_tint[2],
2604                0.0,
2605            ],
2606            style_bands: lights.style_bands,
2607            // XS.4.3 — visible sprite casters for the scene-pass cast march
2608            // (only when the device is sprite-shadow capable; else the cast
2609            // bindings/loop are absent).
2610            sprite_cast_count: if self.sprite_shadows_capable {
2611                sprite_pass.map_or(0, |(visible, _)| visible)
2612            } else {
2613                0
2614            },
2615            _pad7: [0; 2],
2616        };
2617        self.queue
2618            .write_buffer(&dda.uniform_buf, 0, bytemuck::bytes_of(&uniform));
2619
2620        let mut dda_entries = vec![
2621            wgpu::BindGroupEntry {
2622                binding: 0,
2623                resource: dda.uniform_buf.as_entire_binding(),
2624            },
2625            // Occupancy page 0 at binding 1; pages 1..MAX_OCC_PAGES
2626            // at bindings 12.. (see GPU.X occupancy paging).
2627            wgpu::BindGroupEntry {
2628                binding: 1,
2629                resource: scene.occupancy_pages[0].as_entire_binding(),
2630            },
2631            wgpu::BindGroupEntry {
2632                binding: 2,
2633                resource: scene.all_color_offsets.as_entire_binding(),
2634            },
2635            wgpu::BindGroupEntry {
2636                binding: 3,
2637                resource: scene.all_colors.as_entire_binding(),
2638            },
2639            wgpu::BindGroupEntry {
2640                binding: 4,
2641                resource: scene.all_chunk_colors_base.as_entire_binding(),
2642            },
2643            wgpu::BindGroupEntry {
2644                binding: 5,
2645                resource: scene.all_chunk_occupancy.as_entire_binding(),
2646            },
2647            wgpu::BindGroupEntry {
2648                binding: 6,
2649                resource: scene.grid_static_meta.as_entire_binding(),
2650            },
2651            wgpu::BindGroupEntry {
2652                binding: 7,
2653                resource: scene.all_slot_chunk_idx.as_entire_binding(),
2654            },
2655            wgpu::BindGroupEntry {
2656                binding: 8,
2657                resource: dda.framebuffer.as_entire_binding(),
2658            },
2659            wgpu::BindGroupEntry {
2660                binding: 9,
2661                resource: wgpu::BindingResource::TextureView(&self.sky_view),
2662            },
2663            wgpu::BindGroupEntry {
2664                binding: 10,
2665                resource: wgpu::BindingResource::Sampler(&self.sky_sampler),
2666            },
2667            wgpu::BindGroupEntry {
2668                binding: 11,
2669                resource: dda.depth_buffer.as_entire_binding(),
2670            },
2671            wgpu::BindGroupEntry {
2672                binding: 12,
2673                resource: scene.occupancy_pages[1].as_entire_binding(),
2674            },
2675            wgpu::BindGroupEntry {
2676                binding: 13,
2677                resource: scene.occupancy_pages[2].as_entire_binding(),
2678            },
2679            wgpu::BindGroupEntry {
2680                binding: 14,
2681                resource: scene.occupancy_pages[3].as_entire_binding(),
2682            },
2683            wgpu::BindGroupEntry {
2684                binding: 15,
2685                resource: grid_cameras.as_entire_binding(),
2686            },
2687            wgpu::BindGroupEntry {
2688                binding: 16,
2689                resource: dda.materials_pal_buf.as_entire_binding(),
2690            },
2691            wgpu::BindGroupEntry {
2692                binding: 17,
2693                resource: dda.terrain_map_buf.as_entire_binding(),
2694            },
2695            // DL — per-grid point lights (18). The per-grid sun dir
2696            // rides in PerGridCamera.sun_dir (binding 15).
2697            wgpu::BindGroupEntry {
2698                binding: 18,
2699                resource: grid_point_lights.as_entire_binding(),
2700            },
2701        ];
2702        // XS.4.3 — sprite-cast bindings (19..21). On a capable device the BGL
2703        // has them, so bind the sprite registry when present (terrain shadow
2704        // rays test sprite volumes), else the dummy (sprite_cast_count == 0).
2705        if self.sprite_shadows_capable {
2706            let dummy = dda
2707                .sprite_cast_dummy
2708                .as_ref()
2709                .expect("capable scene_dda has a sprite-cast dummy");
2710            let (insts, models, occ) = match &self.sprite_registry {
2711                Some(reg) => (&reg.instances, &reg.model_meta, &reg.occupancy),
2712                None => (dummy, dummy, dummy),
2713            };
2714            dda_entries.push(wgpu::BindGroupEntry {
2715                binding: 19,
2716                resource: insts.as_entire_binding(),
2717            });
2718            dda_entries.push(wgpu::BindGroupEntry {
2719                binding: 20,
2720                resource: models.as_entire_binding(),
2721            });
2722            dda_entries.push(wgpu::BindGroupEntry {
2723                binding: 21,
2724                resource: occ.as_entire_binding(),
2725            });
2726        }
2727        let dda_bg = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
2728            label: Some("roxlap-gpu scene_dda.bg"),
2729            layout: &dda.bgl_dda,
2730            entries: &dda_entries,
2731        });
2732
2733        // GPU.9 — when sprites are present, build both splatter bind
2734        // groups up front (the splat pass writes the key buffer; the
2735        // resolve pass reads keys + scene depth and writes colour).
2736        // GPU.10.3 — model-DDA bind group + per-frame uniform, using the
2737        // cull/bin results captured above. Per-model + per-instance data
2738        // + the tile lists live in the registry buffers.
2739        let sprite_model_bg = match (&self.sprite_model_dda, &self.sprite_registry, sprite_pass) {
2740            (Some(smd), Some(reg), Some((visible, tiles_x))) => {
2741                // World camera (see the cull pass above) — sprites
2742                // project through it regardless of grid 0's transform.
2743                let cam = sprite_camera;
2744                // DL.4 — world-space lights for the sprite pass (sprites are
2745                // world-space, not grid-local). No sprite shadows (deferred).
2746                let dl = &self.scene_lights;
2747                let sprite_sun_enabled = dl.world_sun_dir != [0.0; 3];
2748                let sprite_pts: Vec<GpuPointLight> = dl
2749                    .world_points
2750                    .iter()
2751                    .take(MAX_POINT_LIGHTS)
2752                    .map(|l| GpuPointLight {
2753                        pos: l.position,
2754                        radius: l.radius,
2755                        color: l.color,
2756                        intensity: l.intensity,
2757                        // XS.4.2 — honour the light's caster flag so a
2758                        // receiving sprite is shadowed by it (capable devices).
2759                        casts_shadow: u32::from(l.casts_shadow),
2760                        _pad: [0; 3],
2761                    })
2762                    .collect();
2763                let sprite_point_count = sprite_pts.len() as u32;
2764                let sprite_point_buf = upload_grid_point_lights(&self.device, &sprite_pts);
2765                // sun_flags bit0 = sun enabled, bit1 = sun casts shadow (XS.4.2),
2766                // bit2 = dynamic lighting active.
2767                let sprite_sun_flags = u32::from(sprite_sun_enabled)
2768                    | (u32::from(dl.sun_casts_shadow) << 1)
2769                    | (u32::from(dl.enabled) << 2);
2770                let uni = SpriteModelUniform {
2771                    cam_pos: cam.position,
2772                    _p0: 0.0,
2773                    cam_right: cam.right,
2774                    _p1: 0.0,
2775                    cam_down: cam.down,
2776                    _p2: 0.0,
2777                    cam_forward: cam.forward,
2778                    _p3: 0.0,
2779                    fog_color: [
2780                        self.fog_color[0],
2781                        self.fog_color[1],
2782                        self.fog_color[2],
2783                        self.fog_near,
2784                    ],
2785                    screen_size: [render_w, render_h],
2786                    instance_count: visible,
2787                    fog_far: self.fog_far,
2788                    fov_y_rad,
2789                    tiles_x,
2790                    tile_size: SPRITE_TILE_SIZE,
2791                    has_translucent: u32::from(self.sprite_has_translucent),
2792                    sun_dir: [
2793                        dl.world_sun_dir[0],
2794                        dl.world_sun_dir[1],
2795                        dl.world_sun_dir[2],
2796                        0.0,
2797                    ],
2798                    sun_color: [
2799                        dl.sun_color[0],
2800                        dl.sun_color[1],
2801                        dl.sun_color[2],
2802                        dl.sun_intensity,
2803                    ],
2804                    ambient_color: [dl.ambient[0], dl.ambient[1], dl.ambient[2], 0.0],
2805                    sun_flags: sprite_sun_flags,
2806                    point_light_count: sprite_point_count,
2807                    _pad_dl: [0; 2],
2808                    shadow_tint: [dl.shadow_tint[0], dl.shadow_tint[1], dl.shadow_tint[2], 0.0],
2809                    style_bands: dl.style_bands,
2810                    // XS.4.2 — sprite-shadow (receive) ABI, mirroring the scene
2811                    // pass. Only consulted when the device is sprite-shadow
2812                    // capable (the shadowed shader variant is built); otherwise
2813                    // the stub `sprite_shadow_occluded` ignores them.
2814                    occ_num_pages: scene.occupancy_num_pages,
2815                    occ_page_words: scene.occupancy_page_words,
2816                    grid_count: scene.grid_count,
2817                    max_outer_steps,
2818                    shadow_max_steps: dl.shadow_max_steps,
2819                    shadow_bias: dl.shadow_bias,
2820                    shadow_max_dist: dl.shadow_max_dist,
2821                    shadow_strength: dl.shadow_strength,
2822                    _pad_xs: [0; 3],
2823                };
2824                self.queue
2825                    .write_buffer(&smd.uniform_buf, 0, bytemuck::bytes_of(&uni));
2826                let mut sprite_entries = vec![
2827                    wgpu::BindGroupEntry {
2828                        binding: 0,
2829                        resource: smd.uniform_buf.as_entire_binding(),
2830                    },
2831                    wgpu::BindGroupEntry {
2832                        binding: 1,
2833                        resource: reg.occupancy.as_entire_binding(),
2834                    },
2835                    wgpu::BindGroupEntry {
2836                        binding: 2,
2837                        resource: reg.colors.as_entire_binding(),
2838                    },
2839                    wgpu::BindGroupEntry {
2840                        binding: 3,
2841                        resource: reg.color_offsets.as_entire_binding(),
2842                    },
2843                    wgpu::BindGroupEntry {
2844                        binding: 4,
2845                        resource: reg.model_meta.as_entire_binding(),
2846                    },
2847                    wgpu::BindGroupEntry {
2848                        binding: 5,
2849                        resource: reg.instances.as_entire_binding(),
2850                    },
2851                    wgpu::BindGroupEntry {
2852                        binding: 6,
2853                        resource: dda.depth_buffer.as_entire_binding(),
2854                    },
2855                    wgpu::BindGroupEntry {
2856                        binding: 7,
2857                        resource: dda.framebuffer.as_entire_binding(),
2858                    },
2859                    wgpu::BindGroupEntry {
2860                        binding: 8,
2861                        resource: reg.tile_ranges.as_entire_binding(),
2862                    },
2863                    wgpu::BindGroupEntry {
2864                        binding: 9,
2865                        resource: reg.tile_instances.as_entire_binding(),
2866                    },
2867                    wgpu::BindGroupEntry {
2868                        binding: 10,
2869                        resource: reg.dirs.as_entire_binding(),
2870                    },
2871                    wgpu::BindGroupEntry {
2872                        binding: 11,
2873                        resource: reg.colmul.as_entire_binding(),
2874                    },
2875                    wgpu::BindGroupEntry {
2876                        binding: 12,
2877                        resource: smd.materials_buf.as_entire_binding(),
2878                    },
2879                    wgpu::BindGroupEntry {
2880                        binding: 13,
2881                        resource: reg.materials_vox.as_entire_binding(),
2882                    },
2883                    // DL.7 — world point lights (15). (Binding 14 univec
2884                    // normal table dropped — face-normal lighting now.)
2885                    wgpu::BindGroupEntry {
2886                        binding: 15,
2887                        resource: sprite_point_buf.as_entire_binding(),
2888                    },
2889                ];
2890                // XS.4.2 — when capable, bind the terrain occupancy set (the
2891                // same resident buffers + the per-frame grid cameras the scene
2892                // pass uses) so sprite shadow rays march terrain. Must match
2893                // the BGL built in `build_sprite_model_dda`.
2894                if self.sprite_shadows_capable {
2895                    let terrain: [(u32, &wgpu::Buffer); 8] = [
2896                        (16, &scene.occupancy_pages[0]),
2897                        (17, &scene.occupancy_pages[1]),
2898                        (18, &scene.occupancy_pages[2]),
2899                        (19, &scene.occupancy_pages[3]),
2900                        (20, &scene.all_chunk_occupancy),
2901                        (21, &scene.all_slot_chunk_idx),
2902                        (22, &scene.grid_static_meta),
2903                        (23, &grid_cameras),
2904                    ];
2905                    for (binding, buf) in terrain {
2906                        sprite_entries.push(wgpu::BindGroupEntry {
2907                            binding,
2908                            resource: buf.as_entire_binding(),
2909                        });
2910                    }
2911                }
2912                Some(self.device.create_bind_group(&wgpu::BindGroupDescriptor {
2913                    label: Some("roxlap-gpu sprite_model_dda.bg"),
2914                    layout: &smd.bgl,
2915                    entries: &sprite_entries,
2916                }))
2917            }
2918            _ => None,
2919        };
2920
2921        let mut encoder = self
2922            .device
2923            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
2924                label: Some("roxlap-gpu scene encoder"),
2925            });
2926        {
2927            let mut cpass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
2928                label: Some("roxlap-gpu scene_dda compute"),
2929                timestamp_writes: None,
2930            });
2931            cpass.set_pipeline(&dda.pipeline_dda);
2932            cpass.set_bind_group(0, &dda_bg, &[]);
2933            cpass.dispatch_workgroups(render_w.div_ceil(8), render_h.div_ceil(8), 1);
2934        }
2935        // GPU.10 — sprite model-DDA pass: one thread per pixel marches
2936        // the tile's instances + composites against scene depth, after
2937        // the scene pass wrote the depth buffer and before the blit.
2938        if let (Some(smd), Some(bg)) = (&self.sprite_model_dda, &sprite_model_bg) {
2939            let mut cpass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
2940                label: Some("roxlap-gpu sprite_model_dda"),
2941                timestamp_writes: None,
2942            });
2943            cpass.set_pipeline(&smd.pipeline);
2944            cpass.set_bind_group(0, bg, &[]);
2945            cpass.dispatch_workgroups(render_w.div_ceil(8), render_h.div_ceil(8), 1);
2946        }
2947        // RP.1 — resolve pass: box-downfilter framebuffer(march) →
2948        // resolve_buf(logical). One thread per logical pixel. `ssaa == 1` ⇒
2949        // exact 1×1 copy (byte-identical), so the blit always reads resolve_buf.
2950        {
2951            let mut cpass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
2952                label: Some("roxlap-gpu scene_dda resolve"),
2953                timestamp_writes: None,
2954            });
2955            cpass.set_pipeline(&dda.pipeline_resolve);
2956            cpass.set_bind_group(0, &dda.resolve_bg, &[]);
2957            cpass.dispatch_workgroups(logical_w.div_ceil(8), logical_h.div_ceil(8), 1);
2958        }
2959        {
2960            let mut rpass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
2961                label: Some("roxlap-gpu scene_dda blit"),
2962                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
2963                    view: &surf_view,
2964                    depth_slice: None,
2965                    resolve_target: None,
2966                    ops: wgpu::Operations {
2967                        load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
2968                        store: wgpu::StoreOp::Store,
2969                    },
2970                })],
2971                depth_stencil_attachment: None,
2972                timestamp_writes: None,
2973                occlusion_query_set: None,
2974                multiview_mask: None,
2975            });
2976            rpass.set_pipeline(&dda.pipeline_blit);
2977            rpass.set_bind_group(0, &dda.blit_bg, &[]);
2978            rpass.draw(0..3, 0..1);
2979        }
2980        self.queue.submit(std::iter::once(encoder.finish()));
2981        // This frame wrote `scene_dda.depth_buffer`, so depth-tested
2982        // overlays may test against it.
2983        self.scene_depth_valid = true;
2984        // Deferred present — the host calls `present` or `paint_egui`.
2985        self.pending_frame = Some((surf_tex, surf_view));
2986        self.frame_count = self.frame_count.wrapping_add(1);
2987    }
2988
2989    /// Like [`Self::render`] (clear to colour) but **deferred**: stashes
2990    /// the frame for [`Self::present`] / [`Self::paint_egui`] instead of
2991    /// presenting. The facade uses this before any grid is resident so a
2992    /// HUD can still be painted over an empty scene.
2993    pub fn render_clear_deferred(&mut self) {
2994        // No scene pass this frame ⇒ `scene_dda.depth_buffer` (if it
2995        // exists from an earlier scene) is stale; depth-tested overlays
2996        // must not test against it.
2997        self.scene_depth_valid = false;
2998        self.pending_frame = None;
2999        let Some(surf_tex) = self.acquire_frame() else {
3000            return;
3001        };
3002        let view = surf_tex
3003            .texture
3004            .create_view(&wgpu::TextureViewDescriptor::default());
3005        let [r, g, b] = self.clear_colour;
3006        let mut encoder = self
3007            .device
3008            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
3009                label: Some("roxlap-gpu clear (deferred)"),
3010            });
3011        {
3012            let _rp = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
3013                label: Some("roxlap-gpu clear (deferred)"),
3014                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
3015                    view: &view,
3016                    depth_slice: None,
3017                    resolve_target: None,
3018                    ops: wgpu::Operations {
3019                        load: wgpu::LoadOp::Clear(wgpu::Color { r, g, b, a: 1.0 }),
3020                        store: wgpu::StoreOp::Store,
3021                    },
3022                })],
3023                depth_stencil_attachment: None,
3024                timestamp_writes: None,
3025                occlusion_query_set: None,
3026                multiview_mask: None,
3027            });
3028        }
3029        self.queue.submit(std::iter::once(encoder.finish()));
3030        self.pending_frame = Some((surf_tex, view));
3031    }
3032
3033    /// Present the frame stashed by the last deferred render
3034    /// ([`Self::render_scene`] / [`Self::render_clear_deferred`]). No-op
3035    /// if nothing is pending (e.g. the surface was lost mid-render).
3036    pub fn present(&mut self) {
3037        if let Some((surf_tex, _view)) = self.pending_frame.take() {
3038            surf_tex.present();
3039        }
3040    }
3041
3042    /// Block until the GPU has drained every submitted command (queue
3043    /// idle), dropping any not-yet-presented swapchain frame first. Call at
3044    /// shutdown — before the [`GpuRenderer`] (and its window) drop — so the
3045    /// device is torn down with no work in flight and no half-presented
3046    /// frame, instead of yanking the swapchain mid-submission (which leaves
3047    /// the driver/compositor compositing stale buffers — the "leftover
3048    /// triangles / flicker after an unclean exit" symptom). No-op on wasm
3049    /// (`poll(Wait)` is unavailable there; the browser reclaims the device).
3050    pub fn wait_idle(&mut self) {
3051        // Release the acquired-but-unpresented frame so its swapchain image
3052        // isn't held across teardown.
3053        self.pending_frame = None;
3054        #[cfg(not(target_arch = "wasm32"))]
3055        {
3056            self.device.poll(wgpu::PollType::wait_indefinitely()).ok();
3057        }
3058    }
3059
3060    /// Draw depth-tested world-space [`GpuLine`]s over the pending frame
3061    /// (L3.2). Projects each endpoint with `cam` (the marcher's pinhole) +
3062    /// the last frame's FOV / surface size, expands to screen-space quads,
3063    /// and runs a `LoadOp::Load` pass into the pending swapchain view — so
3064    /// the lines land on the marched frame and a later `present` /
3065    /// `paint_egui` still finishes it (the pending frame is left intact).
3066    /// Depth-tested lines are occluded by nearer marched geometry (compared
3067    /// against the scene-DDA depth buffer's `best_t`); call after `render`,
3068    /// before `present` / `paint_egui`. No-op if no frame is pending.
3069    pub fn draw_lines_deferred(&mut self, cam: &GpuLineCamera, lines: &[GpuLine]) {
3070        if self.pending_frame.is_none() || lines.is_empty() {
3071            return;
3072        }
3073        let (w, h) = (self.surface_config.width, self.surface_config.height);
3074        // RP.0 — project with the render (logical) aspect so the lines align
3075        // with the upscaled scene; the depth buffer is render-sized too.
3076        let (rw, rh) = self.render_dims();
3077        let fov = self.last_fov_y_rad;
3078        if w == 0 || h == 0 || fov <= 0.0 {
3079            return; // no frame marched yet — no projection to reuse
3080        }
3081        let verts = build_line_vertices(cam, lines, rw, rh, fov, self.flip_x);
3082        if verts.is_empty() {
3083            return;
3084        }
3085        self.ensure_line_resources();
3086        let res = self.line_resources.as_ref().expect("just built");
3087
3088        // Skip the depth test when there's no current scene depth to read —
3089        // either no buffer at all (sprite-only / never-rendered) or this
3090        // frame was a color-only clear so the buffer is stale (an empty
3091        // scene drawn after a grid scene). The 1-word dummy / stale buffer
3092        // is still bound to satisfy the layout; `no_depth = 1` keeps the
3093        // shader from indexing it.
3094        let no_depth = u32::from(self.scene_dda.is_none() || !self.scene_depth_valid);
3095        let params = LineParams {
3096            screen_w: w,
3097            screen_h: h,
3098            depth_bias: LINE_DEPTH_BIAS,
3099            no_depth,
3100            flip_x: u32::from(self.flip_x),
3101            depth_w: rw,
3102            depth_h: rh,
3103            _pad: 0,
3104        };
3105        self.queue
3106            .write_buffer(&res.uniform_buf, 0, bytemuck::bytes_of(&params));
3107
3108        let depth_resource = match &self.scene_dda {
3109            Some(dda) => dda.depth_buffer.as_entire_binding(),
3110            None => res.dummy_depth.as_entire_binding(),
3111        };
3112        let bg = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
3113            label: Some("roxlap-gpu line.bg"),
3114            layout: &res.bgl,
3115            entries: &[
3116                wgpu::BindGroupEntry {
3117                    binding: 0,
3118                    resource: res.uniform_buf.as_entire_binding(),
3119                },
3120                wgpu::BindGroupEntry {
3121                    binding: 1,
3122                    resource: depth_resource,
3123                },
3124            ],
3125        });
3126
3127        // Grow-only persistent vertex buffer (L3.3): one `write_buffer`
3128        // per overlay, reused across frames. Power-of-two capacity keeps
3129        // re-allocation rare as the segment count drifts.
3130        let needed = std::mem::size_of_val(verts.as_slice()) as u64;
3131        if self.line_vbuf_cap < needed {
3132            let cap = needed.next_power_of_two().max(4096);
3133            self.line_vbuf = Some(self.device.create_buffer(&wgpu::BufferDescriptor {
3134                label: Some("roxlap-gpu line.vbuf"),
3135                size: cap,
3136                usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
3137                mapped_at_creation: false,
3138            }));
3139            self.line_vbuf_cap = cap;
3140        }
3141        let vbuf = self.line_vbuf.as_ref().expect("ensured above");
3142        self.queue
3143            .write_buffer(vbuf, 0, bytemuck::cast_slice(&verts));
3144
3145        let view = &self.pending_frame.as_ref().expect("checked above").1;
3146        let mut encoder = self
3147            .device
3148            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
3149                label: Some("roxlap-gpu lines"),
3150            });
3151        {
3152            // `LoadOp::Load` keeps the marcher's frame; the lines draw over
3153            // it. Manual depth test in the FS (no depth-stencil attachment).
3154            let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
3155                label: Some("roxlap-gpu line paint"),
3156                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
3157                    view,
3158                    depth_slice: None,
3159                    resolve_target: None,
3160                    ops: wgpu::Operations {
3161                        load: wgpu::LoadOp::Load,
3162                        store: wgpu::StoreOp::Store,
3163                    },
3164                })],
3165                depth_stencil_attachment: None,
3166                timestamp_writes: None,
3167                occlusion_query_set: None,
3168                multiview_mask: None,
3169            });
3170            pass.set_pipeline(&res.pipeline);
3171            pass.set_bind_group(0, &bg, &[]);
3172            pass.set_vertex_buffer(0, vbuf.slice(..));
3173            pass.draw(0..verts.len() as u32, 0..1);
3174        }
3175        self.queue.submit(std::iter::once(encoder.finish()));
3176        // pending_frame left intact — present/paint_egui finishes the frame.
3177    }
3178
3179    /// Lazy-build the [`LineResources`] (`line.wgsl` pipeline + uniform +
3180    /// dummy depth buffer). The colour target uses the surface format with
3181    /// straight-alpha over-blending; no depth-stencil attachment (the depth
3182    /// test is manual in the fragment shader against the scene depth buffer).
3183    fn ensure_line_resources(&mut self) {
3184        if self.line_resources.is_some() {
3185            return;
3186        }
3187        let shader = self
3188            .device
3189            .create_shader_module(wgpu::ShaderModuleDescriptor {
3190                label: Some("line.wgsl"),
3191                source: wgpu::ShaderSource::Wgsl(include_str!("../shaders/line.wgsl").into()),
3192            });
3193        let bgl = self
3194            .device
3195            .create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
3196                label: Some("roxlap-gpu line.bgl"),
3197                entries: &[
3198                    wgpu::BindGroupLayoutEntry {
3199                        binding: 0,
3200                        visibility: wgpu::ShaderStages::FRAGMENT,
3201                        ty: wgpu::BindingType::Buffer {
3202                            ty: wgpu::BufferBindingType::Uniform,
3203                            has_dynamic_offset: false,
3204                            min_binding_size: None,
3205                        },
3206                        count: None,
3207                    },
3208                    wgpu::BindGroupLayoutEntry {
3209                        binding: 1,
3210                        visibility: wgpu::ShaderStages::FRAGMENT,
3211                        ty: wgpu::BindingType::Buffer {
3212                            ty: wgpu::BufferBindingType::Storage { read_only: true },
3213                            has_dynamic_offset: false,
3214                            min_binding_size: None,
3215                        },
3216                        count: None,
3217                    },
3218                ],
3219            });
3220        let layout = self
3221            .device
3222            .create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
3223                label: Some("roxlap-gpu line.layout"),
3224                bind_group_layouts: &[Some(&bgl)],
3225                immediate_size: 0,
3226            });
3227        let pipeline = self
3228            .device
3229            .create_render_pipeline(&wgpu::RenderPipelineDescriptor {
3230                label: Some("roxlap-gpu line.pipeline"),
3231                layout: Some(&layout),
3232                vertex: wgpu::VertexState {
3233                    module: &shader,
3234                    entry_point: Some("vs_main"),
3235                    compilation_options: wgpu::PipelineCompilationOptions::default(),
3236                    buffers: &[wgpu::VertexBufferLayout {
3237                        array_stride: std::mem::size_of::<LineVertex>() as u64,
3238                        step_mode: wgpu::VertexStepMode::Vertex,
3239                        attributes: &wgpu::vertex_attr_array![
3240                            0 => Float32x2, // pos (NDC)
3241                            1 => Float32,   // depth
3242                            2 => Float32,   // depth_test
3243                            3 => Float32x4, // color
3244                        ],
3245                    }],
3246                },
3247                fragment: Some(wgpu::FragmentState {
3248                    module: &shader,
3249                    entry_point: Some("fs_main"),
3250                    compilation_options: wgpu::PipelineCompilationOptions::default(),
3251                    targets: &[Some(wgpu::ColorTargetState {
3252                        format: self.surface_config.format,
3253                        blend: Some(wgpu::BlendState::ALPHA_BLENDING),
3254                        write_mask: wgpu::ColorWrites::ALL,
3255                    })],
3256                }),
3257                primitive: wgpu::PrimitiveState {
3258                    cull_mode: None,
3259                    ..Default::default()
3260                },
3261                depth_stencil: None,
3262                multisample: wgpu::MultisampleState::default(),
3263                multiview_mask: None,
3264                cache: None,
3265            });
3266        let uniform_buf = self.device.create_buffer(&wgpu::BufferDescriptor {
3267            label: Some("roxlap-gpu line.uniform"),
3268            size: std::mem::size_of::<LineParams>() as u64,
3269            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
3270            mapped_at_creation: false,
3271        });
3272        let dummy_depth = self.device.create_buffer(&wgpu::BufferDescriptor {
3273            label: Some("roxlap-gpu line.dummy_depth"),
3274            size: 4,
3275            usage: wgpu::BufferUsages::STORAGE,
3276            mapped_at_creation: false,
3277        });
3278        self.line_resources = Some(LineResources {
3279            pipeline,
3280            bgl,
3281            uniform_buf,
3282            dummy_depth,
3283        });
3284    }
3285
3286    /// Upload (or replace) an RGBA8 image as a sampled texture, returning
3287    /// a stable id for [`GpuImageQuad::image`]. `rgba` is row-major,
3288    /// `width * height * 4` bytes, straight (un-premultiplied) alpha.
3289    /// Reuses a dropped slot when one exists. Returns `0` for malformed
3290    /// input (an id that draws nothing).
3291    pub fn upload_image(&mut self, rgba: &[u8], width: u32, height: u32) -> usize {
3292        if width == 0 || height == 0 || rgba.len() != (width as usize) * (height as usize) * 4 {
3293            return 0;
3294        }
3295        let texture = self.device.create_texture(&wgpu::TextureDescriptor {
3296            label: Some("roxlap-gpu image_sprite"),
3297            size: wgpu::Extent3d {
3298                width,
3299                height,
3300                depth_or_array_layers: 1,
3301            },
3302            mip_level_count: 1,
3303            sample_count: 1,
3304            dimension: wgpu::TextureDimension::D2,
3305            format: wgpu::TextureFormat::Rgba8Unorm,
3306            usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
3307            view_formats: &[],
3308        });
3309        self.queue.write_texture(
3310            wgpu::TexelCopyTextureInfo {
3311                texture: &texture,
3312                mip_level: 0,
3313                origin: wgpu::Origin3d::ZERO,
3314                aspect: wgpu::TextureAspect::All,
3315            },
3316            rgba,
3317            wgpu::TexelCopyBufferLayout {
3318                offset: 0,
3319                bytes_per_row: Some(width * 4),
3320                rows_per_image: Some(height),
3321            },
3322            wgpu::Extent3d {
3323                width,
3324                height,
3325                depth_or_array_layers: 1,
3326            },
3327        );
3328        let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
3329        let resident = ImageResident {
3330            view,
3331            _texture: texture,
3332        };
3333        if let Some(slot) = self.images.iter().position(Option::is_none) {
3334            self.images[slot] = Some(resident);
3335            slot
3336        } else {
3337            self.images.push(Some(resident));
3338            self.images.len() - 1
3339        }
3340    }
3341
3342    /// Release an image uploaded with [`Self::upload_image`] (the slot
3343    /// becomes reusable).
3344    pub fn drop_image(&mut self, id: usize) {
3345        if let Some(slot) = self.images.get_mut(id) {
3346            *slot = None;
3347        }
3348    }
3349
3350    /// Draw world-space 2D image sprites ([`GpuImageQuad`]) over the
3351    /// pending frame — the textured-quad sibling of
3352    /// [`Self::draw_lines_deferred`]. Projects each quad with `cam` (the
3353    /// marcher's pinhole) + the last frame's FOV / surface size, expands +
3354    /// near-clips to triangles, and runs one `LoadOp::Load` pass with a
3355    /// draw per quad (each binds its own texture). UVs are perspective-correct;
3356    /// depth-tested quads are occluded by nearer marched geometry. Call
3357    /// after `render`, before `present` / `paint_egui`. No-op if no frame
3358    /// is pending.
3359    pub fn draw_images_deferred(&mut self, cam: &GpuLineCamera, quads: &[GpuImageQuad]) {
3360        if self.pending_frame.is_none() || quads.is_empty() {
3361            return;
3362        }
3363        let (w, h) = (self.surface_config.width, self.surface_config.height);
3364        // RP.0 — project with the render (logical) aspect (see
3365        // `draw_lines_deferred`); depth buffer is render-sized.
3366        let (rw, rh) = self.render_dims();
3367        let fov = self.last_fov_y_rad;
3368        if w == 0 || h == 0 || fov <= 0.0 {
3369            return;
3370        }
3371
3372        // Concatenate every quad's verts into one buffer, recording each
3373        // quad's (range, texture) so they share a single render pass.
3374        let mut verts: Vec<ImageVertex> = Vec::new();
3375        let mut draws: Vec<(u32, u32, usize)> = Vec::new();
3376        for quad in quads {
3377            if !matches!(self.images.get(quad.image), Some(Some(_))) {
3378                continue; // dropped / never-uploaded id
3379            }
3380            let v = build_image_vertices(cam, quad, rw, rh, fov, self.flip_x);
3381            if v.is_empty() {
3382                continue;
3383            }
3384            let start = verts.len() as u32;
3385            verts.extend_from_slice(&v);
3386            draws.push((start, verts.len() as u32, quad.image));
3387        }
3388        if draws.is_empty() {
3389            return;
3390        }
3391
3392        self.ensure_image_resources();
3393        // See `draw_lines_deferred`: skip depth when there's no valid
3394        // current-frame scene depth (none built, or a color-only clear).
3395        let no_depth = u32::from(self.scene_dda.is_none() || !self.scene_depth_valid);
3396        let params = LineParams {
3397            screen_w: w,
3398            screen_h: h,
3399            depth_bias: LINE_DEPTH_BIAS,
3400            no_depth,
3401            flip_x: u32::from(self.flip_x),
3402            depth_w: rw,
3403            depth_h: rh,
3404            _pad: 0,
3405        };
3406        {
3407            let res = self.image_resources.as_ref().expect("just built");
3408            self.queue
3409                .write_buffer(&res.uniform_buf, 0, bytemuck::bytes_of(&params));
3410        }
3411
3412        // Grow-only persistent vertex buffer (mirrors the line vbuf).
3413        let needed = std::mem::size_of_val(verts.as_slice()) as u64;
3414        if self.image_vbuf_cap < needed {
3415            let cap = needed.next_power_of_two().max(4096);
3416            self.image_vbuf = Some(self.device.create_buffer(&wgpu::BufferDescriptor {
3417                label: Some("roxlap-gpu image.vbuf"),
3418                size: cap,
3419                usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
3420                mapped_at_creation: false,
3421            }));
3422            self.image_vbuf_cap = cap;
3423        }
3424        let vbuf = self.image_vbuf.as_ref().expect("ensured above");
3425        self.queue
3426            .write_buffer(vbuf, 0, bytemuck::cast_slice(&verts));
3427
3428        // One bind group per draw (the texture view differs per quad).
3429        let res = self.image_resources.as_ref().expect("just built");
3430        let depth_resource = match &self.scene_dda {
3431            Some(dda) => dda.depth_buffer.as_entire_binding(),
3432            None => res.dummy_depth.as_entire_binding(),
3433        };
3434        let bind_groups: Vec<wgpu::BindGroup> = draws
3435            .iter()
3436            .map(|&(_, _, image_id)| {
3437                let resident = self.images[image_id].as_ref().expect("checked present");
3438                self.device.create_bind_group(&wgpu::BindGroupDescriptor {
3439                    label: Some("roxlap-gpu image.bg"),
3440                    layout: &res.bgl,
3441                    entries: &[
3442                        wgpu::BindGroupEntry {
3443                            binding: 0,
3444                            resource: res.uniform_buf.as_entire_binding(),
3445                        },
3446                        wgpu::BindGroupEntry {
3447                            binding: 1,
3448                            resource: depth_resource.clone(),
3449                        },
3450                        wgpu::BindGroupEntry {
3451                            binding: 2,
3452                            resource: wgpu::BindingResource::TextureView(&resident.view),
3453                        },
3454                        wgpu::BindGroupEntry {
3455                            binding: 3,
3456                            resource: wgpu::BindingResource::Sampler(&res.sampler),
3457                        },
3458                    ],
3459                })
3460            })
3461            .collect();
3462
3463        let view = &self.pending_frame.as_ref().expect("checked above").1;
3464        let mut encoder = self
3465            .device
3466            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
3467                label: Some("roxlap-gpu images"),
3468            });
3469        {
3470            let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
3471                label: Some("roxlap-gpu image paint"),
3472                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
3473                    view,
3474                    depth_slice: None,
3475                    resolve_target: None,
3476                    ops: wgpu::Operations {
3477                        load: wgpu::LoadOp::Load,
3478                        store: wgpu::StoreOp::Store,
3479                    },
3480                })],
3481                depth_stencil_attachment: None,
3482                timestamp_writes: None,
3483                occlusion_query_set: None,
3484                multiview_mask: None,
3485            });
3486            pass.set_pipeline(&res.pipeline);
3487            pass.set_vertex_buffer(0, vbuf.slice(..));
3488            for (&(start, end, _), bg) in draws.iter().zip(&bind_groups) {
3489                pass.set_bind_group(0, bg, &[]);
3490                pass.draw(start..end, 0..1);
3491            }
3492        }
3493        self.queue.submit(std::iter::once(encoder.finish()));
3494        // pending_frame left intact — present/paint_egui finishes it.
3495    }
3496
3497    /// Lazy-build the [`ImageResources`] (`image.wgsl` pipeline + uniform +
3498    /// nearest sampler + dummy depth). Straight-alpha over-blend, no
3499    /// depth-stencil attachment (the depth test is manual in the FS).
3500    fn ensure_image_resources(&mut self) {
3501        if self.image_resources.is_some() {
3502            return;
3503        }
3504        let shader = self
3505            .device
3506            .create_shader_module(wgpu::ShaderModuleDescriptor {
3507                label: Some("image.wgsl"),
3508                source: wgpu::ShaderSource::Wgsl(include_str!("../shaders/image.wgsl").into()),
3509            });
3510        let bgl = self
3511            .device
3512            .create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
3513                label: Some("roxlap-gpu image.bgl"),
3514                entries: &[
3515                    wgpu::BindGroupLayoutEntry {
3516                        binding: 0,
3517                        visibility: wgpu::ShaderStages::FRAGMENT,
3518                        ty: wgpu::BindingType::Buffer {
3519                            ty: wgpu::BufferBindingType::Uniform,
3520                            has_dynamic_offset: false,
3521                            min_binding_size: None,
3522                        },
3523                        count: None,
3524                    },
3525                    wgpu::BindGroupLayoutEntry {
3526                        binding: 1,
3527                        visibility: wgpu::ShaderStages::FRAGMENT,
3528                        ty: wgpu::BindingType::Buffer {
3529                            ty: wgpu::BufferBindingType::Storage { read_only: true },
3530                            has_dynamic_offset: false,
3531                            min_binding_size: None,
3532                        },
3533                        count: None,
3534                    },
3535                    wgpu::BindGroupLayoutEntry {
3536                        binding: 2,
3537                        visibility: wgpu::ShaderStages::FRAGMENT,
3538                        ty: wgpu::BindingType::Texture {
3539                            sample_type: wgpu::TextureSampleType::Float { filterable: true },
3540                            view_dimension: wgpu::TextureViewDimension::D2,
3541                            multisampled: false,
3542                        },
3543                        count: None,
3544                    },
3545                    wgpu::BindGroupLayoutEntry {
3546                        binding: 3,
3547                        visibility: wgpu::ShaderStages::FRAGMENT,
3548                        ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
3549                        count: None,
3550                    },
3551                ],
3552            });
3553        let layout = self
3554            .device
3555            .create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
3556                label: Some("roxlap-gpu image.layout"),
3557                bind_group_layouts: &[Some(&bgl)],
3558                immediate_size: 0,
3559            });
3560        let pipeline = self
3561            .device
3562            .create_render_pipeline(&wgpu::RenderPipelineDescriptor {
3563                label: Some("roxlap-gpu image.pipeline"),
3564                layout: Some(&layout),
3565                vertex: wgpu::VertexState {
3566                    module: &shader,
3567                    entry_point: Some("vs_main"),
3568                    compilation_options: wgpu::PipelineCompilationOptions::default(),
3569                    buffers: &[wgpu::VertexBufferLayout {
3570                        array_stride: std::mem::size_of::<ImageVertex>() as u64,
3571                        step_mode: wgpu::VertexStepMode::Vertex,
3572                        attributes: &wgpu::vertex_attr_array![
3573                            0 => Float32x2, // ndc
3574                            1 => Float32,   // w
3575                            2 => Float32,   // depth
3576                            3 => Float32,   // depth_test
3577                            4 => Float32,   // cutoff
3578                            5 => Float32x2, // uv
3579                            6 => Float32x4, // tint
3580                        ],
3581                    }],
3582                },
3583                fragment: Some(wgpu::FragmentState {
3584                    module: &shader,
3585                    entry_point: Some("fs_main"),
3586                    compilation_options: wgpu::PipelineCompilationOptions::default(),
3587                    targets: &[Some(wgpu::ColorTargetState {
3588                        format: self.surface_config.format,
3589                        blend: Some(wgpu::BlendState::ALPHA_BLENDING),
3590                        write_mask: wgpu::ColorWrites::ALL,
3591                    })],
3592                }),
3593                primitive: wgpu::PrimitiveState {
3594                    cull_mode: None,
3595                    ..Default::default()
3596                },
3597                depth_stencil: None,
3598                multisample: wgpu::MultisampleState::default(),
3599                multiview_mask: None,
3600                cache: None,
3601            });
3602        let uniform_buf = self.device.create_buffer(&wgpu::BufferDescriptor {
3603            label: Some("roxlap-gpu image.uniform"),
3604            size: std::mem::size_of::<LineParams>() as u64,
3605            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
3606            mapped_at_creation: false,
3607        });
3608        let dummy_depth = self.device.create_buffer(&wgpu::BufferDescriptor {
3609            label: Some("roxlap-gpu image.dummy_depth"),
3610            size: 4,
3611            usage: wgpu::BufferUsages::STORAGE,
3612            mapped_at_creation: false,
3613        });
3614        let sampler = self.device.create_sampler(&wgpu::SamplerDescriptor {
3615            label: Some("roxlap-gpu image.sampler"),
3616            // Nearest + clamp: pixel-art references want crisp texels and
3617            // no wrap bleed at the quad edges.
3618            address_mode_u: wgpu::AddressMode::ClampToEdge,
3619            address_mode_v: wgpu::AddressMode::ClampToEdge,
3620            address_mode_w: wgpu::AddressMode::ClampToEdge,
3621            mag_filter: wgpu::FilterMode::Nearest,
3622            min_filter: wgpu::FilterMode::Nearest,
3623            mipmap_filter: wgpu::MipmapFilterMode::Nearest,
3624            ..Default::default()
3625        });
3626        self.image_resources = Some(ImageResources {
3627            pipeline,
3628            bgl,
3629            uniform_buf,
3630            dummy_depth,
3631            sampler,
3632        });
3633    }
3634
3635    /// Project a world point to window pixels under the marcher's
3636    /// vertical-FOV pinhole (the inverse of [`Self::pixel_ray`]), using
3637    /// the last-rendered frame's size + FOV. `None` before the first
3638    /// scene render or for a point at/behind the near plane.
3639    #[must_use]
3640    pub fn project_point(
3641        &self,
3642        cam_pos: [f32; 3],
3643        right: [f32; 3],
3644        down: [f32; 3],
3645        forward: [f32; 3],
3646        world: [f32; 3],
3647    ) -> Option<(f32, f32)> {
3648        let dda = self.scene_dda.as_ref()?;
3649        let (w, h) = dda.storage_size;
3650        if w == 0 || h == 0 || self.last_fov_y_rad <= 0.0 {
3651            return None;
3652        }
3653        let d = [
3654            world[0] - cam_pos[0],
3655            world[1] - cam_pos[1],
3656            world[2] - cam_pos[2],
3657        ];
3658        let cz = forward[0] * d[0] + forward[1] * d[1] + forward[2] * d[2];
3659        if cz < LINE_NEAR_Z {
3660            return None;
3661        }
3662        let cx = right[0] * d[0] + right[1] * d[1] + right[2] * d[2];
3663        let cy = down[0] * d[0] + down[1] * d[1] + down[2] * d[2];
3664        let half_h = (self.last_fov_y_rad * 0.5).tan();
3665        let half_w = half_h * (w as f32 / h as f32);
3666        let ndc_x = (cx / cz) / half_w;
3667        let ndc_y = -(cy / cz) / half_h;
3668        let sx = (ndc_x * 0.5 + 0.5) * w as f32;
3669        let sy = (0.5 - ndc_y * 0.5) * h as f32;
3670        Some((sx, sy))
3671    }
3672
3673    /// Overlay an `egui` UI on the pending frame, then present it
3674    /// (`hud` feature). `jobs` are the host's tessellated primitives
3675    /// (`egui::Context::tessellate`), `textures` the per-frame texture
3676    /// delta from `egui::FullOutput`, `pixels_per_point` the UI scale.
3677    ///
3678    /// Draws with `LoadOp::Load` over the marcher's frame (a separate
3679    /// encoder submitted after the scene's), so the UI composites on top
3680    /// of the world. No-op if no frame is pending.
3681    #[cfg(feature = "hud")]
3682    pub fn paint_egui(
3683        &mut self,
3684        jobs: &[egui::ClippedPrimitive],
3685        textures: &egui::TexturesDelta,
3686        pixels_per_point: f32,
3687    ) {
3688        let Some((surf_tex, surf_view)) = self.pending_frame.take() else {
3689            return;
3690        };
3691        let format = self.surface_config.format;
3692        let egui_rend = self.egui_renderer.get_or_insert_with(|| {
3693            egui_wgpu::Renderer::new(
3694                &self.device,
3695                format,
3696                egui_wgpu::RendererOptions {
3697                    msaa_samples: 1,
3698                    depth_stencil_format: None,
3699                    dithering: false,
3700                    ..Default::default()
3701                },
3702            )
3703        });
3704
3705        let screen = egui_wgpu::ScreenDescriptor {
3706            size_in_pixels: [self.surface_config.width, self.surface_config.height],
3707            pixels_per_point,
3708        };
3709        for (id, delta) in &textures.set {
3710            egui_rend.update_texture(&self.device, &self.queue, *id, delta);
3711        }
3712        let mut encoder = self
3713            .device
3714            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
3715                label: Some("roxlap-gpu egui"),
3716            });
3717        let user_bufs =
3718            egui_rend.update_buffers(&self.device, &self.queue, &mut encoder, jobs, &screen);
3719        {
3720            // `LoadOp::Load` keeps the marcher's frame; egui draws over it.
3721            let mut pass = encoder
3722                .begin_render_pass(&wgpu::RenderPassDescriptor {
3723                    label: Some("roxlap-gpu egui paint"),
3724                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
3725                        view: &surf_view,
3726                        depth_slice: None,
3727                        resolve_target: None,
3728                        ops: wgpu::Operations {
3729                            load: wgpu::LoadOp::Load,
3730                            store: wgpu::StoreOp::Store,
3731                        },
3732                    })],
3733                    depth_stencil_attachment: None,
3734                    timestamp_writes: None,
3735                    occlusion_query_set: None,
3736                    multiview_mask: None,
3737                })
3738                // egui-wgpu 0.29 requires a `'static` pass (see its docs).
3739                .forget_lifetime();
3740            egui_rend.render(&mut pass, jobs, &screen);
3741        }
3742        for id in &textures.free {
3743            egui_rend.free_texture(id);
3744        }
3745        self.queue.submit(
3746            user_bufs
3747                .into_iter()
3748                .chain(std::iter::once(encoder.finish())),
3749        );
3750        surf_tex.present();
3751    }
3752
3753    fn build_scene_dda(
3754        &self,
3755        width: u32,
3756        height: u32,
3757        logical_w: u32,
3758        logical_h: u32,
3759        surface_w: u32,
3760        surface_h: u32,
3761        surface_format: wgpu::TextureFormat,
3762    ) -> SceneDdaResources {
3763        // `width`/`height` are the **march** size (`logical × ssaa`) — the
3764        // scene + sprite + depth passes run at it. `logical_*` is the resolved
3765        // (retro) grid the resolve pass downfilters into and the blit reads.
3766        // `surface_*` is the swapchain the blit upscales onto. Framebuffer is a
3767        // packed-`rgba8unorm` storage buffer (row stride = march `width`).
3768        let framebuffer = self.device.create_buffer(&wgpu::BufferDescriptor {
3769            label: Some("roxlap-gpu scene_dda.framebuffer"),
3770            size: u64::from(width) * u64::from(height) * 4,
3771            usage: wgpu::BufferUsages::STORAGE,
3772            mapped_at_creation: false,
3773        });
3774        // RP.1 — logical-resolution buffer the resolve pass writes; the blit
3775        // reads it (so the blit src is the *logical* size, not the march size).
3776        let resolve_buf = self.device.create_buffer(&wgpu::BufferDescriptor {
3777            label: Some("roxlap-gpu scene_dda.resolve_buf"),
3778            size: u64::from(logical_w) * u64::from(logical_h) * 4,
3779            usage: wgpu::BufferUsages::STORAGE,
3780            mapped_at_creation: false,
3781        });
3782        // Resolve uniform: `[src(march) w,h, dst(logical) w,h, ssaa,
3783        // levels r,g,b, dither, pad×3]` (48 B). Dims+ssaa written here; the
3784        // posterize fields (offset 20) are re-written per frame in render_scene.
3785        let resolve_dims = self.device.create_buffer(&wgpu::BufferDescriptor {
3786            label: Some("roxlap-gpu scene_dda.resolve_dims"),
3787            size: 48,
3788            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
3789            mapped_at_creation: false,
3790        });
3791        self.queue.write_buffer(
3792            &resolve_dims,
3793            0,
3794            bytemuck::bytes_of(&[width, height, logical_w, logical_h, self.ssaa]),
3795        );
3796        // Blit uniform `Dims`: logical (src) size, swapchain (dst) size, then
3797        // `flip_x` + pad (RP.0 nearest upscale). The flip flag (offset 16) is
3798        // re-written per frame in `render_scene`; a render/surface resize
3799        // forces a full rebuild, so the sizes only need writing here.
3800        let blit_dims = self.device.create_buffer(&wgpu::BufferDescriptor {
3801            label: Some("roxlap-gpu scene_dda.blit_dims"),
3802            size: 32,
3803            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
3804            mapped_at_creation: false,
3805        });
3806        self.queue.write_buffer(
3807            &blit_dims,
3808            0,
3809            bytemuck::bytes_of(&[
3810                logical_w,
3811                logical_h,
3812                surface_w,
3813                surface_h,
3814                u32::from(self.flip_x),
3815                0u32,
3816                0u32,
3817                0u32,
3818            ]),
3819        );
3820
3821        let uniform_buf = self.device.create_buffer(&wgpu::BufferDescriptor {
3822            label: Some("roxlap-gpu scene_dda.uniform"),
3823            size: std::mem::size_of::<SceneDdaUniform>() as u64,
3824            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
3825            mapped_at_creation: false,
3826        });
3827
3828        // GPU.9 — per-pixel world-t depth (f32 bits as u32). Sized to
3829        // the storage texture; written by the scene pass when sprites
3830        // are active, read+tested by the sprite splatter.
3831        let depth_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
3832            label: Some("roxlap-gpu scene_dda.depth"),
3833            size: u64::from(width) * u64::from(height) * 4,
3834            // COPY_SRC so `read_depth_pixel` can stage it for picking.
3835            usage: wgpu::BufferUsages::STORAGE
3836                | wgpu::BufferUsages::COPY_DST
3837                | wgpu::BufferUsages::COPY_SRC,
3838            mapped_at_creation: false,
3839        });
3840        let depth_readback = self.device.create_buffer(&wgpu::BufferDescriptor {
3841            label: Some("roxlap-gpu scene_dda.depth_readback"),
3842            size: u64::from(width) * u64::from(height) * 4,
3843            usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
3844            mapped_at_creation: false,
3845        });
3846        // XS.4.3 — on sprite-shadow-capable devices, splice the sprite-cast
3847        // snippet over the `sprites_occlude` stub (binds the sprite registry at
3848        // 19..21 so terrain shadow rays test sprite volumes).
3849        let capable = self.sprite_shadows_capable;
3850        let dda_shader = self
3851            .device
3852            .create_shader_module(wgpu::ShaderModuleDescriptor {
3853                label: Some("scene_dda.wgsl"),
3854                source: wgpu::ShaderSource::Wgsl(scene_shader_source(capable).into()),
3855            });
3856        let mut dda_entries = vec![
3857            bgl_uniform_entry(0),
3858            bgl_storage_entry(1, true),
3859            bgl_storage_entry(2, true),
3860            bgl_storage_entry(3, true),
3861            bgl_storage_entry(4, true),
3862            bgl_storage_entry(5, true),
3863            bgl_storage_entry(6, true),
3864            bgl_storage_entry(7, true),
3865            // Framebuffer storage buffer (read-write; the scene +
3866            // sprite passes write packed pixels into it).
3867            bgl_storage_entry(8, false),
3868            // GPU.8 sky panorama + sampler.
3869            wgpu::BindGroupLayoutEntry {
3870                binding: 9,
3871                visibility: wgpu::ShaderStages::COMPUTE,
3872                ty: wgpu::BindingType::Texture {
3873                    sample_type: wgpu::TextureSampleType::Float { filterable: true },
3874                    view_dimension: wgpu::TextureViewDimension::D2,
3875                    multisampled: false,
3876                },
3877                count: None,
3878            },
3879            wgpu::BindGroupLayoutEntry {
3880                binding: 10,
3881                visibility: wgpu::ShaderStages::COMPUTE,
3882                ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
3883                count: None,
3884            },
3885            // GPU.9 — read-write per-pixel depth buffer.
3886            bgl_storage_entry(11, false),
3887            // Occupancy pages 1..MAX_OCC_PAGES (page 0 is
3888            // binding 1). Unused pages bind a dummy buffer.
3889            bgl_storage_entry(12, true),
3890            bgl_storage_entry(13, true),
3891            bgl_storage_entry(14, true),
3892            // Per-grid cameras (runtime-sized; one per grid).
3893            bgl_storage_entry(15, true),
3894            // TV.6 — material palette + terrain colour→material map.
3895            bgl_storage_entry(16, true),
3896            bgl_storage_entry(17, true),
3897            // DL — per-grid point lights (18). Sun dir rides in
3898            // PerGridCamera (binding 15) to stay within the 16
3899            // storage-buffer limit.
3900            bgl_storage_entry(18, true),
3901        ];
3902        if capable {
3903            // XS.4.3 — sprite registry for the sprite-cast shadow march.
3904            dda_entries.push(bgl_storage_entry(19, true)); // sprite_instances
3905            dda_entries.push(bgl_storage_entry(20, true)); // sprite_models
3906            dda_entries.push(bgl_storage_entry(21, true)); // sprite_occupancy
3907        }
3908        let bgl_dda = self
3909            .device
3910            .create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
3911                label: Some("roxlap-gpu scene_dda.bgl"),
3912                entries: &dda_entries,
3913            });
3914        let dda_pl = self
3915            .device
3916            .create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
3917                label: Some("roxlap-gpu scene_dda.layout"),
3918                bind_group_layouts: &[Some(&bgl_dda)],
3919                immediate_size: 0,
3920            });
3921        let pipeline_dda = self
3922            .device
3923            .create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
3924                label: Some("roxlap-gpu scene_dda.pipeline"),
3925                layout: Some(&dda_pl),
3926                module: &dda_shader,
3927                entry_point: Some("render_scene"),
3928                compilation_options: wgpu::PipelineCompilationOptions::default(),
3929                cache: None,
3930            });
3931
3932        // RP.1 — box-downfilter resolve pass (framebuffer march → resolve_buf
3933        // logical). `ssaa == 1` is a 1×1 copy; the blit always reads resolve_buf.
3934        let resolve_shader = self
3935            .device
3936            .create_shader_module(wgpu::ShaderModuleDescriptor {
3937                label: Some("scene_resolve.wgsl"),
3938                source: wgpu::ShaderSource::Wgsl(
3939                    include_str!("../shaders/scene_resolve.wgsl").into(),
3940                ),
3941            });
3942        let bgl_resolve = self
3943            .device
3944            .create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
3945                label: Some("roxlap-gpu scene_dda.resolve_bgl"),
3946                entries: &[
3947                    bgl_storage_entry(0, true),  // src framebuffer (read)
3948                    bgl_storage_entry(1, false), // dst resolve_buf (read-write)
3949                    bgl_uniform_entry(2),        // resolve dims
3950                ],
3951            });
3952        let resolve_pl = self
3953            .device
3954            .create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
3955                label: Some("roxlap-gpu scene_dda.resolve_layout"),
3956                bind_group_layouts: &[Some(&bgl_resolve)],
3957                immediate_size: 0,
3958            });
3959        let pipeline_resolve =
3960            self.device
3961                .create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
3962                    label: Some("roxlap-gpu scene_dda.resolve_pipeline"),
3963                    layout: Some(&resolve_pl),
3964                    module: &resolve_shader,
3965                    entry_point: Some("main"),
3966                    compilation_options: wgpu::PipelineCompilationOptions::default(),
3967                    cache: None,
3968                });
3969        let resolve_bg = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
3970            label: Some("roxlap-gpu scene_dda.resolve_bg"),
3971            layout: &bgl_resolve,
3972            entries: &[
3973                wgpu::BindGroupEntry {
3974                    binding: 0,
3975                    resource: framebuffer.as_entire_binding(),
3976                },
3977                wgpu::BindGroupEntry {
3978                    binding: 1,
3979                    resource: resolve_buf.as_entire_binding(),
3980                },
3981                wgpu::BindGroupEntry {
3982                    binding: 2,
3983                    resource: resolve_dims.as_entire_binding(),
3984                },
3985            ],
3986        });
3987
3988        let blit_shader = self
3989            .device
3990            .create_shader_module(wgpu::ShaderModuleDescriptor {
3991                label: Some("scene_blit.wgsl"),
3992                source: wgpu::ShaderSource::Wgsl(include_str!("../shaders/scene_blit.wgsl").into()),
3993            });
3994        let bgl_blit = self
3995            .device
3996            .create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
3997                label: Some("roxlap-gpu scene_dda.blit_bgl"),
3998                entries: &[
3999                    // Framebuffer storage buffer (read-only in the blit).
4000                    wgpu::BindGroupLayoutEntry {
4001                        binding: 0,
4002                        visibility: wgpu::ShaderStages::FRAGMENT,
4003                        ty: wgpu::BindingType::Buffer {
4004                            ty: wgpu::BufferBindingType::Storage { read_only: true },
4005                            has_dynamic_offset: false,
4006                            min_binding_size: None,
4007                        },
4008                        count: None,
4009                    },
4010                    // Screen-size uniform for the pixel→index math.
4011                    wgpu::BindGroupLayoutEntry {
4012                        binding: 1,
4013                        visibility: wgpu::ShaderStages::FRAGMENT,
4014                        ty: wgpu::BindingType::Buffer {
4015                            ty: wgpu::BufferBindingType::Uniform,
4016                            has_dynamic_offset: false,
4017                            min_binding_size: None,
4018                        },
4019                        count: None,
4020                    },
4021                ],
4022            });
4023        let blit_pl = self
4024            .device
4025            .create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
4026                label: Some("roxlap-gpu scene_dda.blit_layout"),
4027                bind_group_layouts: &[Some(&bgl_blit)],
4028                immediate_size: 0,
4029            });
4030        let pipeline_blit = self
4031            .device
4032            .create_render_pipeline(&wgpu::RenderPipelineDescriptor {
4033                label: Some("roxlap-gpu scene_dda.blit_pipeline"),
4034                layout: Some(&blit_pl),
4035                vertex: wgpu::VertexState {
4036                    module: &blit_shader,
4037                    entry_point: Some("vs_main"),
4038                    compilation_options: wgpu::PipelineCompilationOptions::default(),
4039                    buffers: &[],
4040                },
4041                fragment: Some(wgpu::FragmentState {
4042                    module: &blit_shader,
4043                    entry_point: Some("fs_main"),
4044                    compilation_options: wgpu::PipelineCompilationOptions::default(),
4045                    targets: &[Some(wgpu::ColorTargetState {
4046                        format: surface_format,
4047                        blend: None,
4048                        write_mask: wgpu::ColorWrites::ALL,
4049                    })],
4050                }),
4051                primitive: wgpu::PrimitiveState::default(),
4052                depth_stencil: None,
4053                multisample: wgpu::MultisampleState::default(),
4054                multiview_mask: None,
4055                cache: None,
4056            });
4057        let blit_bg = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
4058            label: Some("roxlap-gpu scene_dda.blit_bg"),
4059            layout: &bgl_blit,
4060            entries: &[
4061                wgpu::BindGroupEntry {
4062                    binding: 0,
4063                    // RP.1 — blit reads the logical resolve buffer.
4064                    resource: resolve_buf.as_entire_binding(),
4065                },
4066                wgpu::BindGroupEntry {
4067                    binding: 1,
4068                    resource: blit_dims.as_entire_binding(),
4069                },
4070            ],
4071        });
4072
4073        // TV.6 — material palette + terrain map buffers, seeded from the
4074        // renderer's current scene-material state (so a map defined before the
4075        // scene pass was built still takes effect).
4076        let (materials_pal_buf, terrain_map_buf) = {
4077            use wgpu::util::DeviceExt;
4078            let pal = self
4079                .device
4080                .create_buffer_init(&wgpu::util::BufferInitDescriptor {
4081                    label: Some("roxlap-gpu scene_dda.materials_pal"),
4082                    contents: bytemuck::cast_slice(self.scene_materials.as_slice()),
4083                    usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
4084                });
4085            // Fixed 256-row map (≤256 materials anyway) → no re-alloc when the
4086            // host changes the map after the scene pass is built.
4087            let mut rows = [[0u32; 2]; 256];
4088            for (slot, &row) in rows.iter_mut().zip(self.scene_terrain_map.iter()) {
4089                *slot = row;
4090            }
4091            let map = self
4092                .device
4093                .create_buffer_init(&wgpu::util::BufferInitDescriptor {
4094                    label: Some("roxlap-gpu scene_dda.terrain_map"),
4095                    contents: bytemuck::cast_slice(&rows),
4096                    usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
4097                });
4098            (pal, map)
4099        };
4100
4101        SceneDdaResources {
4102            storage_size: (width, height),
4103            logical_size: (logical_w, logical_h),
4104            framebuffer,
4105            uniform_buf,
4106            bgl_dda,
4107            pipeline_dda,
4108            pipeline_resolve,
4109            resolve_bg,
4110            resolve_dims,
4111            blit_bg,
4112            pipeline_blit,
4113            blit_dims,
4114            depth_buffer,
4115            depth_readback,
4116            materials_pal_buf,
4117            terrain_map_buf,
4118            // XS.4.3 — 80-byte dummy (≥ one Instance) for the sprite-cast
4119            // bindings when capable but no sprite registry is bound this frame.
4120            sprite_cast_dummy: capable.then(|| {
4121                self.device.create_buffer(&wgpu::BufferDescriptor {
4122                    label: Some("roxlap-gpu scene_dda.sprite_cast_dummy"),
4123                    size: 80,
4124                    usage: wgpu::BufferUsages::STORAGE,
4125                    mapped_at_creation: false,
4126                })
4127            }),
4128        }
4129    }
4130
4131    /// Read back the per-pixel world-t depth at window pixel `(x, y)`
4132    /// from the last rendered frame, for screen→world picking. Returns
4133    /// the distance `t` along the (normalised) view ray to the nearest
4134    /// scene-grid surface, so the host reconstructs the world hit as
4135    /// `cam.pos + t * normalize(ray_dir)`. `None` for out-of-bounds
4136    /// pixels, sky / no-hit (the `T_INF` sentinel), or when no scene
4137    /// frame has been rendered.
4138    ///
4139    /// The depth buffer is the SCENE pass's output (terrain + grids),
4140    /// untouched by the sprite pass (which reads it read-only), so a
4141    /// cursor sprite under the pointer does not occlude the pick.
4142    ///
4143    /// Synchronous: copies the depth buffer to a mapped staging buffer
4144    /// and blocks on `device.poll(Wait)`. Cheap enough for click-time
4145    /// picks; do not call it every frame.
4146    ///
4147    /// Requires the last frame to have written depth, which happens
4148    /// when sprites are present (`write_depth`). The pick demo always
4149    /// has a cursor sprite, so this holds.
4150    ///
4151    /// Compiles on wasm, but the wasm facade never calls it: WebGPU's
4152    /// `device.poll` doesn't block for the GPU, so the blocking
4153    /// `recv()` here would hang the single browser thread. Picking is
4154    /// deferred on the wasm GPU path (the facade returns `None`).
4155    #[must_use]
4156    pub fn read_depth_pixel(&self, x: u32, y: u32) -> Option<f32> {
4157        let dda = self.scene_dda.as_ref()?;
4158        let (w, h) = dda.storage_size;
4159        if x >= w || y >= h {
4160            return None;
4161        }
4162        let mut enc = self
4163            .device
4164            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
4165                label: Some("roxlap-gpu depth readback"),
4166            });
4167        let size = u64::from(w) * u64::from(h) * 4;
4168        enc.copy_buffer_to_buffer(&dda.depth_buffer, 0, &dda.depth_readback, 0, size);
4169        self.queue.submit(std::iter::once(enc.finish()));
4170
4171        let slice = dda.depth_readback.slice(..);
4172        let (tx, rx) = std::sync::mpsc::channel();
4173        slice.map_async(wgpu::MapMode::Read, move |r| {
4174            let _ = tx.send(r);
4175        });
4176        self.device.poll(wgpu::PollType::wait_indefinitely()).ok();
4177        rx.recv().ok()?.ok()?;
4178
4179        let t = {
4180            let data = slice.get_mapped_range();
4181            let idx = ((y * w + x) * 4) as usize;
4182            let bytes: [u8; 4] = data[idx..idx + 4].try_into().ok()?;
4183            f32::from_le_bytes(bytes)
4184        };
4185        dda.depth_readback.unmap();
4186
4187        // Reject sky / no-hit (T_INF == 1e30 in the shader) + non-finite.
4188        if !t.is_finite() || t >= 1.0e29 {
4189            return None;
4190        }
4191        Some(t)
4192    }
4193
4194    /// World-space view-ray direction (un-normalised) for window pixel
4195    /// `(x, y)`, under the GPU marcher's projection — the canonical GPU
4196    /// unproject, mirroring `scene_dda.wgsl`'s `render_scene`
4197    /// (vertical-FOV pinhole). Uses the last-rendered frame's target
4198    /// size + FOV; `None` before the first scene render. Pair with
4199    /// [`Self::read_depth_pixel`] for screen→world picking.
4200    #[must_use]
4201    pub fn pixel_ray(
4202        &self,
4203        right: [f64; 3],
4204        down: [f64; 3],
4205        forward: [f64; 3],
4206        x: f64,
4207        y: f64,
4208    ) -> Option<[f64; 3]> {
4209        let dda = self.scene_dda.as_ref()?;
4210        let (w, h) = dda.storage_size;
4211        if w == 0 || h == 0 || self.last_fov_y_rad <= 0.0 {
4212            return None;
4213        }
4214        Some(pinhole_pixel_ray(
4215            right,
4216            down,
4217            forward,
4218            x,
4219            y,
4220            f64::from(w),
4221            f64::from(h),
4222            f64::from(self.last_fov_y_rad),
4223        ))
4224    }
4225
4226    /// GPU.10.1 — upload a sprite model registry + its instances for
4227    /// the DDA path. An empty instance slice clears all sprites.
4228    pub fn set_sprite_instances(
4229        &mut self,
4230        registry: &sprite_model::SpriteModelRegistry,
4231        instances: &[sprite_model::SpriteInstance],
4232    ) {
4233        if instances.is_empty() {
4234            self.sprite_registry = None;
4235            return;
4236        }
4237        self.sprite_registry = Some(sprite_model::SpriteRegistryResident::upload(
4238            &self.device,
4239            registry,
4240            instances,
4241        ));
4242    }
4243
4244    /// Incrementally append sprite instances **without** rebuilding the
4245    /// registry — the cheap streaming-spawn path (asteroids, projectiles).
4246    /// Returns the index of the first appended instance (`[base, base+N)`).
4247    ///
4248    /// Every appended instance must reference a model already registered
4249    /// by the [`Self::set_sprite_instances`] that established residency
4250    /// (model volumes are not re-uploaded here — build the full
4251    /// `SpriteModelRegistry` up front and seed it once, then stream
4252    /// instances). If no registry is resident yet, this performs the
4253    /// initial full upload and returns `0`.
4254    ///
4255    /// Cost is amortised O(1) per instance (the GPU instance buffer grows
4256    /// by powers of two), versus the full volume + buffer rebuild of
4257    /// [`Self::set_sprite_instances`].
4258    pub fn append_sprite_instances(
4259        &mut self,
4260        registry: &sprite_model::SpriteModelRegistry,
4261        instances: &[sprite_model::SpriteInstance],
4262    ) -> u32 {
4263        match self.sprite_registry.as_mut() {
4264            Some(reg) => reg.append_instances(&self.device, registry, instances),
4265            None => {
4266                self.set_sprite_instances(registry, instances);
4267                0
4268            }
4269        }
4270    }
4271
4272    /// Remove the sprite instance at `index` (swap-remove, O(1), no model
4273    /// re-upload). Returns `Some(old_last)` if a different instance was
4274    /// moved into `index` to fill the hole — its index changed from
4275    /// `old_last` to `index`, so a caller tracking instance handles must
4276    /// update that one. Returns `None` if `index` was the last element /
4277    /// out of range, or no registry is resident.
4278    pub fn remove_sprite_instance(&mut self, index: usize) -> Option<usize> {
4279        self.sprite_registry
4280            .as_mut()
4281            .and_then(|reg| reg.remove_instance(index))
4282    }
4283
4284    /// Incrementally add a new model (its full LOD chain) to the resident
4285    /// sprite registry **without** re-uploading the existing models — the
4286    /// counterpart to [`Self::append_sprite_instances`] for streaming in
4287    /// new geometry (unique asteroids, generated meshes).
4288    ///
4289    /// Usage mirrors `update_sprite_model`: you own the
4290    /// [`SpriteModelRegistry`](sprite_model::SpriteModelRegistry), append
4291    /// the model with [`add_lod`](sprite_model::SpriteModelRegistry::add_lod)
4292    /// (or `add`), then pass the returned `chain_id` here to sync that one
4293    /// chain to the GPU. Afterwards [`Self::append_sprite_instances`] may
4294    /// reference it.
4295    ///
4296    /// If no registry is resident yet, this performs the initial full
4297    /// upload of `registry` (all its current models, zero instances) to
4298    /// establish residency — so call it for your *first* model; only
4299    /// chains appended *after* residency exists are added incrementally.
4300    ///
4301    /// Cost is amortised O(new model voxels): the shared volume buffers
4302    /// carry slack and bump-append, growing (and rebuilding once from the
4303    /// registry) only on overflow.
4304    /// Flush queued `write_buffer` uploads by submitting an empty command
4305    /// stream. wgpu stages `write_buffer` data and flushes it on the next
4306    /// `Queue::submit`; calling this between batches of uploads (e.g. a
4307    /// flipbook's frames in [`Self::add_sprite_model`]) recycles the device
4308    /// staging pool so a big one-shot batch can't exhaust it (which would
4309    /// then crash egui-wgpu's own `write_buffer`).
4310    pub fn flush_writes(&self) {
4311        self.queue.submit(std::iter::empty::<wgpu::CommandBuffer>());
4312    }
4313
4314    pub fn add_sprite_model(
4315        &mut self,
4316        registry: &sprite_model::SpriteModelRegistry,
4317        chain_id: u32,
4318    ) {
4319        match self.sprite_registry.as_mut() {
4320            Some(reg) => reg.add_model(&self.device, &self.queue, registry, chain_id),
4321            None => {
4322                self.sprite_registry = Some(sprite_model::SpriteRegistryResident::upload(
4323                    &self.device,
4324                    registry,
4325                    &[],
4326                ));
4327            }
4328        }
4329    }
4330
4331    /// Remove a model (tombstone its LOD chain) from the resident sprite
4332    /// registry — the counterpart to [`Self::add_sprite_model`]. Frees its
4333    /// `colors`/`dirs` space for reuse by a later add; the smaller
4334    /// `occupancy`/`color_offsets` holes are reclaimed by
4335    /// [`Self::compact_sprite_models`]. Entry / chain ids stay stable, so
4336    /// other models' `chain_id`s remain valid.
4337    ///
4338    /// Instances of the removed model keep their slots but draw as nothing
4339    /// until the caller drops them via [`Self::remove_sprite_instance`].
4340    /// No-op if `chain_id` is unknown / already removed / no registry.
4341    pub fn remove_sprite_model(&mut self, chain_id: u32) {
4342        if let Some(reg) = self.sprite_registry.as_mut() {
4343            reg.remove_model(chain_id);
4344        }
4345    }
4346
4347    /// Reclaim the holes left by [`Self::remove_sprite_model`] by rebuilding
4348    /// the shared volume buffers from the live models only. `registry` must
4349    /// be the resident one. Cost is O(live volume) — call it when
4350    /// [`Self::dead_sprite_model_count`] is high (e.g. exceeds the live
4351    /// count), not every frame. No-op if no registry is resident.
4352    pub fn compact_sprite_models(&mut self, registry: &sprite_model::SpriteModelRegistry) {
4353        if let Some(reg) = self.sprite_registry.as_mut() {
4354            reg.compact(&self.device, &self.queue, registry);
4355        }
4356    }
4357
4358    /// Number of live (non-removed) sprite models (0 if none uploaded).
4359    #[must_use]
4360    pub fn sprite_model_count(&self) -> usize {
4361        self.sprite_registry
4362            .as_ref()
4363            .map_or(0, sprite_model::SpriteRegistryResident::live_model_count)
4364    }
4365
4366    /// Number of removed-but-not-yet-compacted sprite models — the
4367    /// fragmentation signal for deciding when to call
4368    /// [`Self::compact_sprite_models`].
4369    #[must_use]
4370    pub fn dead_sprite_model_count(&self) -> usize {
4371        self.sprite_registry
4372            .as_ref()
4373            .map_or(0, sprite_model::SpriteRegistryResident::dead_model_count)
4374    }
4375
4376    /// Number of resident sprite instances (0 if none uploaded).
4377    #[must_use]
4378    pub fn sprite_instance_count(&self) -> usize {
4379        self.sprite_registry
4380            .as_ref()
4381            .map_or(0, sprite_model::SpriteRegistryResident::instance_count)
4382    }
4383
4384    /// Re-pose the already-resident sprite instances in place (no model
4385    /// volume re-upload) — the cheap per-frame path for animated KFA
4386    /// limbs. `instances` must match the last [`Self::set_sprite_instances`]
4387    /// in length + order. No-op if no sprite registry is resident.
4388    pub fn update_sprite_instance_transforms(
4389        &mut self,
4390        instances: &[sprite_model::SpriteInstance],
4391    ) {
4392        if let Some(reg) = self.sprite_registry.as_mut() {
4393            reg.update_transforms(instances);
4394        }
4395    }
4396
4397    /// GPU.12 incremental — re-upload only LOD chain `chain_id`'s entries
4398    /// after an in-place edit of `registry` (carve / recolour), without
4399    /// rebuilding the whole sprite registry. `registry` must be the one
4400    /// last passed to [`Self::set_sprite_instances`] with chain
4401    /// `chain_id` already edited. No-op if no registry is resident.
4402    pub fn update_sprite_model(
4403        &mut self,
4404        registry: &sprite_model::SpriteModelRegistry,
4405        chain_id: u32,
4406    ) {
4407        if let Some(reg) = self.sprite_registry.as_mut() {
4408            reg.update_model(&self.device, &self.queue, registry, chain_id);
4409        }
4410    }
4411
4412    /// VCL.2 — repoint sprite instance `index` at LOD chain `chain_id`
4413    /// (the per-frame flipbook step for animated voxel clips). `registry`
4414    /// is the resident one; `chain_id`'s volume must already be uploaded
4415    /// (e.g. a clip's frames registered via [`Self::add_sprite_model`]).
4416    /// CPU-side rewrite picked up by the next frame's cull — no volume
4417    /// re-upload. No-op if no registry is resident.
4418    pub fn set_sprite_instance_model(
4419        &mut self,
4420        registry: &sprite_model::SpriteModelRegistry,
4421        index: usize,
4422        chain_id: u32,
4423    ) {
4424        if let Some(reg) = self.sprite_registry.as_mut() {
4425            reg.set_instance_model(registry, index, chain_id);
4426        }
4427    }
4428
4429    /// Set the per-instance `kv6colmul[256]` lighting tables (voxlap's
4430    /// `update_reflects` output, e.g. via `roxlap_core::sprite::
4431    /// sprite_colmul`), in the same order/length as the last
4432    /// [`Self::set_sprite_instances`]. The GPU sprite pass modulates each
4433    /// voxel by its surface normal's entry — matching the CPU rasteriser.
4434    /// No-op if no sprite registry is resident.
4435    pub fn set_sprite_instance_colmul(&mut self, tables: &[[u64; 256]]) {
4436        if let Some(reg) = self.sprite_registry.as_mut() {
4437            reg.set_instance_colmul(tables);
4438        }
4439    }
4440
4441    /// GPU.10.4 — set the LOD pixel threshold: a sprite steps to the
4442    /// next mip once a mip-0 voxel would project below `px` screen
4443    /// pixels. `1.0` is the natural "no sub-pixel voxels" default;
4444    /// larger values force LOD in closer (useful for inspection).
4445    /// Clamped to ≥ 0.25.
4446    pub fn set_sprite_lod_px(&mut self, px: f32) {
4447        self.sprite_lod_px = px.max(0.25);
4448    }
4449
4450    /// GPU.11.1 — set the scene-grid LOD scan distance (world units).
4451    /// A chunk entered at world-t `t` is marched at mip
4452    /// `floor(log2(max(t, msd) / msd))`, clamped to its grid's mip
4453    /// ladder. `0` disables LOD (always mip-0). Larger values push
4454    /// the coarser mips farther out — the axis-aligned-mip-beams
4455    /// mitigation lever (GPU.11.2). Default 64 (matches CPU
4456    /// `mip_scan_dist`).
4457    pub fn set_scene_mip_scan_dist(&mut self, dist: f32) {
4458        self.scene_mip_scan_dist = dist.max(0.0);
4459    }
4460
4461    /// Set per-face grid side-shading — voxlap's
4462    /// `setsideshades(top, bot, left, right, up, down)`. Each value is
4463    /// subtracted (as a u8, matching the CPU `gcsub` high byte) from a
4464    /// hit voxel's brightness byte before shading, so the scene-DDA pass
4465    /// darkens grid faces the same way the CPU rasteriser does. `[0; 6]`
4466    /// disables it (the default). The hit face is taken from the DDA's
4467    /// last-stepped axis + ray direction.
4468    pub fn set_scene_side_shades(&mut self, s: [i8; 6]) {
4469        // Reinterpret each i8 as u8 (voxlap stamps `sxx` into gcsub's
4470        // high byte verbatim), then pack (top, bot, left, right) /
4471        // (up, down, 0, 0) for the two uniform vec4s.
4472        let v = |i: usize| i32::from(s[i] as u8);
4473        self.scene_side_shades = [[v(0), v(1), v(2), v(3)], [v(4), v(5), 0, 0]];
4474    }
4475
4476    /// DL — set the per-frame dynamic lights (sun + point lights), already
4477    /// transformed into each grid's local frame. Call once per frame before
4478    /// [`Self::render_scene`] (the facade does this from
4479    /// `FrameParams::lights`). [`SceneLights::default`] clears all lights —
4480    /// the pre-DL render. GPU-only; the CPU backend has no analogue.
4481    pub fn set_scene_lights(&mut self, lights: SceneLights) {
4482        self.scene_lights = lights;
4483    }
4484
4485    /// GPU.10.1 — build the instanced model-DDA pipeline (one thread
4486    /// per pixel). Lazily invoked the first frame a registry is present.
4487    fn build_sprite_model_dda(&self) -> SpriteModelDdaResources {
4488        // XS.4.2 — on sprite-shadow-capable devices, splice the terrain shadow
4489        // snippet over the stub (`shadow_occluded_world` becomes a real terrain
4490        // march; binds occupancy 16..23). Otherwise the stub keeps sprites
4491        // unshadowed and the BGL stays at the base 14 storage buffers.
4492        let capable = self.sprite_shadows_capable;
4493        let src = sprite_shader_source(capable);
4494        let shader = self
4495            .device
4496            .create_shader_module(wgpu::ShaderModuleDescriptor {
4497                label: Some("sprite_model_dda.wgsl"),
4498                source: wgpu::ShaderSource::Wgsl(src.into()),
4499            });
4500        let mut entries = vec![
4501            bgl_uniform_entry(0),
4502            bgl_storage_entry(1, true),  // occupancy
4503            bgl_storage_entry(2, true),  // colors
4504            bgl_storage_entry(3, true),  // color_offsets
4505            bgl_storage_entry(4, true),  // model_meta
4506            bgl_storage_entry(5, true),  // instances
4507            bgl_storage_entry(6, true),  // scene depth
4508            bgl_storage_entry(7, false), // framebuffer (read-write buffer)
4509            bgl_storage_entry(8, true),  // tile_ranges
4510            bgl_storage_entry(9, true),  // tile_instances
4511            bgl_storage_entry(10, true), // per-voxel dir
4512            bgl_storage_entry(11, true), // per-instance kv6colmul
4513            bgl_storage_entry(12, true), // TV — material palette
4514            bgl_storage_entry(13, true), // TV.3 — per-voxel material id
4515            bgl_storage_entry(15, true), // DL.7 — world point lights
4516        ];
4517        if capable {
4518            // XS.4.2 — terrain occupancy set for sprite RECEIVE shadows.
4519            entries.push(bgl_storage_entry(16, true)); // occ_page0
4520            entries.push(bgl_storage_entry(17, true)); // occ_page1
4521            entries.push(bgl_storage_entry(18, true)); // occ_page2
4522            entries.push(bgl_storage_entry(19, true)); // occ_page3
4523            entries.push(bgl_storage_entry(20, true)); // all_chunk_occupancy
4524            entries.push(bgl_storage_entry(21, true)); // all_slot_chunk_idx
4525            entries.push(bgl_storage_entry(22, true)); // grid_static_meta
4526            entries.push(bgl_storage_entry(23, true)); // grid_cameras
4527        }
4528        let bgl = self
4529            .device
4530            .create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
4531                label: Some("roxlap-gpu sprite_model_dda.bgl"),
4532                entries: &entries,
4533            });
4534        let pl = self
4535            .device
4536            .create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
4537                label: Some("roxlap-gpu sprite_model_dda.layout"),
4538                bind_group_layouts: &[Some(&bgl)],
4539                immediate_size: 0,
4540            });
4541        let pipeline = self
4542            .device
4543            .create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
4544                label: Some("roxlap-gpu sprite_model_dda.pipeline"),
4545                layout: Some(&pl),
4546                module: &shader,
4547                entry_point: Some("march"),
4548                compilation_options: wgpu::PipelineCompilationOptions::default(),
4549                cache: None,
4550            });
4551        let uniform_buf = self.device.create_buffer(&wgpu::BufferDescriptor {
4552            label: Some("roxlap-gpu sprite_model_dda.uniform"),
4553            size: std::mem::size_of::<SpriteModelUniform>() as u64,
4554            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
4555            mapped_at_creation: false,
4556        });
4557        // TV — material palette, seeded from the current renderer state so a
4558        // table defined before the sprite pass was built still takes effect.
4559        let materials_buf = {
4560            use wgpu::util::DeviceExt;
4561            self.device
4562                .create_buffer_init(&wgpu::util::BufferInitDescriptor {
4563                    label: Some("roxlap-gpu sprite_model_dda.materials"),
4564                    contents: bytemuck::cast_slice(self.sprite_materials.as_slice()),
4565                    usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
4566                })
4567        };
4568        SpriteModelDdaResources {
4569            bgl,
4570            pipeline,
4571            uniform_buf,
4572            materials_buf,
4573        }
4574    }
4575
4576    /// TV — set the global voxel-material palette for the GPU sprite pass.
4577    /// Mirrors the renderer's [`MaterialTable`](roxlap_formats::material::MaterialTable):
4578    /// every sprite/clip instance's `material` id indexes it for opacity +
4579    /// blend mode. Cheap (2 KB); call it whenever the palette changes (or
4580    /// each frame). While every material is opaque the shader stays on the
4581    /// unchanged first-hit path.
4582    pub fn set_sprite_materials(&mut self, table: &roxlap_formats::material::MaterialTable) {
4583        let (palette, any_translucent) = material_palette(table);
4584        self.sprite_materials = palette;
4585        self.sprite_has_translucent = any_translucent;
4586        if let Some(smd) = &self.sprite_model_dda {
4587            self.queue.write_buffer(
4588                &smd.materials_buf,
4589                0,
4590                bytemuck::cast_slice(self.sprite_materials.as_slice()),
4591            );
4592        }
4593    }
4594
4595    /// TV.6 — set the scene (terrain) material palette + colour→material map
4596    /// for the multi-grid scene pass. Matching-colour terrain voxels render
4597    /// translucent; an empty map / all-opaque palette renders unchanged. The
4598    /// map is capped at 256 rows (the fixed buffer size).
4599    pub fn set_scene_terrain_materials(
4600        &mut self,
4601        table: &roxlap_formats::material::MaterialTable,
4602        map: &[(u32, u8)],
4603    ) {
4604        let (palette, _) = material_palette(table);
4605        self.scene_materials = palette;
4606        self.scene_terrain_map = map
4607            .iter()
4608            .take(256)
4609            .map(|&(c, m)| [c & 0x00ff_ffff, u32::from(m)])
4610            .collect();
4611        self.scene_terrain_translucent = map.iter().any(|&(_, m)| !table.get(m).is_opaque());
4612        if let Some(dda) = &self.scene_dda {
4613            self.queue.write_buffer(
4614                &dda.materials_pal_buf,
4615                0,
4616                bytemuck::cast_slice(self.scene_materials.as_slice()),
4617            );
4618            if !self.scene_terrain_map.is_empty() {
4619                self.queue.write_buffer(
4620                    &dda.terrain_map_buf,
4621                    0,
4622                    bytemuck::cast_slice(&self.scene_terrain_map),
4623                );
4624            }
4625        }
4626    }
4627}
4628
4629/// GPU.11 — headless scene-DDA renderer for tests + offline visual
4630/// gates. Owns the `scene_dda.wgsl` compute pipeline with no surface
4631/// and no blit pass; renders a [`GpuSceneResident`] to an in-memory
4632/// RGBA framebuffer via texture readback. The per-substage visual
4633/// gate (render reference scenes, diff PPMs) and the GPU.11.1 mip
4634/// render-diff both ride on this.
4635pub struct HeadlessSceneRenderer {
4636    width: u32,
4637    height: u32,
4638    /// Framebuffer storage buffer (packed `rgba8unorm`, tight rows) —
4639    /// matches the buffer-output `scene_dda.wgsl` (see its note).
4640    framebuffer: wgpu::Buffer,
4641    depth_buffer: wgpu::Buffer,
4642    uniform_buf: wgpu::Buffer,
4643    _sky_texture: wgpu::Texture,
4644    sky_view: wgpu::TextureView,
4645    sky_sampler: wgpu::Sampler,
4646    bgl: wgpu::BindGroupLayout,
4647    pipeline: wgpu::ComputePipeline,
4648    readback: wgpu::Buffer,
4649    /// Per-face side-shades for the gate render (default none). Packed
4650    /// `[(top,bot,left,right), (up,down,_,_)]`; set via
4651    /// [`Self::set_side_shades`].
4652    side_shades: [[i32; 4]; 2],
4653    /// DL — dynamic lights for the render (already grid-local, like the
4654    /// surface path). Default = none (baked-only). Set via
4655    /// [`Self::set_scene_lights`]; lets tests exercise the lit path.
4656    lights: SceneLights,
4657}
4658
4659impl HeadlessSceneRenderer {
4660    /// Build the compute pipeline + output/readback resources for a
4661    /// `width × height` framebuffer. Validates `scene_dda.wgsl` and
4662    /// the [`scene::GridStaticMeta`] std430 layout at pipeline /
4663    /// bind-group time.
4664    #[must_use]
4665    pub fn new(device: &wgpu::Device, queue: &wgpu::Queue, width: u32, height: u32) -> Self {
4666        let framebuffer = device.create_buffer(&wgpu::BufferDescriptor {
4667            label: Some("roxlap-gpu headless.framebuffer"),
4668            size: u64::from(width) * u64::from(height) * 4,
4669            usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC,
4670            mapped_at_creation: false,
4671        });
4672
4673        let uniform_buf = device.create_buffer(&wgpu::BufferDescriptor {
4674            label: Some("roxlap-gpu headless.uniform"),
4675            size: std::mem::size_of::<SceneDdaUniform>() as u64,
4676            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
4677            mapped_at_creation: false,
4678        });
4679        let depth_buffer = device.create_buffer(&wgpu::BufferDescriptor {
4680            label: Some("roxlap-gpu headless.depth"),
4681            size: u64::from(width) * u64::from(height) * 4,
4682            usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
4683            mapped_at_creation: false,
4684        });
4685
4686        let default_sky_pixel = [120u8, 150, 220, 255];
4687        let (sky_texture, sky_view) = create_sky_texture(device, 1, 1, &default_sky_pixel);
4688        // Upload the default sky texel (create_sky_texture only allocates
4689        // — the texel must be written or the shader samples black, which
4690        // is why a grid-less headless render came back black).
4691        queue.write_texture(
4692            wgpu::TexelCopyTextureInfo {
4693                texture: &sky_texture,
4694                mip_level: 0,
4695                origin: wgpu::Origin3d::ZERO,
4696                aspect: wgpu::TextureAspect::All,
4697            },
4698            &default_sky_pixel,
4699            wgpu::TexelCopyBufferLayout {
4700                offset: 0,
4701                bytes_per_row: Some(4),
4702                rows_per_image: Some(1),
4703            },
4704            wgpu::Extent3d {
4705                width: 1,
4706                height: 1,
4707                depth_or_array_layers: 1,
4708            },
4709        );
4710        let sky_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
4711            label: Some("roxlap-gpu headless.sky_sampler"),
4712            address_mode_u: wgpu::AddressMode::Repeat,
4713            address_mode_v: wgpu::AddressMode::Repeat,
4714            mag_filter: wgpu::FilterMode::Linear,
4715            min_filter: wgpu::FilterMode::Linear,
4716            ..Default::default()
4717        });
4718
4719        let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
4720            label: Some("scene_dda.wgsl (headless)"),
4721            source: wgpu::ShaderSource::Wgsl(include_str!("../shaders/scene_dda.wgsl").into()),
4722        });
4723        let bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
4724            label: Some("roxlap-gpu headless.bgl"),
4725            entries: &[
4726                bgl_uniform_entry(0),
4727                bgl_storage_entry(1, true),
4728                bgl_storage_entry(2, true),
4729                bgl_storage_entry(3, true),
4730                bgl_storage_entry(4, true),
4731                bgl_storage_entry(5, true),
4732                bgl_storage_entry(6, true),
4733                bgl_storage_entry(7, true),
4734                // Framebuffer storage buffer (read-write).
4735                bgl_storage_entry(8, false),
4736                wgpu::BindGroupLayoutEntry {
4737                    binding: 9,
4738                    visibility: wgpu::ShaderStages::COMPUTE,
4739                    ty: wgpu::BindingType::Texture {
4740                        sample_type: wgpu::TextureSampleType::Float { filterable: true },
4741                        view_dimension: wgpu::TextureViewDimension::D2,
4742                        multisampled: false,
4743                    },
4744                    count: None,
4745                },
4746                wgpu::BindGroupLayoutEntry {
4747                    binding: 10,
4748                    visibility: wgpu::ShaderStages::COMPUTE,
4749                    ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
4750                    count: None,
4751                },
4752                bgl_storage_entry(11, false),
4753                bgl_storage_entry(12, true),
4754                bgl_storage_entry(13, true),
4755                bgl_storage_entry(14, true),
4756                // Per-grid cameras (runtime-sized; one per grid).
4757                bgl_storage_entry(15, true),
4758                // TV.6 — material palette + terrain map (opaque dummies here).
4759                bgl_storage_entry(16, true),
4760                bgl_storage_entry(17, true),
4761                // DL — per-grid point lights (18). Sun dir rides in
4762                // PerGridCamera (binding 15).
4763                bgl_storage_entry(18, true),
4764            ],
4765        });
4766        let pl = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
4767            label: Some("roxlap-gpu headless.layout"),
4768            bind_group_layouts: &[Some(&bgl)],
4769            immediate_size: 0,
4770        });
4771        let pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
4772            label: Some("roxlap-gpu headless.pipeline"),
4773            layout: Some(&pl),
4774            module: &shader,
4775            entry_point: Some("render_scene"),
4776            compilation_options: wgpu::PipelineCompilationOptions::default(),
4777            cache: None,
4778        });
4779
4780        // Readback is a tight buffer-to-buffer copy (no 256-byte row
4781        // padding, unlike the old texture-to-buffer path).
4782        let readback = device.create_buffer(&wgpu::BufferDescriptor {
4783            label: Some("roxlap-gpu headless.readback"),
4784            size: u64::from(width) * u64::from(height) * 4,
4785            usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
4786            mapped_at_creation: false,
4787        });
4788
4789        Self {
4790            width,
4791            height,
4792            framebuffer,
4793            depth_buffer,
4794            uniform_buf,
4795            _sky_texture: sky_texture,
4796            sky_view,
4797            sky_sampler,
4798            bgl,
4799            pipeline,
4800            readback,
4801            side_shades: [[0; 4]; 2],
4802            lights: SceneLights::default(),
4803        }
4804    }
4805
4806    /// DL — set dynamic lights for subsequent [`Self::render`] calls
4807    /// (already in grid-local space). Lets tests exercise the lit path
4808    /// (sun N·L, point lights). Default = none (baked-only).
4809    pub fn set_scene_lights(&mut self, lights: SceneLights) {
4810        self.lights = lights;
4811    }
4812
4813    /// Set per-face side-shades for subsequent [`Self::render`] calls —
4814    /// voxlap `setsideshades(top, bot, left, right, up, down)`, each an
4815    /// i8 stamped as u8 (matching the engine path). Lets the gate test
4816    /// the GPU side-shade darkening.
4817    pub fn set_side_shades(&mut self, s: [i8; 6]) {
4818        let v = |i: usize| i32::from(s[i] as u8);
4819        self.side_shades = [[v(0), v(1), v(2), v(3)], [v(4), v(5), 0, 0]];
4820    }
4821
4822    /// Render `scene` from `cameras` (one per grid) and read the
4823    /// framebuffer back as `width*height` packed `0xAABBGGRR` pixels
4824    /// (R in the low byte). Fog is disabled. `mip_scan_dist` drives
4825    /// the GPU.11.1 scene-grid LOD (`0` = always mip-0). Blocks on
4826    /// readback.
4827    ///
4828    /// # Panics
4829    /// If `cameras.len() != scene.grid_count`.
4830    /// Headless render with identity per-grid world transforms (shadows stay
4831    /// intra-grid). See [`Self::render_with_transforms`] for the cross-grid
4832    /// (XS.3) variant.
4833    #[must_use]
4834    #[allow(clippy::too_many_arguments)]
4835    pub fn render(
4836        &self,
4837        device: &wgpu::Device,
4838        queue: &wgpu::Queue,
4839        scene: &GpuSceneResident,
4840        cameras: &[Camera],
4841        fov_y_rad: f32,
4842        max_outer_steps: u32,
4843        mip_scan_dist: f32,
4844    ) -> Vec<u32> {
4845        self.render_with_transforms(
4846            device,
4847            queue,
4848            scene,
4849            cameras,
4850            &[],
4851            fov_y_rad,
4852            max_outer_steps,
4853            mip_scan_dist,
4854        )
4855    }
4856
4857    /// XS.3 — headless render with explicit per-grid world transforms, so the
4858    /// scene shader can lift a shadow ray to world space and test it against
4859    /// every grid (cross-grid shadows). Empty `grid_world` ⇒ identity.
4860    #[must_use]
4861    #[allow(clippy::too_many_arguments)]
4862    pub fn render_with_transforms(
4863        &self,
4864        device: &wgpu::Device,
4865        queue: &wgpu::Queue,
4866        scene: &GpuSceneResident,
4867        cameras: &[Camera],
4868        grid_world: &[GridWorldTransform],
4869        fov_y_rad: f32,
4870        max_outer_steps: u32,
4871        mip_scan_dist: f32,
4872    ) -> Vec<u32> {
4873        assert_eq!(
4874            cameras.len(),
4875            scene.grid_count as usize,
4876            "headless render: {} cameras for {} grids",
4877            cameras.len(),
4878            scene.grid_count,
4879        );
4880
4881        let mut cam_vec: Vec<SceneDdaPerGridCamera> = cameras
4882            .iter()
4883            .map(SceneDdaPerGridCamera::from_camera)
4884            .collect();
4885        // XS.3 — stamp world transforms for cross-grid shadows (identity if absent).
4886        for (c, t) in cam_vec.iter_mut().zip(grid_world.iter()) {
4887            c.set_world_transform(t);
4888        }
4889        // TV.6 — opaque dummies for the material palette + terrain map
4890        // bindings (headless renders opaque-only: terrain_has_translucent=0).
4891        let (dummy_pal, dummy_map) = {
4892            use wgpu::util::DeviceExt;
4893            let pal: Vec<MaterialGpu> = vec![
4894                MaterialGpu {
4895                    alpha: 1.0,
4896                    mode: 0
4897                };
4898                256
4899            ];
4900            let p = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
4901                label: Some("roxlap-gpu headless.materials_pal"),
4902                contents: bytemuck::cast_slice(&pal),
4903                usage: wgpu::BufferUsages::STORAGE,
4904            });
4905            let m = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
4906                label: Some("roxlap-gpu headless.terrain_map"),
4907                contents: bytemuck::cast_slice(&[[0u32; 2]]),
4908                usage: wgpu::BufferUsages::STORAGE,
4909            });
4910            (p, m)
4911        };
4912        // DL — pack any dynamic lights (default none ⇒ the baked-only path,
4913        // matching the oracle goldens). Injects sun dir into cam_vec.sun_dir
4914        // and builds the point-light buffer (binding 18). Shared with the
4915        // surface path.
4916        let dl = self.lights.clone();
4917        let (dummy_point_lights, sun_flags, point_count) =
4918            pack_scene_lights(device, &dl, scene.grid_count as usize, &mut cam_vec);
4919        let grid_cameras = upload_grid_cameras(device, &cam_vec);
4920        let uniform = SceneDdaUniform {
4921            fov_y_rad,
4922            grid_count: scene.grid_count,
4923            max_outer_steps,
4924            _pad0: 0,
4925            screen_size: [self.width, self.height],
4926            _pad1: [0; 2],
4927            // Fog off: near/far past any reachable t → factor 0.
4928            fog_color: [0.0, 0.0, 0.0, 1.0e29],
4929            fog_far: 1.0e30,
4930            write_depth: 0,
4931            occ_page_words: scene.occupancy_page_words,
4932            occ_num_pages: scene.occupancy_num_pages,
4933            mip_scan_dist,
4934            terrain_has_translucent: 0, // headless gate: opaque only
4935            terrain_map_count: 0,
4936            _pad4: 0,
4937            // Sky direction from the first grid camera (the world frame
4938            // in these tests); a default forward camera when there are
4939            // none (grid_count == 0) so the sky lookup stays valid.
4940            sky_cam: SceneDdaPerGridCamera::from_camera(&cameras.first().copied().unwrap_or(
4941                Camera {
4942                    position: [0.0; 3],
4943                    right: [1.0, 0.0, 0.0],
4944                    down: [0.0, 0.0, 1.0],
4945                    forward: [0.0, 1.0, 0.0],
4946                    fov_y_rad,
4947                },
4948            )),
4949            side_shades0: self.side_shades[0],
4950            side_shades1: self.side_shades[1],
4951            // DL — light parameters (default = no lights ⇒ sun_flags 0).
4952            sun_color: [
4953                dl.sun_color[0],
4954                dl.sun_color[1],
4955                dl.sun_color[2],
4956                dl.sun_intensity,
4957            ],
4958            ambient_color: [
4959                dl.ambient[0],
4960                dl.ambient[1],
4961                dl.ambient[2],
4962                dl.shadow_strength,
4963            ],
4964            sun_flags,
4965            point_light_count: point_count,
4966            shadow_max_steps: dl.shadow_max_steps,
4967            _pad5: 0,
4968            shadow_bias: dl.shadow_bias,
4969            shadow_max_dist: dl.shadow_max_dist,
4970            _pad6: [0.0; 2],
4971            shadow_tint: [dl.shadow_tint[0], dl.shadow_tint[1], dl.shadow_tint[2], 0.0],
4972            style_bands: dl.style_bands,
4973            sprite_cast_count: 0, // headless renderer has no sprite pass
4974            _pad7: [0; 2],
4975        };
4976        queue.write_buffer(&self.uniform_buf, 0, bytemuck::bytes_of(&uniform));
4977
4978        let bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
4979            label: Some("roxlap-gpu headless.bg"),
4980            layout: &self.bgl,
4981            entries: &[
4982                wgpu::BindGroupEntry {
4983                    binding: 0,
4984                    resource: self.uniform_buf.as_entire_binding(),
4985                },
4986                wgpu::BindGroupEntry {
4987                    binding: 1,
4988                    resource: scene.occupancy_pages[0].as_entire_binding(),
4989                },
4990                wgpu::BindGroupEntry {
4991                    binding: 2,
4992                    resource: scene.all_color_offsets.as_entire_binding(),
4993                },
4994                wgpu::BindGroupEntry {
4995                    binding: 3,
4996                    resource: scene.all_colors.as_entire_binding(),
4997                },
4998                wgpu::BindGroupEntry {
4999                    binding: 4,
5000                    resource: scene.all_chunk_colors_base.as_entire_binding(),
5001                },
5002                wgpu::BindGroupEntry {
5003                    binding: 5,
5004                    resource: scene.all_chunk_occupancy.as_entire_binding(),
5005                },
5006                wgpu::BindGroupEntry {
5007                    binding: 6,
5008                    resource: scene.grid_static_meta.as_entire_binding(),
5009                },
5010                wgpu::BindGroupEntry {
5011                    binding: 7,
5012                    resource: scene.all_slot_chunk_idx.as_entire_binding(),
5013                },
5014                wgpu::BindGroupEntry {
5015                    binding: 8,
5016                    resource: self.framebuffer.as_entire_binding(),
5017                },
5018                wgpu::BindGroupEntry {
5019                    binding: 9,
5020                    resource: wgpu::BindingResource::TextureView(&self.sky_view),
5021                },
5022                wgpu::BindGroupEntry {
5023                    binding: 10,
5024                    resource: wgpu::BindingResource::Sampler(&self.sky_sampler),
5025                },
5026                wgpu::BindGroupEntry {
5027                    binding: 11,
5028                    resource: self.depth_buffer.as_entire_binding(),
5029                },
5030                wgpu::BindGroupEntry {
5031                    binding: 12,
5032                    resource: scene.occupancy_pages[1].as_entire_binding(),
5033                },
5034                wgpu::BindGroupEntry {
5035                    binding: 13,
5036                    resource: scene.occupancy_pages[2].as_entire_binding(),
5037                },
5038                wgpu::BindGroupEntry {
5039                    binding: 14,
5040                    resource: scene.occupancy_pages[3].as_entire_binding(),
5041                },
5042                wgpu::BindGroupEntry {
5043                    binding: 15,
5044                    resource: grid_cameras.as_entire_binding(),
5045                },
5046                wgpu::BindGroupEntry {
5047                    binding: 16,
5048                    resource: dummy_pal.as_entire_binding(),
5049                },
5050                wgpu::BindGroupEntry {
5051                    binding: 17,
5052                    resource: dummy_map.as_entire_binding(),
5053                },
5054                // DL — dummy per-grid point lights (18). Sun dir rides in
5055                // PerGridCamera (binding 15).
5056                wgpu::BindGroupEntry {
5057                    binding: 18,
5058                    resource: dummy_point_lights.as_entire_binding(),
5059                },
5060            ],
5061        });
5062
5063        let mut enc =
5064            device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
5065        {
5066            let mut pass = enc.begin_compute_pass(&wgpu::ComputePassDescriptor {
5067                label: Some("roxlap-gpu headless.pass"),
5068                timestamp_writes: None,
5069            });
5070            pass.set_pipeline(&self.pipeline);
5071            pass.set_bind_group(0, &bg, &[]);
5072            pass.dispatch_workgroups(self.width.div_ceil(8), self.height.div_ceil(8), 1);
5073        }
5074        enc.copy_buffer_to_buffer(
5075            &self.framebuffer,
5076            0,
5077            &self.readback,
5078            0,
5079            u64::from(self.width) * u64::from(self.height) * 4,
5080        );
5081        queue.submit(Some(enc.finish()));
5082
5083        let slice = self.readback.slice(..);
5084        let (tx, rx) = std::sync::mpsc::channel();
5085        slice.map_async(wgpu::MapMode::Read, move |r| {
5086            let _ = tx.send(r);
5087        });
5088        device.poll(wgpu::PollType::wait_indefinitely()).ok();
5089        rx.recv().expect("map_async channel").expect("map_async");
5090
5091        let data = slice.get_mapped_range();
5092        // Tight `width*height` packed pixels — the shader's
5093        // `pack4x8unorm(vec4(r,g,b,a))` already yields `0xAABBGGRR`
5094        // little-endian, so a straight u32 read reconstructs each pixel.
5095        let out: Vec<u32> = data
5096            .chunks_exact(4)
5097            .map(|px| u32::from_le_bytes([px[0], px[1], px[2], px[3]]))
5098            .collect();
5099        drop(data);
5100        self.readback.unmap();
5101        out
5102    }
5103}
5104
5105fn bgl_uniform_entry(binding: u32) -> wgpu::BindGroupLayoutEntry {
5106    wgpu::BindGroupLayoutEntry {
5107        binding,
5108        visibility: wgpu::ShaderStages::COMPUTE,
5109        ty: wgpu::BindingType::Buffer {
5110            ty: wgpu::BufferBindingType::Uniform,
5111            has_dynamic_offset: false,
5112            min_binding_size: None,
5113        },
5114        count: None,
5115    }
5116}
5117
5118fn bgl_storage_entry(binding: u32, read_only: bool) -> wgpu::BindGroupLayoutEntry {
5119    wgpu::BindGroupLayoutEntry {
5120        binding,
5121        visibility: wgpu::ShaderStages::COMPUTE,
5122        ty: wgpu::BindingType::Buffer {
5123            ty: wgpu::BufferBindingType::Storage { read_only },
5124            has_dynamic_offset: false,
5125            min_binding_size: None,
5126        },
5127        count: None,
5128    }
5129}
5130
5131/// Create a fresh sky panorama texture sized `width × height` with
5132/// the initial pixel data uploaded via `write_texture`. Used by
5133/// `GpuRenderer::new` (1×1 default) and `set_sky_panorama` (host-
5134/// supplied panorama).
5135fn create_sky_texture(
5136    device: &wgpu::Device,
5137    width: u32,
5138    height: u32,
5139    _initial_pixels: &[u8],
5140) -> (wgpu::Texture, wgpu::TextureView) {
5141    let tex = device.create_texture(&wgpu::TextureDescriptor {
5142        label: Some("roxlap-gpu sky_texture"),
5143        size: wgpu::Extent3d {
5144            width,
5145            height,
5146            depth_or_array_layers: 1,
5147        },
5148        mip_level_count: 1,
5149        sample_count: 1,
5150        dimension: wgpu::TextureDimension::D2,
5151        format: wgpu::TextureFormat::Rgba8Unorm,
5152        usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
5153        view_formats: &[],
5154    });
5155    let view = tex.create_view(&wgpu::TextureViewDescriptor::default());
5156    (tex, view)
5157}
5158
5159/// GPU.4 needs to upload a whole grid (~hundreds of MiB) as a few
5160/// storage buffers. wgpu's default `max_storage_buffer_binding_size`
5161/// is 128 MiB, which is just enough for the demo's 32×32 ground
5162/// occupancy (~128 MiB) but not the colour array. We request as
5163/// much as the adapter is willing to give — most desktop GPUs cap
5164/// individual storage buffers at 2-4 GiB; iGPUs often offer the
5165/// full system memory.
5166pub(crate) fn pick_required_limits(adapter_limits: &wgpu::Limits) -> wgpu::Limits {
5167    wgpu::Limits {
5168        max_storage_buffer_binding_size: adapter_limits.max_storage_buffer_binding_size,
5169        max_buffer_size: adapter_limits.max_buffer_size,
5170        // Occupancy paging adds up to MAX_OCC_PAGES-1 extra storage
5171        // bindings; with the scene's other buffers + the GPU.9 depth
5172        // buffer the scene_dda stage needs 16. XS.4 GPU sprite shadows
5173        // need more (the sprite pass binds the terrain occupancy set on
5174        // top of its own — up to `SPRITE_SHADOW_MIN_STORAGE_BUFFERS`), so
5175        // request that many when the adapter offers them; capable devices
5176        // light up sprite shadows, others fall back (still ≥16 for the
5177        // base renderer). Both NVK and lavapipe advertise ≫16.
5178        max_storage_buffers_per_shader_stage: adapter_limits
5179            .max_storage_buffers_per_shader_stage
5180            .min(SPRITE_SHADOW_MIN_STORAGE_BUFFERS),
5181        ..wgpu::Limits::default()
5182    }
5183}
5184
5185/// XS.4.2 — build the sprite-pass shader source. On a sprite-shadow-capable
5186/// device, splice `sprite_terrain_shadow.wgsl` over the `//XS4_STUB_BEGIN`..
5187/// `//XS4_STUB_END` block so `shadow_occluded_world` becomes the real terrain
5188/// march (+ the occupancy bindings 16..23); otherwise the stub keeps GPU
5189/// sprites unshadowed. The base file is always valid WGSL (the stub variant),
5190/// so `wgsl_shaders_validate` covers the fallback path.
5191fn sprite_shader_source(capable: bool) -> String {
5192    let base = include_str!("../shaders/sprite_model_dda.wgsl");
5193    if !capable {
5194        return base.to_string();
5195    }
5196    let snippet = include_str!("../shaders/sprite_terrain_shadow.wgsl");
5197    const BEGIN: &str = "//XS4_STUB_BEGIN";
5198    const END: &str = "//XS4_STUB_END";
5199    let (Some(b), Some(e)) = (base.find(BEGIN), base.find(END)) else {
5200        // Markers missing — fail loud rather than silently shipping the stub.
5201        panic!("sprite_model_dda.wgsl: XS4 stub markers not found");
5202    };
5203    let e_end = e + END.len();
5204    let mut out = String::with_capacity(base.len() + snippet.len());
5205    out.push_str(&base[..b]);
5206    out.push_str(snippet);
5207    out.push_str(&base[e_end..]);
5208    out
5209}
5210
5211/// XS.4.3 — build the scene-pass shader source. On a sprite-shadow-capable
5212/// device, splice `scene_sprite_shadow.wgsl` over the `//XS4C_STUB_BEGIN`..
5213/// `//XS4C_STUB_END` block so `sprites_occlude` marches the sprite registry
5214/// (+ bindings 19..21) and terrain receives sprite-cast shadows; otherwise the
5215/// stub returns false. The base file is always valid WGSL (the stub variant).
5216fn scene_shader_source(capable: bool) -> String {
5217    let base = include_str!("../shaders/scene_dda.wgsl");
5218    if !capable {
5219        return base.to_string();
5220    }
5221    let snippet = include_str!("../shaders/scene_sprite_shadow.wgsl");
5222    const BEGIN: &str = "//XS4C_STUB_BEGIN";
5223    const END: &str = "//XS4C_STUB_END";
5224    let (Some(b), Some(e)) = (base.find(BEGIN), base.find(END)) else {
5225        panic!("scene_dda.wgsl: XS4C stub markers not found");
5226    };
5227    let e_end = e + END.len();
5228    let mut out = String::with_capacity(base.len() + snippet.len());
5229    out.push_str(&base[..b]);
5230    out.push_str(snippet);
5231    out.push_str(&base[e_end..]);
5232    out
5233}
5234
5235/// XS.4 — storage buffers per shader stage needed for GPU sprite shadows. The
5236/// sprite pass binds its own 14 + the terrain occupancy set (occupancy pages
5237/// 0..3, chunk occupancy, slot index, grid meta, per-grid cameras) to march
5238/// terrain shadows. Devices granting fewer fall back to unshadowed GPU sprites.
5239pub(crate) const SPRITE_SHADOW_MIN_STORAGE_BUFFERS: u32 = 22;
5240
5241fn pick_present_mode(modes: &[wgpu::PresentMode]) -> wgpu::PresentMode {
5242    // Prefer Mailbox > Immediate > Fifo. Fifo is the universal
5243    // fallback and the only one Wayland-on-Mesa always offers.
5244    for &m in &[wgpu::PresentMode::Mailbox, wgpu::PresentMode::Immediate] {
5245        if modes.contains(&m) {
5246            return m;
5247        }
5248    }
5249    wgpu::PresentMode::Fifo
5250}
5251
5252/// World-space view-ray direction (un-normalised) for window pixel
5253/// `(x, y)` under a vertical-FOV pinhole — the projection
5254/// `scene_dda.wgsl`'s `render_scene` uses. Shared by
5255/// [`GpuRenderer::pixel_ray`]; standalone so it's unit-testable without
5256/// a device. `right`/`down`/`forward` are the camera basis.
5257#[must_use]
5258#[allow(clippy::too_many_arguments)]
5259pub fn pinhole_pixel_ray(
5260    right: [f64; 3],
5261    down: [f64; 3],
5262    forward: [f64; 3],
5263    x: f64,
5264    y: f64,
5265    w: f64,
5266    h: f64,
5267    fov_y_rad: f64,
5268) -> [f64; 3] {
5269    let half_h = (fov_y_rad * 0.5).tan();
5270    let half_w = half_h * (w / h);
5271    let ndc_x = (x + 0.5) / w * 2.0 - 1.0;
5272    let ndc_y_top = 1.0 - (y + 0.5) / h * 2.0;
5273    let (kx, ky) = (ndc_x * half_w, ndc_y_top * half_h);
5274    [
5275        forward[0] + kx * right[0] - ky * down[0],
5276        forward[1] + kx * right[1] - ky * down[1],
5277        forward[2] + kx * right[2] - ky * down[2],
5278    ]
5279}
5280
5281#[cfg(test)]
5282mod pixel_ray_tests {
5283    use super::pinhole_pixel_ray;
5284
5285    const RIGHT: [f64; 3] = [1.0, 0.0, 0.0];
5286    const DOWN: [f64; 3] = [0.0, 1.0, 0.0];
5287    const FWD: [f64; 3] = [0.0, 0.0, 1.0]; // voxlap z-down "look down"
5288
5289    // Frame centre (NDC 0,0) points straight along `forward`.
5290    #[test]
5291    fn centre_pixel_is_forward() {
5292        let d = pinhole_pixel_ray(
5293            RIGHT,
5294            DOWN,
5295            FWD,
5296            639.5,
5297            359.5,
5298            1280.0,
5299            720.0,
5300            60_f64.to_radians(),
5301        );
5302        assert!(
5303            d[0].abs() < 1e-9 && d[1].abs() < 1e-9,
5304            "centre ≈ forward, got {d:?}"
5305        );
5306        assert!((d[2] - 1.0).abs() < 1e-9);
5307    }
5308
5309    // Right edge pixel tilts +right by tan(hfov/2); the lateral
5310    // component equals half_w = tan(fov_y/2)*aspect at the very edge.
5311    #[test]
5312    fn right_edge_tilts_by_half_w() {
5313        let fov = 60_f64.to_radians();
5314        let d = pinhole_pixel_ray(RIGHT, DOWN, FWD, 1279.5, 359.5, 1280.0, 720.0, fov);
5315        let half_w = (fov * 0.5).tan() * (1280.0 / 720.0);
5316        assert!((d[0] - half_w).abs() < 1e-6, "x={}, half_w={half_w}", d[0]);
5317        assert!(d[0] > 0.0, "right edge tilts +right");
5318    }
5319
5320    /// Statically validate every WGSL shader with naga (the same
5321    /// front-end + validator wgpu runs at pipeline creation), so shader
5322    /// edits — e.g. the GPU.10 sprite lighting bindings — are caught in
5323    /// CI without needing a GPU device.
5324    #[test]
5325    fn wgsl_shaders_validate() {
5326        let shaders: &[(&str, &str)] = &[
5327            (
5328                "sprite_model_dda.wgsl",
5329                include_str!("../shaders/sprite_model_dda.wgsl"),
5330            ),
5331            ("scene_dda.wgsl", include_str!("../shaders/scene_dda.wgsl")),
5332            ("blit.wgsl", include_str!("../shaders/blit.wgsl")),
5333            ("chunk_dda.wgsl", include_str!("../shaders/chunk_dda.wgsl")),
5334            ("grid_dda.wgsl", include_str!("../shaders/grid_dda.wgsl")),
5335            (
5336                "scene_blit.wgsl",
5337                include_str!("../shaders/scene_blit.wgsl"),
5338            ),
5339            (
5340                "scene_resolve.wgsl",
5341                include_str!("../shaders/scene_resolve.wgsl"),
5342            ),
5343            ("line.wgsl", include_str!("../shaders/line.wgsl")),
5344            ("image.wgsl", include_str!("../shaders/image.wgsl")),
5345        ];
5346        let mut validator = naga::valid::Validator::new(
5347            naga::valid::ValidationFlags::all(),
5348            naga::valid::Capabilities::all(),
5349        );
5350        for (name, src) in shaders {
5351            let module = naga::front::wgsl::parse_str(src).unwrap_or_else(|e| {
5352                panic!("{name}: WGSL parse failed:\n{}", e.emit_to_string(src))
5353            });
5354            validator
5355                .validate(&module)
5356                .unwrap_or_else(|e| panic!("{name}: WGSL validation failed: {e:?}"));
5357        }
5358        // XS.4.2 — the raw `sprite_model_dda.wgsl` above is the unshadowed STUB
5359        // variant; also validate the sprite-shadow-CAPABLE spliced variant (the
5360        // terrain-shadow snippet injected) that capable devices build.
5361        let capable = super::sprite_shader_source(true);
5362        let module = naga::front::wgsl::parse_str(&capable).unwrap_or_else(|e| {
5363            panic!(
5364                "sprite_model_dda.wgsl (capable): parse failed:\n{}",
5365                e.emit_to_string(&capable)
5366            )
5367        });
5368        validator.validate(&module).unwrap_or_else(|e| {
5369            panic!("sprite_model_dda.wgsl (capable): validation failed: {e:?}")
5370        });
5371        // XS.4.3 — the capable scene variant (sprite-cast snippet spliced in).
5372        let scene_cap = super::scene_shader_source(true);
5373        let module = naga::front::wgsl::parse_str(&scene_cap).unwrap_or_else(|e| {
5374            panic!(
5375                "scene_dda.wgsl (capable): parse failed:\n{}",
5376                e.emit_to_string(&scene_cap)
5377            )
5378        });
5379        validator
5380            .validate(&module)
5381            .unwrap_or_else(|e| panic!("scene_dda.wgsl (capable): validation failed: {e:?}"));
5382    }
5383
5384    /// A 2×2 world quad centred straight ahead projects to vertices whose
5385    /// homogeneous `w` equals the camera-forward distance (so the shader's
5386    /// `clip = ndc·w` recovers perspective-correct UVs) and whose `depth`
5387    /// is the euclidean range. Verifies geometry without a GPU device.
5388    #[test]
5389    fn image_vertices_carry_forward_w_and_euclidean_depth() {
5390        let cam = crate::GpuLineCamera {
5391            pos: [0.0, 0.0, 0.0],
5392            right: [1.0, 0.0, 0.0],
5393            down: [0.0, 1.0, 0.0],
5394            forward: [0.0, 0.0, 1.0],
5395        };
5396        // Quad 10 units ahead (forward = +Z), spanning x∈[-1,1], y∈[-1,1].
5397        let quad = crate::GpuImageQuad {
5398            corners: [
5399                [-1.0, -1.0, 10.0], // TL
5400                [1.0, -1.0, 10.0],  // TR
5401                [-1.0, 1.0, 10.0],  // BL
5402                [1.0, 1.0, 10.0],   // BR
5403            ],
5404            image: 0,
5405            tint: [1.0, 1.0, 1.0, 1.0],
5406            depth_test: true,
5407            alpha_cutoff: 0.0,
5408        };
5409        let verts = crate::build_image_vertices(&cam, &quad, 800, 600, 60_f32.to_radians(), false);
5410        assert_eq!(verts.len(), 6, "two triangles, no near-clip");
5411        for v in &verts {
5412            assert!((v.w - 10.0).abs() < 1e-4, "w == forward distance");
5413            assert!(v.depth >= 10.0, "euclidean depth >= forward distance");
5414            assert_eq!(v.depth_test, 1.0);
5415        }
5416    }
5417}