Skip to main content

roxlap_gpu/
lib.rs

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