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