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