Skip to main content

roxlap_gpu/
lib.rs

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