Skip to main content

roxlap_gpu/
lib.rs

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