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            world_origin: [0.0; 4],
873            rot0: [1.0, 0.0, 0.0, 0.0],
874            rot1: [0.0, 1.0, 0.0, 0.0],
875            rot2: [0.0, 0.0, 1.0, 0.0],
876        }
877    }
878
879    /// XS.3 — stamp this grid's world transform (for cross-grid shadows).
880    /// `rot_cols[i]` is the world image of grid-local axis `i` (the
881    /// local→world rotation's columns).
882    fn set_world_transform(&mut self, t: &GridWorldTransform) {
883        self.world_origin = [t.origin[0], t.origin[1], t.origin[2], 0.0];
884        self.rot0 = [t.rot_cols[0][0], t.rot_cols[0][1], t.rot_cols[0][2], 0.0];
885        self.rot1 = [t.rot_cols[1][0], t.rot_cols[1][1], t.rot_cols[1][2], 0.0];
886        self.rot2 = [t.rot_cols[2][0], t.rot_cols[2][1], t.rot_cols[2][2], 0.0];
887    }
888}
889
890/// XS.3 — a grid's world transform for cross-grid shadows: world origin +
891/// the local→world rotation columns (`rot_cols[i]` = world image of grid-local
892/// axis `i`). Built host-side per frame from the grid's `GridTransform` and
893/// handed to `SceneRenderer::render_scene` alongside the per-grid cameras.
894#[derive(Clone, Copy)]
895pub struct GridWorldTransform {
896    /// World position of the grid's local origin, voxel units.
897    pub origin: [f32; 3],
898    /// Local→world rotation as columns: `rot_cols[i]` is the world
899    /// image of grid-local axis `i` (unit vectors for a pure rotation).
900    /// Identity for an unrotated grid.
901    pub rot_cols: [[f32; 3]; 3],
902}
903
904impl Default for GridWorldTransform {
905    fn default() -> Self {
906        Self {
907            origin: [0.0; 3],
908            rot_cols: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
909        }
910    }
911}
912
913#[repr(C)]
914#[derive(Clone, Copy, Pod, Zeroable)]
915struct SceneDdaUniform {
916    fov_y_rad: f32,
917    grid_count: u32,
918    max_outer_steps: u32,
919    _pad0: u32,
920    screen_size: [u32; 2],
921    _pad1: [u32; 2],
922    /// GPU.8 — `[r, g, b, fog_near]`. The `near` distance is packed
923    /// into the colour's alpha channel to keep std140 alignment
924    /// tidy (a bare `f32` after the `vec4` would force extra pads).
925    fog_color: [f32; 4],
926    fog_far: f32,
927    /// GPU.9 — `1` when the sprite pass is active (scene pass then
928    /// records `best_t` into the depth buffer), `0` otherwise.
929    write_depth: u32,
930    /// Occupancy paging: words per storage page (see
931    /// `scene::split_occupancy_pages`). Only consulted by the shader
932    /// when `occ_num_pages > 1`.
933    occ_page_words: u32,
934    /// Number of real occupancy pages (1 on multi-GiB GPUs → the
935    /// shader takes a branch-free single-page read).
936    occ_num_pages: u32,
937    /// GPU.11.1 — scene-grid LOD scan distance (world units). A chunk
938    /// entered at world-t `t` marches at mip
939    /// `floor(log2(max(t, msd) / msd))`, clamped to the grid's mip
940    /// count. `0` disables LOD (always mip-0).
941    mip_scan_dist: f32,
942    /// TV.6 — `1` if any mapped terrain material is translucent OR
943    /// emissive (EV.2) — gates the material lookup + accumulate path;
944    /// `0` ⇒ unchanged opaque first-hit march.
945    terrain_has_translucent: u32,
946    /// TV.6 — number of `(rgb, material_id)` entries in the terrain map.
947    terrain_map_count: u32,
948    _pad4: u32,
949    /// World camera used only to derive the per-pixel sky direction —
950    /// always valid, so a `grid_count == 0` (sprite-only / empty) scene
951    /// still paints a proper sky instead of a degenerate `(0,0,1)`
952    /// (whose `atan2(0,0)` sky lookup samples black).
953    sky_cam: SceneDdaPerGridCamera,
954    /// Per-face side-shade intensities (voxlap setsideshades), each the
955    /// u8 shade subtracted from a voxel's brightness byte at a hit.
956    /// `side_shades0 = (top, bot, left, right)`,
957    /// `side_shades1 = (up, down, _, _)`. All-zero = no shading.
958    side_shades0: [i32; 4],
959    side_shades1: [i32; 4],
960    // ── DL — dynamic lighting (appended; all-zero ⇒ pre-DL render) ──
961    /// `rgb` = sun colour, `w` = sun intensity.
962    sun_color: [f32; 4],
963    /// `rgb` = ambient multiplier on the baked byte, `w` = shadow strength.
964    ambient_color: [f32; 4],
965    /// Bit 0 = sun enabled, bit 1 = sun casts shadow.
966    sun_flags: u32,
967    /// Number of point lights per grid (rows in the binding-18 buffer).
968    point_light_count: u32,
969    /// Shadow-ray step budget (DL.3).
970    shadow_max_steps: u32,
971    _pad5: u32,
972    /// Shadow-ray origin bias along the surface normal (voxel units).
973    shadow_bias: f32,
974    /// Sun shadow-ray length cap (world units).
975    shadow_max_dist: f32,
976    _pad6: [f32; 2],
977    /// DL.6 — stylized ramp's cool shadow tint (rgb; w unused).
978    shadow_tint: [f32; 4],
979    /// DL.6 — cel band count; 0 = smooth (no banding / gradient map).
980    style_bands: u32,
981    /// XS.4.3 — visible sprite-instance count for the scene pass's
982    /// sprite-cast shadow march (sprites cast onto terrain). `0` ⇒ no sprite
983    /// casters (the loop is skipped); only consulted by the capable variant.
984    sprite_cast_count: u32,
985    _pad7: [u32; 2],
986}
987
988impl GpuRenderer {
989    /// Stand up the device + surface + swapchain on `window`. Async
990    /// because `wgpu::Adapter`/`Device` requests are.
991    ///
992    /// `window` is any [`raw-window-handle`] provider (winit, SDL,
993    /// GLFW, …) wrapped in an `Arc`; `size` is its initial physical
994    /// framebuffer size in pixels — passed explicitly so the renderer
995    /// stays decoupled from any one windowing library's size API.
996    ///
997    /// [`raw-window-handle`]: raw_window_handle
998    ///
999    /// # Errors
1000    /// Returns [`GpuInitError`] if surface creation, adapter
1001    /// selection, or device request fails. Hosts treat any error as
1002    /// "fall back to the CPU path".
1003    pub async fn new<W>(
1004        window: Arc<W>,
1005        size: (u32, u32),
1006        settings: GpuRendererSettings,
1007    ) -> Result<Self, GpuInitError>
1008    where
1009        W: HasWindowHandle + HasDisplayHandle + Send + Sync + 'static,
1010    {
1011        let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
1012        let surface = instance.create_surface(window.clone())?;
1013        let adapter = Self::request_adapter(&instance, Some(&surface), settings).await?;
1014        let (device, queue) = Self::request_device(&adapter).await?;
1015        Ok(Self::finish_init(
1016            &adapter, device, queue, surface, size, settings,
1017        ))
1018    }
1019
1020    /// wasm/WebGPU: build the renderer against an HTML `canvas`. No
1021    /// `Send + Sync` bound — wgpu's surface/device/queue are `!Send` on
1022    /// the `+atomics` shared-memory wasm build, and the browser host is
1023    /// single-threaded (`Rc<RefCell<…>>`). The native generic-`W` entry
1024    /// (which carries the bound) isn't reachable on wasm.
1025    ///
1026    /// Probes for an adapter **before** `create_surface`: on wasm,
1027    /// creating the surface calls `canvas.getContext("webgpu")`, which
1028    /// permanently locks the canvas's context type. If we bound it and
1029    /// then found no adapter, a CPU/WebGL2 fallback on the *same* canvas
1030    /// (the facade clones the handle, but it's the same DOM element)
1031    /// would fail with "no webgl2 context". Probing first leaves the
1032    /// canvas pristine when WebGPU is unavailable.
1033    ///
1034    /// # Errors
1035    /// See [`Self::new`].
1036    #[cfg(target_arch = "wasm32")]
1037    pub async fn new_from_canvas(
1038        canvas: web_sys::HtmlCanvasElement,
1039        size: (u32, u32),
1040        settings: GpuRendererSettings,
1041    ) -> Result<Self, GpuInitError> {
1042        let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
1043        // Probe adapter AND device before binding the canvas — both
1044        // `requestAdapter` and `requestDevice` can fail on wasm, and
1045        // `create_surface` permanently locks the canvas to a WebGPU
1046        // context. Creating the surface last keeps the canvas pristine
1047        // for the CPU/WebGL2 fallback on any GPU-init failure.
1048        let adapter = Self::request_adapter(&instance, None, settings).await?;
1049        let (device, queue) = Self::request_device(&adapter).await?;
1050        let surface = instance.create_surface(wgpu::SurfaceTarget::Canvas(canvas))?;
1051        Ok(Self::finish_init(
1052            &adapter, device, queue, surface, size, settings,
1053        ))
1054    }
1055
1056    /// Pick a GPU adapter at the settings' power preference. `None`
1057    /// `compatible_surface` is used on the wasm canvas path so the probe
1058    /// doesn't bind the canvas's context (see [`Self::new_from_canvas`]);
1059    /// WebGPU exposes a single surface-independent adapter, so this is
1060    /// safe there.
1061    async fn request_adapter(
1062        instance: &wgpu::Instance,
1063        compatible_surface: Option<&wgpu::Surface<'static>>,
1064        settings: GpuRendererSettings,
1065    ) -> Result<wgpu::Adapter, GpuInitError> {
1066        // QE-C6: this crate reads no environment — the render facade
1067        // (roxlap-render's `env_config`) resolves the `ROXLAP_GPU_POWER`
1068        // escape hatch into `settings.power_preference` before init.
1069        // `Low` matters on broken hybrid-GPU (PRIME) driver stacks,
1070        // where rendering on the display-owning iGPU avoids the
1071        // cross-GPU present entirely. (A nixos mesa update deadlocked
1072        // the nouveau↔i915 explicit-sync fences: dGPU frames hit the
1073        // drm job timeout and the channel was killed; `low` kept the
1074        // demo alive.)
1075        let power_preference = match settings.power_preference {
1076            PowerPreference::Low => wgpu::PowerPreference::LowPower,
1077            PowerPreference::High => wgpu::PowerPreference::HighPerformance,
1078        };
1079        instance
1080            .request_adapter(&wgpu::RequestAdapterOptions {
1081                power_preference,
1082                compatible_surface,
1083                force_fallback_adapter: false,
1084            })
1085            .await
1086            .map_err(|_| GpuInitError::NoAdapter)
1087    }
1088
1089    /// Request the device + queue from `adapter`. Pulled out of
1090    /// [`Self::finish_init`] so the wasm canvas path can validate the
1091    /// device **before** `create_surface` binds the canvas's WebGPU
1092    /// context — if the device request fails (e.g. a browser that
1093    /// rejects a wgpu-sent limit), the canvas stays pristine for the
1094    /// CPU/WebGL2 fallback instead of being poisoned.
1095    async fn request_device(
1096        adapter: &wgpu::Adapter,
1097    ) -> Result<(wgpu::Device, wgpu::Queue), GpuInitError> {
1098        Ok(adapter
1099            .request_device(&wgpu::DeviceDescriptor {
1100                label: Some("roxlap-gpu device"),
1101                required_features: wgpu::Features::empty(),
1102                required_limits: pick_required_limits(&adapter.limits()),
1103                experimental_features: wgpu::ExperimentalFeatures::disabled(),
1104                memory_hints: wgpu::MemoryHints::default(),
1105                trace: wgpu::Trace::Off,
1106            })
1107            .await?)
1108    }
1109
1110    /// Shared swapchain → sky/sampler setup, run after the adapter +
1111    /// device + surface exist (the surface comes from a window handle on
1112    /// native, or an HTML canvas on wasm — created last on wasm so a
1113    /// failed device request never touches the canvas).
1114    fn finish_init(
1115        adapter: &wgpu::Adapter,
1116        device: wgpu::Device,
1117        queue: wgpu::Queue,
1118        surface: wgpu::Surface<'static>,
1119        size: (u32, u32),
1120        settings: GpuRendererSettings,
1121    ) -> Self {
1122        let info = adapter.get_info();
1123        let adapter_info = format!(
1124            "{name} ({backend:?}, {device_type:?})",
1125            name = info.name,
1126            backend = info.backend,
1127            device_type = info.device_type,
1128        );
1129        let low_power = info.device_type != wgpu::DeviceType::DiscreteGpu;
1130
1131        let caps = surface.get_capabilities(adapter);
1132        // Pick a NON-sRGB, 8-bit swapchain format. Voxlap colours are
1133        // already sRGB-encoded (the slab bytes are display-ready,
1134        // matching what the CPU softbuffer path writes straight to the
1135        // framebuffer with no conversion); an sRGB swapchain would
1136        // re-apply the gamma curve, washing the look out. We also
1137        // *prefer 8-bit BGRA/RGBA* over any other non-sRGB format: some
1138        // adapters (e.g. NVK) advertise a 16-bit-unorm format first,
1139        // and wgpu 29 gates `create_view` on 16-bit-norm formats behind
1140        // the `TEXTURE_FORMAT_16BIT_NORM` device feature (which we don't
1141        // enable, to stay WebGPU-portable). Falls back to the first
1142        // non-sRGB format, then `caps.formats[0]`.
1143        let surface_format = caps
1144            .formats
1145            .iter()
1146            .copied()
1147            .find(|f| {
1148                matches!(
1149                    f,
1150                    wgpu::TextureFormat::Bgra8Unorm | wgpu::TextureFormat::Rgba8Unorm
1151                )
1152            })
1153            .or_else(|| caps.formats.iter().copied().find(|f| !f.is_srgb()))
1154            .unwrap_or(caps.formats[0]);
1155        let present_mode = if settings.uncapped_present {
1156            pick_present_mode(&caps.present_modes)
1157        } else {
1158            wgpu::PresentMode::Fifo
1159        };
1160        // GPU.11.2 — surface the present mode: `Fifo` is vsync-capped
1161        // (FPS pinned to refresh rate → compute optimisations like the
1162        // mip LOD won't show up in the FPS counter). Mailbox/Immediate
1163        // are uncapped. Wayland under Mesa frequently offers only Fifo.
1164        eprintln!(
1165            "roxlap-gpu: present mode = {present_mode:?} (available: {:?})",
1166            caps.present_modes,
1167        );
1168        let (init_w, init_h) = size;
1169        let surface_config = wgpu::SurfaceConfiguration {
1170            usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
1171            format: surface_format,
1172            width: init_w.max(1),
1173            height: init_h.max(1),
1174            present_mode,
1175            alpha_mode: caps.alpha_modes[0],
1176            view_formats: vec![],
1177            desired_maximum_frame_latency: 2,
1178        };
1179        surface.configure(&device, &surface_config);
1180
1181        // GPU.8 default sky: a 1×1 mid-grey texture. Hosts replace
1182        // it via `set_sky_panorama` with a real equirectangular
1183        // panorama; the default stops the shader sampling
1184        // uninitialised memory before that happens.
1185        let default_sky_pixel = [0x80u8, 0x80, 0x80, 0xff];
1186        let (sky_texture, sky_view) = create_sky_texture(&device, 1, 1, &default_sky_pixel);
1187        queue.write_texture(
1188            wgpu::TexelCopyTextureInfo {
1189                texture: &sky_texture,
1190                mip_level: 0,
1191                origin: wgpu::Origin3d::ZERO,
1192                aspect: wgpu::TextureAspect::All,
1193            },
1194            &default_sky_pixel,
1195            wgpu::TexelCopyBufferLayout {
1196                offset: 0,
1197                bytes_per_row: Some(4),
1198                rows_per_image: Some(1),
1199            },
1200            wgpu::Extent3d {
1201                width: 1,
1202                height: 1,
1203                depth_or_array_layers: 1,
1204            },
1205        );
1206        let sky_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
1207            label: Some("roxlap-gpu sky_sampler"),
1208            // Voxlap-convention panorama: u = elevation [0, 1]
1209            // (Repeat is a no-op since values don't go outside),
1210            // v = azimuth (wraps 360° — Repeat is required).
1211            address_mode_u: wgpu::AddressMode::Repeat,
1212            address_mode_v: wgpu::AddressMode::Repeat,
1213            address_mode_w: wgpu::AddressMode::ClampToEdge,
1214            mag_filter: wgpu::FilterMode::Linear,
1215            min_filter: wgpu::FilterMode::Linear,
1216            mipmap_filter: wgpu::MipmapFilterMode::Nearest,
1217            ..Default::default()
1218        });
1219
1220        // XS.4 — did the device grant enough storage buffers per stage for the
1221        // GPU sprite-shadow cross-pass bindings? If not, sprites render
1222        // unshadowed (the CPU backend still has full sprite shadows).
1223        let sprite_shadows_capable = device.limits().max_storage_buffers_per_shader_stage
1224            >= SPRITE_SHADOW_MIN_STORAGE_BUFFERS;
1225
1226        Self {
1227            surface,
1228            surface_config,
1229            device,
1230            queue,
1231            adapter_info,
1232            low_power,
1233            clear_colour: settings.clear_colour,
1234            frame_count: 0,
1235            flip_x: false,
1236            render_res: RenderResolution::Native,
1237            ssaa: 1,
1238            posterize: None,
1239            scene_dda: None,
1240            scene_materials: Box::new(
1241                [MaterialGpu {
1242                    alpha: 1.0,
1243                    mode: 0,
1244                    emissive: 0.0,
1245                    _pad: 0,
1246                }; 256],
1247            ),
1248            scene_terrain_map: Vec::new(),
1249            scene_terrain_translucent: false,
1250            dirty: FrameDirty::default(),
1251            sky_texture,
1252            sky_view,
1253            sky_sampler,
1254            // Fog disabled by default — voxlap's CPU rasterizer
1255            // also runs without fog in the scene-demo, so matching
1256            // it means no GPU fog out of the box. Hosts can opt in
1257            // via `set_fog` (e.g. for atmospheric far-LOD masking).
1258            fog_color: [0.66, 0.74, 0.88],
1259            fog_near: 0.0,
1260            fog_far: 1.0e30,
1261            sprite_registry: None,
1262            sprite_model_dda: None,
1263            sprite_shadows_capable,
1264            sprite_materials: Box::new(
1265                [MaterialGpu {
1266                    alpha: 1.0,
1267                    mode: 0,
1268                    emissive: 0.0,
1269                    _pad: 0,
1270                }; 256],
1271            ),
1272            sprite_has_translucent: false,
1273            // GPU.10.4 — default LOD threshold: step to a coarser mip
1274            // once a voxel projects below 4 px. Empirically the best
1275            // quality/cost tradeoff; the host can override.
1276            sprite_lod_px: 1.0,
1277            // GPU.11.1 — matches the CPU demo's mip_scan_dist=64.
1278            scene_mip_scan_dist: 64.0,
1279            scene_side_shades: [[0; 4]; 2],
1280            scene_lights: SceneLights::default(),
1281            lights_sun_flags: 0,
1282            lights_point_count: 0,
1283            lights_packed_grids: 0,
1284            last_fov_y_rad: 0.0,
1285            pending_frame: None,
1286            frame_pack: None,
1287            line_resources: None,
1288            line_vbuf: None,
1289            line_vbuf_cap: 0,
1290            line_bg_cache: None,
1291            image_resources: None,
1292            image_vbuf: None,
1293            image_vbuf_cap: 0,
1294            image_bg_cache: std::collections::HashMap::new(),
1295            image_bg_depth: None,
1296            images: Vec::new(),
1297            #[cfg(feature = "hud")]
1298            egui_renderer: None,
1299        }
1300    }
1301
1302    /// Synchronous wrapper for hosts that don't have an async
1303    /// runtime. Internally `pollster::block_on`s [`Self::new`].
1304    ///
1305    /// # Errors
1306    /// See [`Self::new`].
1307    #[cfg(not(target_arch = "wasm32"))]
1308    pub fn new_blocking<W>(
1309        window: Arc<W>,
1310        size: (u32, u32),
1311        settings: GpuRendererSettings,
1312    ) -> Result<Self, GpuInitError>
1313    where
1314        W: HasWindowHandle + HasDisplayHandle + Send + Sync + 'static,
1315    {
1316        pollster::block_on(Self::new(window, size, settings))
1317    }
1318
1319    /// Human-readable adapter description — name + backend +
1320    /// device type. The demo host prints this in the title bar.
1321    pub fn adapter_info(&self) -> &str {
1322        &self.adapter_info
1323    }
1324
1325    /// `true` when the adapter is NOT a discrete GPU (integrated,
1326    /// software rasterizer, virtual, unknown) — a hint that hosts
1327    /// should default to a lighter render resolution.
1328    pub fn low_power(&self) -> bool {
1329        self.low_power
1330    }
1331
1332    /// Borrow the underlying wgpu device — hosts use this to build
1333    /// chunk uploads (`GpuChunkResident::upload(gpu.device(), …)`).
1334    pub fn device(&self) -> &wgpu::Device {
1335        &self.device
1336    }
1337
1338    /// XS.4 — whether this device can run GPU sprite shadows (it granted
1339    /// enough storage buffers per shader stage for the cross-pass occupancy
1340    /// bindings). `false` ⇒ GPU sprites render unshadowed; the CPU backend
1341    /// always has sprite shadows. Lets the facade/host report the fallback.
1342    #[must_use]
1343    pub fn sprite_shadows_capable(&self) -> bool {
1344        self.sprite_shadows_capable
1345    }
1346
1347    /// Borrow the wgpu queue — hosts use this for read-back paths
1348    /// (`GpuChunkResident::read_voxel_blocking(gpu.device(), gpu.queue(), …)`).
1349    pub fn queue(&self) -> &wgpu::Queue {
1350        &self.queue
1351    }
1352
1353    /// GPU.8 — upload an equirectangular panorama as the scene's
1354    /// sky texture. `rgba` is row-major, `width × height` pixels,
1355    /// 4 bytes per pixel (R, G, B, A). The shader samples it with
1356    /// `u = atan2(dir.x, dir.y) / (2π) + 0.5` (azimuth) and
1357    /// `v = acos(-dir.z) / π` (elevation), matching standard
1358    /// equirectangular layout (top of image = zenith for voxlap's
1359    /// `+z = down` basis).
1360    /// Mirror the marched scene (and its line/image overlays) horizontally
1361    /// on present, leaving the egui overlay upright. See `Self::flip_x`.
1362    pub fn set_flip_x(&mut self, flip: bool) {
1363        self.flip_x = flip;
1364    }
1365
1366    ///
1367    /// # Panics
1368    /// If `rgba.len() != (width * height * 4) as usize`.
1369    pub fn set_sky_panorama(&mut self, rgba: &[u8], width: u32, height: u32) {
1370        assert_eq!(
1371            rgba.len(),
1372            (width as usize) * (height as usize) * 4,
1373            "set_sky_panorama: expected w*h*4 bytes, got {}",
1374            rgba.len(),
1375        );
1376        let (tex, view) = create_sky_texture(&self.device, width, height, rgba);
1377        // Upload pixel data via `queue.write_texture` so we don't
1378        // have to map the buffer manually.
1379        self.queue.write_texture(
1380            wgpu::TexelCopyTextureInfo {
1381                texture: &tex,
1382                mip_level: 0,
1383                origin: wgpu::Origin3d::ZERO,
1384                aspect: wgpu::TextureAspect::All,
1385            },
1386            rgba,
1387            wgpu::TexelCopyBufferLayout {
1388                offset: 0,
1389                bytes_per_row: Some(width * 4),
1390                rows_per_image: Some(height),
1391            },
1392            wgpu::Extent3d {
1393                width,
1394                height,
1395                depth_or_array_layers: 1,
1396            },
1397        );
1398        self.sky_texture = tex;
1399        self.sky_view = view;
1400    }
1401
1402    /// GPU.8 — set the fog blend. `color` is per-channel [0, 1];
1403    /// `near`/`far` are world-space ray distances in voxel units.
1404    /// Hits with `t < near` show their full colour; hits with
1405    /// `t > far` show `color` exclusively; in between is a
1406    /// smoothstep blend.
1407    pub fn set_fog(&mut self, color: [f32; 3], near: f32, far: f32) {
1408        self.fog_color = color;
1409        self.fog_near = near;
1410        self.fog_far = far.max(near + 1.0);
1411    }
1412
1413    /// Re-configure the swapchain to a new physical size. Call from
1414    /// `WindowEvent::Resized`. The scene resources rebuild lazily at
1415    /// the new size on the next [`Self::render_scene`].
1416    pub fn resize(&mut self, width: u32, height: u32) {
1417        if width == 0 || height == 0 {
1418            return;
1419        }
1420        self.surface_config.width = width;
1421        self.surface_config.height = height;
1422        self.surface.configure(&self.device, &self.surface_config);
1423        self.scene_dda = None;
1424    }
1425
1426    /// RP.0 — set the logical render resolution. Rebuilds the scene-DDA
1427    /// resources on the next [`Self::render_scene`] when the render size
1428    /// changes.
1429    pub fn set_render_resolution(&mut self, res: RenderResolution) {
1430        self.render_res = res;
1431    }
1432
1433    /// RP.1 — set the supersampling factor (clamped to `1..=4`). `1` = off.
1434    pub fn set_ssaa(&mut self, factor: u8) {
1435        self.ssaa = u32::from(factor).clamp(1, 4);
1436    }
1437
1438    /// RP.2 — set (or clear) the posterize post. Applied per-frame via the
1439    /// resolve uniform, so no pipeline rebuild is needed.
1440    pub fn set_posterize(&mut self, cfg: Option<PosterizeGpu>) {
1441        self.posterize = cfg;
1442    }
1443
1444    /// RP.0 — the logical (retro) grid size the scene resolves to before the
1445    /// upscale, resolved against the swapchain size. `logical_dims ==
1446    /// surface_dims` under [`RenderResolution::Native`].
1447    #[must_use]
1448    pub fn logical_dims(&self) -> (u32, u32) {
1449        self.render_res.logical_for(self.surface_dims())
1450    }
1451
1452    /// RP.1 — the resolution the scene/sprite passes actually march at:
1453    /// `logical_dims × ssaa`. The framebuffer + depth buffer are sized to this.
1454    #[must_use]
1455    pub fn render_dims(&self) -> (u32, u32) {
1456        let (lw, lh) = self.logical_dims();
1457        (lw * self.ssaa, lh * self.ssaa)
1458    }
1459
1460    /// RP.0 — the swapchain (native window) size.
1461    #[must_use]
1462    pub fn surface_dims(&self) -> (u32, u32) {
1463        (self.surface_config.width, self.surface_config.height)
1464    }
1465
1466    /// Acquire the next swapchain frame, or `None` to skip this frame.
1467    /// wgpu 29's `get_current_texture` returns a
1468    /// [`wgpu::CurrentSurfaceTexture`] status enum (was
1469    /// `Result<_, SurfaceError>`): an outdated/lost surface reconfigures
1470    /// and skips, transient statuses just skip.
1471    fn acquire_frame(&self) -> Option<wgpu::SurfaceTexture> {
1472        use wgpu::CurrentSurfaceTexture as C;
1473        match self.surface.get_current_texture() {
1474            C::Success(t) | C::Suboptimal(t) => Some(t),
1475            C::Outdated | C::Lost => {
1476                self.surface.configure(&self.device, &self.surface_config);
1477                None
1478            }
1479            C::Timeout | C::Occluded | C::Validation => None,
1480        }
1481    }
1482
1483    /// GPU.1 render: single render pass clearing the swapchain to a
1484    /// slowly drifting colour, then presenting. Voxels arrive in
1485    /// GPU.3+.
1486    pub fn render(&mut self) {
1487        let Some(surf_tex) = self.acquire_frame() else {
1488            return;
1489        };
1490        let view = surf_tex
1491            .texture
1492            .create_view(&wgpu::TextureViewDescriptor::default());
1493
1494        // Slow colour drift so the user can tell the GPU path is
1495        // actually presenting frames vs. e.g. a frozen window.
1496        // Wrap at 2π/0.005 frames (~1257) so the cast stays exact.
1497        let phase = f64::from(self.frame_count % 1257) * 0.005;
1498        let [r, g, b] = self.clear_colour;
1499        let drift = (phase.sin() * 0.04 + 0.04).clamp(0.0, 0.1);
1500        let clear = wgpu::Color {
1501            r: (r + drift).clamp(0.0, 1.0),
1502            g: (g + drift * 0.5).clamp(0.0, 1.0),
1503            b: (b + drift * 0.25).clamp(0.0, 1.0),
1504            a: 1.0,
1505        };
1506
1507        let mut encoder = self
1508            .device
1509            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
1510                label: Some("roxlap-gpu encoder"),
1511            });
1512        {
1513            let _rp = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1514                label: Some("roxlap-gpu clear"),
1515                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1516                    view: &view,
1517                    depth_slice: None,
1518                    resolve_target: None,
1519                    ops: wgpu::Operations {
1520                        load: wgpu::LoadOp::Clear(clear),
1521                        store: wgpu::StoreOp::Store,
1522                    },
1523                })],
1524                depth_stencil_attachment: None,
1525                timestamp_writes: None,
1526                occlusion_query_set: None,
1527                multiview_mask: None,
1528            });
1529        }
1530        self.queue.submit(std::iter::once(encoder.finish()));
1531        surf_tex.present();
1532        self.frame_count = self.frame_count.wrapping_add(1);
1533    }
1534
1535    /// GPU.5 render — multi-grid scene marcher. `cameras[i]` is the
1536    /// world camera transformed into grid `i`'s local frame
1537    /// (caller-supplied; see scene-demo's `redraw_gpu` for the
1538    /// glam-based transform). `fov_y_rad` is the shared vertical
1539    /// FOV; `max_outer_steps` caps per-ray chunk-DDA work for each
1540    /// grid.
1541    ///
1542    /// # Panics
1543    /// If `cameras.len() != scene.grid_count`.
1544    /// `cameras[i]` is grid `i`'s world camera transformed into that
1545    /// grid's local frame (the grid marcher works in grid-local space).
1546    /// `sprite_camera` is the **world** camera: instanced sprites carry
1547    /// world-space positions/transforms, so they must project through
1548    /// the untransformed world camera — not `cameras[0]`, which is only
1549    /// the world camera when grid 0 is at identity.
1550    pub fn render_scene(
1551        &mut self,
1552        scene: &GpuSceneResident,
1553        cameras: &[Camera],
1554        // XS.3 — per-grid world transforms (parallel to `cameras`) for
1555        // cross-grid shadows. Empty ⇒ identity (shadows stay intra-grid).
1556        grid_world: &[GridWorldTransform],
1557        sprite_camera: &Camera,
1558        fov_y_rad: f32,
1559        max_outer_steps: u32,
1560    ) {
1561        assert_eq!(
1562            cameras.len(),
1563            scene.grid_count as usize,
1564            "render_scene: {} cameras supplied, scene has {} grids",
1565            cameras.len(),
1566            scene.grid_count,
1567        );
1568        self.last_fov_y_rad = fov_y_rad; // cached for pixel_ray (picking)
1569
1570        // Deferred present: drop any frame a prior render left
1571        // un-presented (a host that skipped present/paint_egui) so we
1572        // never hold two outstanding swapchain textures.
1573        self.pending_frame = None;
1574        let Some(surf_tex) = self.acquire_frame() else {
1575            return;
1576        };
1577        let surf_view = surf_tex
1578            .texture
1579            .create_view(&wgpu::TextureViewDescriptor::default());
1580
1581        let surface_w = self.surface_config.width;
1582        let surface_h = self.surface_config.height;
1583        let surface_format = self.surface_config.format;
1584        // RP.0/RP.1 — the scene + sprite + depth passes march at the *render*
1585        // size (`logical × ssaa`); a resolve pass box-downfilters to the
1586        // logical grid; the blit nearest-upscales to the swapchain. The
1587        // framebuffer/depth/occupancy + per-pixel projection key off the render
1588        // (march) size. `Native` + `ssaa==1` ⇒ render == logical == surface.
1589        let (logical_w, logical_h) = self.logical_dims();
1590        let (render_w, render_h) = self.render_dims();
1591
1592        let needs_build = match &self.scene_dda {
1593            Some(r) => {
1594                r.storage_size != (render_w, render_h) || r.logical_size != (logical_w, logical_h)
1595            }
1596            None => true,
1597        };
1598        if needs_build {
1599            self.scene_dda = Some(self.build_scene_dda(
1600                render_w,
1601                render_h,
1602                logical_w,
1603                logical_h,
1604                surface_w,
1605                surface_h,
1606                surface_format,
1607            ));
1608        }
1609        // GPU.9 — materialise the sprite pipeline the first frame
1610        // sprites are present (before the immutable `dda` borrow).
1611        // GPU.10.0 — build the model-DDA pipeline the first frame a
1612        // sprite registry is present.
1613        if self.sprite_registry.is_some() && self.sprite_model_dda.is_none() {
1614            self.sprite_model_dda = Some(self.build_sprite_model_dda());
1615        }
1616        // GPU.10.3 — frustum-cull + screen-tile-bin the sprite instances
1617        // (needs &mut self for buffer growth, so before the immutable
1618        // scene_dda borrow). Captures (visible_count, tiles_x); None when
1619        // nothing is in view.
1620        let sprite_pass: Option<(u32, u32)> = if let Some(reg) = self.sprite_registry.as_mut() {
1621            if reg.instance_capacity > 0 {
1622                // World camera — sprite positions/transforms are world-
1623                // space (independent of any grid's transform).
1624                let cam = sprite_camera;
1625                // Aspect + tile binning are in render (logical) space — the
1626                // sprite pass writes the render-sized framebuffer/depth.
1627                #[allow(clippy::cast_precision_loss)]
1628                let aspect = render_w as f32 / render_h as f32;
1629                let half_h = (fov_y_rad * 0.5).tan();
1630                let frustum = sprite_model::ViewFrustum {
1631                    pos: cam.position,
1632                    right: cam.right,
1633                    down: cam.down,
1634                    forward: cam.forward,
1635                    half_w: half_h * aspect,
1636                    half_h,
1637                    far: 1.0e9,
1638                };
1639                let (visible, tiles_x, _tiles_y) = reg.cull_bin_upload(
1640                    &self.device,
1641                    &self.queue,
1642                    &frustum,
1643                    render_w,
1644                    render_h,
1645                    SPRITE_TILE_SIZE,
1646                    self.sprite_lod_px,
1647                );
1648                (visible > 0).then_some((visible, tiles_x))
1649            } else {
1650                None
1651            }
1652        } else {
1653            None
1654        };
1655        let dda = self.scene_dda.as_ref().expect("just built");
1656
1657        // Refresh the blit's flip flag each frame (offset 16, after the
1658        // src + dst vec2 sizes), so toggling the flip applies without a
1659        // resize. The src/dst sizes themselves are written at build time
1660        // (a render/surface size change forces a rebuild).
1661        self.queue.write_buffer(
1662            &dda.blit_dims,
1663            16,
1664            bytemuck::bytes_of(&[u32::from(self.flip_x), 0u32]),
1665        );
1666        // RP.2 — refresh the resolve pass's posterize fields each frame (offset
1667        // 20, after src/dst dims + ssaa). `None` ⇒ `levels = [1,1,1]`, `dither
1668        // = 0` ⇒ the resolve does box-downfilter only (RP.1).
1669        let (plevels, pdither) = match self.posterize {
1670            Some(p) => (p.levels, p.dither),
1671            None => ([1u32; 3], 0u32),
1672        };
1673        self.queue.write_buffer(
1674            &dda.resolve_dims,
1675            20,
1676            bytemuck::bytes_of(&[plevels[0], plevels[1], plevels[2], pdither]),
1677        );
1678
1679        // Pack per-grid cameras into a runtime-sized storage buffer
1680        // (binding 15) — no fixed cap on grid count.
1681        let mut cam_vec: Vec<SceneDdaPerGridCamera> = cameras
1682            .iter()
1683            .map(SceneDdaPerGridCamera::from_camera)
1684            .collect();
1685        // XS.3 — stamp each grid's world transform for cross-grid shadows.
1686        for (c, t) in cam_vec.iter_mut().zip(grid_world.iter()) {
1687            c.set_world_transform(t);
1688        }
1689
1690        // DL — pack the per-frame lights (already grid-local). The per-grid
1691        // sun direction rides in each `PerGridCamera.sun_dir` (binding 15);
1692        // point lights go in one storage buffer (binding 18). All-zero
1693        // ⇒ the pre-DL render. Shared with the headless path.
1694        // PF.4 — pack CPU-side (no clone of `scene_lights`), then write into
1695        // the persistent grow-only buffers instead of `create_buffer_init`-ing
1696        // fresh ones (which also forced a bind-group rebuild) every frame.
1697        if self.frame_pack.is_none() {
1698            self.frame_pack = Some(FramePackBuffers::new(&self.device));
1699        }
1700        let lights = &self.scene_lights;
1701        // Sun dirs ride in the per-frame camera vector — inject every frame.
1702        inject_grid_sun_dirs(lights, &mut cam_vec);
1703        let fp = self.frame_pack.as_mut().expect("just built");
1704        fp.write_cameras(&self.device, &self.queue, &cam_vec);
1705        // PF.5 — re-pack + re-upload the grid-major point lights only when
1706        // the rig changed (or the grid count did — the rows depend on it).
1707        if self.dirty.scene_lights || self.lights_packed_grids != scene.grid_count {
1708            let (packed_lights, sun_flags, point_count) =
1709                pack_scene_lights(lights, scene.grid_count as usize);
1710            fp.write_point_lights(&self.device, &self.queue, &packed_lights);
1711            self.lights_sun_flags = sun_flags;
1712            self.lights_point_count = point_count;
1713            self.lights_packed_grids = scene.grid_count;
1714            self.dirty.scene_lights = false;
1715        }
1716        let (sun_flags, point_count) = (self.lights_sun_flags, self.lights_point_count);
1717
1718        let uniform = SceneDdaUniform {
1719            fov_y_rad,
1720            grid_count: scene.grid_count,
1721            max_outer_steps,
1722            _pad0: 0,
1723            screen_size: [render_w, render_h],
1724            _pad1: [0; 2],
1725            fog_color: [
1726                self.fog_color[0],
1727                self.fog_color[1],
1728                self.fog_color[2],
1729                self.fog_near,
1730            ],
1731            fog_far: self.fog_far,
1732            // L3.1: always write scene depth. Costs one storage store per
1733            // pixel, and the depth is needed for sprite z-test, sprite-less
1734            // `pick_depth`, and `draw_lines` occlusion alike.
1735            write_depth: 1,
1736            occ_page_words: scene.occupancy_page_words,
1737            occ_num_pages: scene.occupancy_num_pages,
1738            mip_scan_dist: self.scene_mip_scan_dist,
1739            terrain_has_translucent: u32::from(self.scene_terrain_translucent),
1740            terrain_map_count: self.scene_terrain_map.len() as u32,
1741            _pad4: 0,
1742            // Sky direction comes from the world (sprite) camera, so a
1743            // grid-less sprite-only scene still paints a real sky.
1744            sky_cam: SceneDdaPerGridCamera::from_camera(sprite_camera),
1745            side_shades0: self.scene_side_shades[0],
1746            side_shades1: self.scene_side_shades[1],
1747            sun_color: [
1748                lights.sun_color[0],
1749                lights.sun_color[1],
1750                lights.sun_color[2],
1751                lights.sun_intensity,
1752            ],
1753            ambient_color: [
1754                lights.ambient[0],
1755                lights.ambient[1],
1756                lights.ambient[2],
1757                lights.shadow_strength,
1758            ],
1759            sun_flags,
1760            point_light_count: point_count,
1761            shadow_max_steps: lights.shadow_max_steps,
1762            _pad5: 0,
1763            shadow_bias: lights.shadow_bias,
1764            shadow_max_dist: lights.shadow_max_dist,
1765            _pad6: [0.0; 2],
1766            shadow_tint: [
1767                lights.shadow_tint[0],
1768                lights.shadow_tint[1],
1769                lights.shadow_tint[2],
1770                0.0,
1771            ],
1772            style_bands: lights.style_bands,
1773            // XS.4.3 — visible sprite casters for the scene-pass cast march
1774            // (only when the device is sprite-shadow capable; else the cast
1775            // bindings/loop are absent).
1776            sprite_cast_count: if self.sprite_shadows_capable {
1777                sprite_pass.map_or(0, |(visible, _)| visible)
1778            } else {
1779                0
1780            },
1781            _pad7: [0; 2],
1782        };
1783        self.queue
1784            .write_buffer(&dda.uniform_buf, 0, bytemuck::bytes_of(&uniform));
1785
1786        // PF.4 — cached bind group, keyed on the exact resources bound.
1787        // Occupancy page 0 at binding 1; pages 1..MAX_OCC_PAGES at 12..
1788        // (GPU.X paging). Per-grid point lights at 18 (DL); the per-grid
1789        // sun dir rides in PerGridCamera.sun_dir (binding 15).
1790        let mut dda_bufs: Vec<(u32, wgpu::Buffer)> = vec![
1791            (0, dda.uniform_buf.clone()),
1792            (1, scene.occupancy_pages[0].clone()),
1793            (2, scene.all_color_offsets.clone()),
1794            (3, scene.all_colors.clone()),
1795            (4, scene.all_chunk_colors_base.clone()),
1796            (5, scene.all_chunk_occupancy.clone()),
1797            (6, scene.grid_static_meta.clone()),
1798            (7, scene.all_slot_chunk_idx.clone()),
1799            (8, dda.framebuffer.clone()),
1800            (11, dda.depth_buffer.clone()),
1801            (12, scene.occupancy_pages[1].clone()),
1802            (13, scene.occupancy_pages[2].clone()),
1803            (14, scene.occupancy_pages[3].clone()),
1804            (15, fp.grid_cameras.clone()),
1805            (16, dda.materials_pal_buf.clone()),
1806            (17, dda.terrain_map_buf.clone()),
1807            (18, fp.point_lights.clone()),
1808        ];
1809        // XS.4.3 — sprite-cast bindings (19..21). On a capable device the BGL
1810        // has them, so bind the sprite registry when present (terrain shadow
1811        // rays test sprite volumes), else the dummy (sprite_cast_count == 0).
1812        if self.sprite_shadows_capable {
1813            let dummy = dda
1814                .sprite_cast_dummy
1815                .as_ref()
1816                .expect("capable scene_dda has a sprite-cast dummy");
1817            let (insts, models, occ) = match &self.sprite_registry {
1818                Some(reg) => (&reg.instances, &reg.model_meta, &reg.occupancy),
1819                None => (dummy, dummy, dummy),
1820            };
1821            dda_bufs.push((19, insts.clone()));
1822            dda_bufs.push((20, models.clone()));
1823            dda_bufs.push((21, occ.clone()));
1824        }
1825        let dda_bg = cached_bind_group(
1826            &mut fp.dda_bg,
1827            &self.device,
1828            "roxlap-gpu scene_dda.bg",
1829            &dda.bgl_dda,
1830            dda_bufs,
1831            vec![(9, self.sky_view.clone())],
1832            &[(10, &self.sky_sampler)],
1833        )
1834        .clone();
1835
1836        // GPU.9 — when sprites are present, build both splatter bind
1837        // groups up front (the splat pass writes the key buffer; the
1838        // resolve pass reads keys + scene depth and writes colour).
1839        // GPU.10.3 — model-DDA bind group + per-frame uniform, using the
1840        // cull/bin results captured above. Per-model + per-instance data
1841        // + the tile lists live in the registry buffers.
1842        let sprite_model_bg = match (&self.sprite_model_dda, &self.sprite_registry, sprite_pass) {
1843            (Some(smd), Some(reg), Some((visible, tiles_x))) => {
1844                // World camera (see the cull pass above) — sprites
1845                // project through it regardless of grid 0's transform.
1846                let cam = sprite_camera;
1847                // DL.4 — world-space lights for the sprite pass (sprites are
1848                // world-space, not grid-local). No sprite shadows (deferred).
1849                let dl = &self.scene_lights;
1850                let sprite_sun_enabled = dl.world_sun_dir != [0.0; 3];
1851                let sprite_point_count = dl.world_points.len().min(MAX_POINT_LIGHTS) as u32;
1852                // PF.4 — persistent buffer instead of a per-frame allocation.
1853                // PF.5 — rebuilt + re-uploaded only when the rig changed;
1854                // this pass's own dirty flag (it only runs with sprites on
1855                // screen, so it can't ride the scene pack's flag).
1856                if self.dirty.sprite_lights {
1857                    let sprite_pts: Vec<GpuPointLight> = dl
1858                        .world_points
1859                        .iter()
1860                        .take(MAX_POINT_LIGHTS)
1861                        .map(|l| GpuPointLight {
1862                            pos: l.position,
1863                            radius: l.radius,
1864                            color: l.color,
1865                            intensity: l.intensity,
1866                            spot_dir: l.spot_dir,
1867                            cos_outer: l.cos_outer,
1868                            cos_inner: l.cos_inner,
1869                            // XS.4.2 — honour the light's caster flag so a
1870                            // receiving sprite is shadowed by it (capable
1871                            // devices).
1872                            casts_shadow: u32::from(l.casts_shadow),
1873                            _pad: [0; 2],
1874                        })
1875                        .collect();
1876                    fp.write_sprite_lights(&self.device, &self.queue, &sprite_pts);
1877                    self.dirty.sprite_lights = false;
1878                }
1879                // sun_flags bit0 = sun enabled, bit1 = sun casts shadow (XS.4.2),
1880                // bit2 = dynamic lighting active.
1881                let sprite_sun_flags = u32::from(sprite_sun_enabled)
1882                    | (u32::from(dl.sun_casts_shadow) << 1)
1883                    | (u32::from(dl.enabled) << 2);
1884                let uni = SpriteModelUniform {
1885                    cam_pos: cam.position,
1886                    _p0: 0.0,
1887                    cam_right: cam.right,
1888                    _p1: 0.0,
1889                    cam_down: cam.down,
1890                    _p2: 0.0,
1891                    cam_forward: cam.forward,
1892                    _p3: 0.0,
1893                    fog_color: [
1894                        self.fog_color[0],
1895                        self.fog_color[1],
1896                        self.fog_color[2],
1897                        self.fog_near,
1898                    ],
1899                    screen_size: [render_w, render_h],
1900                    instance_count: visible,
1901                    fog_far: self.fog_far,
1902                    fov_y_rad,
1903                    tiles_x,
1904                    tile_size: SPRITE_TILE_SIZE,
1905                    has_translucent: u32::from(self.sprite_has_translucent),
1906                    sun_dir: [
1907                        dl.world_sun_dir[0],
1908                        dl.world_sun_dir[1],
1909                        dl.world_sun_dir[2],
1910                        0.0,
1911                    ],
1912                    sun_color: [
1913                        dl.sun_color[0],
1914                        dl.sun_color[1],
1915                        dl.sun_color[2],
1916                        dl.sun_intensity,
1917                    ],
1918                    ambient_color: [dl.ambient[0], dl.ambient[1], dl.ambient[2], 0.0],
1919                    sun_flags: sprite_sun_flags,
1920                    point_light_count: sprite_point_count,
1921                    _pad_dl: [0; 2],
1922                    shadow_tint: [dl.shadow_tint[0], dl.shadow_tint[1], dl.shadow_tint[2], 0.0],
1923                    style_bands: dl.style_bands,
1924                    // XS.4.2 — sprite-shadow (receive) ABI, mirroring the scene
1925                    // pass. Only consulted when the device is sprite-shadow
1926                    // capable (the shadowed shader variant is built); otherwise
1927                    // the stub `sprite_shadow_occluded` ignores them.
1928                    occ_num_pages: scene.occupancy_num_pages,
1929                    occ_page_words: scene.occupancy_page_words,
1930                    grid_count: scene.grid_count,
1931                    max_outer_steps,
1932                    shadow_max_steps: dl.shadow_max_steps,
1933                    shadow_bias: dl.shadow_bias,
1934                    shadow_max_dist: dl.shadow_max_dist,
1935                    shadow_strength: dl.shadow_strength,
1936                    _pad_xs: [0; 3],
1937                };
1938                self.queue
1939                    .write_buffer(&smd.uniform_buf, 0, bytemuck::bytes_of(&uni));
1940                // PF.4 — cached bind group (identity-keyed, like the scene
1941                // pass's). World point lights at 15 (DL.7; binding 14 univec
1942                // normal table dropped — face-normal lighting now).
1943                let mut sprite_bufs: Vec<(u32, wgpu::Buffer)> = vec![
1944                    (0, smd.uniform_buf.clone()),
1945                    (1, reg.occupancy.clone()),
1946                    (2, reg.colors.clone()),
1947                    (3, reg.color_offsets.clone()),
1948                    (4, reg.model_meta.clone()),
1949                    (5, reg.instances.clone()),
1950                    (6, dda.depth_buffer.clone()),
1951                    (7, dda.framebuffer.clone()),
1952                    (8, reg.tile_ranges.clone()),
1953                    (9, reg.tile_instances.clone()),
1954                    (10, reg.dirs.clone()),
1955                    (11, reg.colmul.clone()),
1956                    (12, smd.materials_buf.clone()),
1957                    (13, reg.materials_vox.clone()),
1958                    (15, fp.sprite_lights.clone()),
1959                ];
1960                // XS.4.2 — when capable, bind the terrain occupancy set (the
1961                // same resident buffers + the per-frame grid cameras the scene
1962                // pass uses) so sprite shadow rays march terrain. Must match
1963                // the BGL built in `build_sprite_model_dda`.
1964                if self.sprite_shadows_capable {
1965                    let terrain: [(u32, &wgpu::Buffer); 8] = [
1966                        (16, &scene.occupancy_pages[0]),
1967                        (17, &scene.occupancy_pages[1]),
1968                        (18, &scene.occupancy_pages[2]),
1969                        (19, &scene.occupancy_pages[3]),
1970                        (20, &scene.all_chunk_occupancy),
1971                        (21, &scene.all_slot_chunk_idx),
1972                        (22, &scene.grid_static_meta),
1973                        (23, &fp.grid_cameras),
1974                    ];
1975                    for (binding, buf) in terrain {
1976                        sprite_bufs.push((binding, buf.clone()));
1977                    }
1978                }
1979                Some(
1980                    cached_bind_group(
1981                        &mut fp.sprite_bg,
1982                        &self.device,
1983                        "roxlap-gpu sprite_model_dda.bg",
1984                        &smd.bgl,
1985                        sprite_bufs,
1986                        Vec::new(),
1987                        &[],
1988                    )
1989                    .clone(),
1990                )
1991            }
1992            _ => None,
1993        };
1994
1995        let mut encoder = self
1996            .device
1997            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
1998                label: Some("roxlap-gpu scene encoder"),
1999            });
2000        {
2001            let mut cpass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
2002                label: Some("roxlap-gpu scene_dda compute"),
2003                timestamp_writes: None,
2004            });
2005            cpass.set_pipeline(&dda.pipeline_dda);
2006            cpass.set_bind_group(0, &dda_bg, &[]);
2007            cpass.dispatch_workgroups(render_w.div_ceil(8), render_h.div_ceil(8), 1);
2008        }
2009        // GPU.10 — sprite model-DDA pass: one thread per pixel marches
2010        // the tile's instances + composites against scene depth, after
2011        // the scene pass wrote the depth buffer and before the blit.
2012        if let (Some(smd), Some(bg)) = (&self.sprite_model_dda, &sprite_model_bg) {
2013            let mut cpass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
2014                label: Some("roxlap-gpu sprite_model_dda"),
2015                timestamp_writes: None,
2016            });
2017            cpass.set_pipeline(&smd.pipeline);
2018            cpass.set_bind_group(0, bg, &[]);
2019            cpass.dispatch_workgroups(render_w.div_ceil(8), render_h.div_ceil(8), 1);
2020        }
2021        // RP.1 — resolve pass: box-downfilter framebuffer(march) →
2022        // resolve_buf(logical). One thread per logical pixel.
2023        // PF.5 (H6) — with ssaa == 1 AND posterize off the resolve is an
2024        // identity copy: skip the whole full-screen pass and blit straight
2025        // from the framebuffer instead (byte-identical output).
2026        let identity_resolve =
2027            (render_w, render_h) == (logical_w, logical_h) && self.posterize.is_none();
2028        if !identity_resolve {
2029            let mut cpass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
2030                label: Some("roxlap-gpu scene_dda resolve"),
2031                timestamp_writes: None,
2032            });
2033            cpass.set_pipeline(&dda.pipeline_resolve);
2034            cpass.set_bind_group(0, &dda.resolve_bg, &[]);
2035            cpass.dispatch_workgroups(logical_w.div_ceil(8), logical_h.div_ceil(8), 1);
2036        }
2037        {
2038            let mut rpass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
2039                label: Some("roxlap-gpu scene_dda blit"),
2040                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
2041                    view: &surf_view,
2042                    depth_slice: None,
2043                    resolve_target: None,
2044                    ops: wgpu::Operations {
2045                        load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
2046                        store: wgpu::StoreOp::Store,
2047                    },
2048                })],
2049                depth_stencil_attachment: None,
2050                timestamp_writes: None,
2051                occlusion_query_set: None,
2052                multiview_mask: None,
2053            });
2054            rpass.set_pipeline(&dda.pipeline_blit);
2055            rpass.set_bind_group(
2056                0,
2057                if identity_resolve {
2058                    &dda.blit_bg_direct
2059                } else {
2060                    &dda.blit_bg
2061                },
2062                &[],
2063            );
2064            rpass.draw(0..3, 0..1);
2065        }
2066        self.queue.submit(std::iter::once(encoder.finish()));
2067        // This frame wrote `scene_dda.depth_buffer`, so depth-tested
2068        // overlays may test against it.
2069        self.dirty.scene_depth_valid = true;
2070        // Deferred present — the host calls `present` or `paint_egui`.
2071        self.pending_frame = Some((surf_tex, surf_view));
2072        self.frame_count = self.frame_count.wrapping_add(1);
2073    }
2074
2075    /// Like [`Self::render`] (clear to colour) but **deferred**: stashes
2076    /// the frame for [`Self::present`] / [`Self::paint_egui`] instead of
2077    /// presenting. The facade uses this before any grid is resident so a
2078    /// HUD can still be painted over an empty scene.
2079    pub fn render_clear_deferred(&mut self) {
2080        // No scene pass this frame ⇒ `scene_dda.depth_buffer` (if it
2081        // exists from an earlier scene) is stale; depth-tested overlays
2082        // must not test against it.
2083        self.dirty.scene_depth_valid = false;
2084        self.pending_frame = None;
2085        let Some(surf_tex) = self.acquire_frame() else {
2086            return;
2087        };
2088        let view = surf_tex
2089            .texture
2090            .create_view(&wgpu::TextureViewDescriptor::default());
2091        let [r, g, b] = self.clear_colour;
2092        let mut encoder = self
2093            .device
2094            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
2095                label: Some("roxlap-gpu clear (deferred)"),
2096            });
2097        {
2098            let _rp = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
2099                label: Some("roxlap-gpu clear (deferred)"),
2100                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
2101                    view: &view,
2102                    depth_slice: None,
2103                    resolve_target: None,
2104                    ops: wgpu::Operations {
2105                        load: wgpu::LoadOp::Clear(wgpu::Color { r, g, b, a: 1.0 }),
2106                        store: wgpu::StoreOp::Store,
2107                    },
2108                })],
2109                depth_stencil_attachment: None,
2110                timestamp_writes: None,
2111                occlusion_query_set: None,
2112                multiview_mask: None,
2113            });
2114        }
2115        self.queue.submit(std::iter::once(encoder.finish()));
2116        self.pending_frame = Some((surf_tex, view));
2117    }
2118
2119    /// Present the frame stashed by the last deferred render
2120    /// ([`Self::render_scene`] / [`Self::render_clear_deferred`]). No-op
2121    /// if nothing is pending (e.g. the surface was lost mid-render).
2122    pub fn present(&mut self) {
2123        if let Some((surf_tex, _view)) = self.pending_frame.take() {
2124            surf_tex.present();
2125        }
2126    }
2127
2128    /// Block until the GPU has drained every submitted command (queue
2129    /// idle), dropping any not-yet-presented swapchain frame first. Call at
2130    /// shutdown — before the [`GpuRenderer`] (and its window) drop — so the
2131    /// device is torn down with no work in flight and no half-presented
2132    /// frame, instead of yanking the swapchain mid-submission (which leaves
2133    /// the driver/compositor compositing stale buffers — the "leftover
2134    /// triangles / flicker after an unclean exit" symptom). No-op on wasm
2135    /// (`poll(Wait)` is unavailable there; the browser reclaims the device).
2136    pub fn wait_idle(&mut self) {
2137        // Release the acquired-but-unpresented frame so its swapchain image
2138        // isn't held across teardown.
2139        self.pending_frame = None;
2140        #[cfg(not(target_arch = "wasm32"))]
2141        {
2142            self.device.poll(wgpu::PollType::wait_indefinitely()).ok();
2143        }
2144    }
2145
2146    /// Project a world point to window pixels under the marcher's
2147    /// vertical-FOV pinhole (the inverse of [`Self::pixel_ray`]), using
2148    /// the last-rendered frame's size + FOV. `None` before the first
2149    /// scene render or for a point at/behind the near plane.
2150    #[must_use]
2151    pub fn project_point(
2152        &self,
2153        cam_pos: [f32; 3],
2154        right: [f32; 3],
2155        down: [f32; 3],
2156        forward: [f32; 3],
2157        world: [f32; 3],
2158    ) -> Option<(f32, f32)> {
2159        let dda = self.scene_dda.as_ref()?;
2160        let (w, h) = dda.storage_size;
2161        if w == 0 || h == 0 || self.last_fov_y_rad <= 0.0 {
2162            return None;
2163        }
2164        let d = [
2165            world[0] - cam_pos[0],
2166            world[1] - cam_pos[1],
2167            world[2] - cam_pos[2],
2168        ];
2169        let cz = forward[0] * d[0] + forward[1] * d[1] + forward[2] * d[2];
2170        if cz < LINE_NEAR_Z {
2171            return None;
2172        }
2173        let cx = right[0] * d[0] + right[1] * d[1] + right[2] * d[2];
2174        let cy = down[0] * d[0] + down[1] * d[1] + down[2] * d[2];
2175        let half_h = (self.last_fov_y_rad * 0.5).tan();
2176        let half_w = half_h * (w as f32 / h as f32);
2177        let ndc_x = (cx / cz) / half_w;
2178        let ndc_y = -(cy / cz) / half_h;
2179        let sx = (ndc_x * 0.5 + 0.5) * w as f32;
2180        let sy = (0.5 - ndc_y * 0.5) * h as f32;
2181        Some((sx, sy))
2182    }
2183
2184    fn build_scene_dda(
2185        &self,
2186        width: u32,
2187        height: u32,
2188        logical_w: u32,
2189        logical_h: u32,
2190        surface_w: u32,
2191        surface_h: u32,
2192        surface_format: wgpu::TextureFormat,
2193    ) -> SceneDdaResources {
2194        // `width`/`height` are the **march** size (`logical × ssaa`) — the
2195        // scene + sprite + depth passes run at it. `logical_*` is the resolved
2196        // (retro) grid the resolve pass downfilters into and the blit reads.
2197        // `surface_*` is the swapchain the blit upscales onto. Framebuffer is a
2198        // packed-`rgba8unorm` storage buffer (row stride = march `width`).
2199        let framebuffer = self.device.create_buffer(&wgpu::BufferDescriptor {
2200            label: Some("roxlap-gpu scene_dda.framebuffer"),
2201            size: u64::from(width) * u64::from(height) * 4,
2202            // QE.7a - COPY_SRC so `read_frame_pixels` can stage the
2203            // identity-resolve path (ssaa 1, posterize off) for capture.
2204            usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC,
2205            mapped_at_creation: false,
2206        });
2207        // RP.1 — logical-resolution buffer the resolve pass writes; the blit
2208        // reads it (so the blit src is the *logical* size, not the march size).
2209        let resolve_buf = self.device.create_buffer(&wgpu::BufferDescriptor {
2210            label: Some("roxlap-gpu scene_dda.resolve_buf"),
2211            size: u64::from(logical_w) * u64::from(logical_h) * 4,
2212            // QE.7a - COPY_SRC so `read_frame_pixels` can stage it (capture).
2213            usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC,
2214            mapped_at_creation: false,
2215        });
2216        // Resolve uniform: `[src(march) w,h, dst(logical) w,h, ssaa,
2217        // levels r,g,b, dither, pad×3]` (48 B). Dims+ssaa written here; the
2218        // posterize fields (offset 20) are re-written per frame in render_scene.
2219        let resolve_dims = self.device.create_buffer(&wgpu::BufferDescriptor {
2220            label: Some("roxlap-gpu scene_dda.resolve_dims"),
2221            size: 48,
2222            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
2223            mapped_at_creation: false,
2224        });
2225        self.queue.write_buffer(
2226            &resolve_dims,
2227            0,
2228            bytemuck::bytes_of(&[width, height, logical_w, logical_h, self.ssaa]),
2229        );
2230        // Blit uniform `Dims`: logical (src) size, swapchain (dst) size, then
2231        // `flip_x` + pad (RP.0 nearest upscale). The flip flag (offset 16) is
2232        // re-written per frame in `render_scene`; a render/surface resize
2233        // forces a full rebuild, so the sizes only need writing here.
2234        let blit_dims = self.device.create_buffer(&wgpu::BufferDescriptor {
2235            label: Some("roxlap-gpu scene_dda.blit_dims"),
2236            size: 32,
2237            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
2238            mapped_at_creation: false,
2239        });
2240        self.queue.write_buffer(
2241            &blit_dims,
2242            0,
2243            bytemuck::bytes_of(&[
2244                logical_w,
2245                logical_h,
2246                surface_w,
2247                surface_h,
2248                u32::from(self.flip_x),
2249                0u32,
2250                0u32,
2251                0u32,
2252            ]),
2253        );
2254
2255        let uniform_buf = self.device.create_buffer(&wgpu::BufferDescriptor {
2256            label: Some("roxlap-gpu scene_dda.uniform"),
2257            size: std::mem::size_of::<SceneDdaUniform>() as u64,
2258            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
2259            mapped_at_creation: false,
2260        });
2261
2262        // GPU.9 — per-pixel world-t depth (f32 bits as u32). Sized to
2263        // the storage texture; written by the scene pass when sprites
2264        // are active, read+tested by the sprite splatter.
2265        let depth_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
2266            label: Some("roxlap-gpu scene_dda.depth"),
2267            size: u64::from(width) * u64::from(height) * 4,
2268            // COPY_SRC so `read_depth_pixel` can stage it for picking.
2269            usage: wgpu::BufferUsages::STORAGE
2270                | wgpu::BufferUsages::COPY_DST
2271                | wgpu::BufferUsages::COPY_SRC,
2272            mapped_at_creation: false,
2273        });
2274        let depth_readback = self.device.create_buffer(&wgpu::BufferDescriptor {
2275            label: Some("roxlap-gpu scene_dda.depth_readback"),
2276            size: u64::from(width) * u64::from(height) * 4,
2277            usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
2278            mapped_at_creation: false,
2279        });
2280        // XS.4.3 — on sprite-shadow-capable devices, splice the sprite-cast
2281        // snippet over the `sprites_occlude` stub (binds the sprite registry at
2282        // 19..21 so terrain shadow rays test sprite volumes).
2283        let capable = self.sprite_shadows_capable;
2284        let dda_shader = self
2285            .device
2286            .create_shader_module(wgpu::ShaderModuleDescriptor {
2287                label: Some("scene_dda.wgsl"),
2288                source: wgpu::ShaderSource::Wgsl(scene_shader_source(capable).into()),
2289            });
2290        let mut dda_entries = vec![
2291            bgl_uniform_entry(0),
2292            bgl_storage_entry(1, true),
2293            bgl_storage_entry(2, true),
2294            bgl_storage_entry(3, true),
2295            bgl_storage_entry(4, true),
2296            bgl_storage_entry(5, true),
2297            bgl_storage_entry(6, true),
2298            bgl_storage_entry(7, true),
2299            // Framebuffer storage buffer (read-write; the scene +
2300            // sprite passes write packed pixels into it).
2301            bgl_storage_entry(8, false),
2302            // GPU.8 sky panorama + sampler.
2303            wgpu::BindGroupLayoutEntry {
2304                binding: 9,
2305                visibility: wgpu::ShaderStages::COMPUTE,
2306                ty: wgpu::BindingType::Texture {
2307                    sample_type: wgpu::TextureSampleType::Float { filterable: true },
2308                    view_dimension: wgpu::TextureViewDimension::D2,
2309                    multisampled: false,
2310                },
2311                count: None,
2312            },
2313            wgpu::BindGroupLayoutEntry {
2314                binding: 10,
2315                visibility: wgpu::ShaderStages::COMPUTE,
2316                ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
2317                count: None,
2318            },
2319            // GPU.9 — read-write per-pixel depth buffer.
2320            bgl_storage_entry(11, false),
2321            // Occupancy pages 1..MAX_OCC_PAGES (page 0 is
2322            // binding 1). Unused pages bind a dummy buffer.
2323            bgl_storage_entry(12, true),
2324            bgl_storage_entry(13, true),
2325            bgl_storage_entry(14, true),
2326            // Per-grid cameras (runtime-sized; one per grid).
2327            bgl_storage_entry(15, true),
2328            // TV.6 — material palette + terrain colour→material map.
2329            bgl_storage_entry(16, true),
2330            bgl_storage_entry(17, true),
2331            // DL — per-grid point lights (18). Sun dir rides in
2332            // PerGridCamera (binding 15) to stay within the 16
2333            // storage-buffer limit.
2334            bgl_storage_entry(18, true),
2335        ];
2336        if capable {
2337            // XS.4.3 — sprite registry for the sprite-cast shadow march.
2338            dda_entries.push(bgl_storage_entry(19, true)); // sprite_instances
2339            dda_entries.push(bgl_storage_entry(20, true)); // sprite_models
2340            dda_entries.push(bgl_storage_entry(21, true)); // sprite_occupancy
2341        }
2342        let bgl_dda = self
2343            .device
2344            .create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
2345                label: Some("roxlap-gpu scene_dda.bgl"),
2346                entries: &dda_entries,
2347            });
2348        let dda_pl = self
2349            .device
2350            .create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
2351                label: Some("roxlap-gpu scene_dda.layout"),
2352                bind_group_layouts: &[Some(&bgl_dda)],
2353                immediate_size: 0,
2354            });
2355        let pipeline_dda = self
2356            .device
2357            .create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
2358                label: Some("roxlap-gpu scene_dda.pipeline"),
2359                layout: Some(&dda_pl),
2360                module: &dda_shader,
2361                entry_point: Some("render_scene"),
2362                compilation_options: wgpu::PipelineCompilationOptions::default(),
2363                cache: None,
2364            });
2365
2366        // RP.1 — box-downfilter resolve pass (framebuffer march → resolve_buf
2367        // logical). `ssaa == 1` is a 1×1 copy; the blit always reads resolve_buf.
2368        let resolve_shader = self
2369            .device
2370            .create_shader_module(wgpu::ShaderModuleDescriptor {
2371                label: Some("scene_resolve.wgsl"),
2372                source: wgpu::ShaderSource::Wgsl(
2373                    include_str!("../shaders/scene_resolve.wgsl").into(),
2374                ),
2375            });
2376        let bgl_resolve = self
2377            .device
2378            .create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
2379                label: Some("roxlap-gpu scene_dda.resolve_bgl"),
2380                entries: &[
2381                    bgl_storage_entry(0, true),  // src framebuffer (read)
2382                    bgl_storage_entry(1, false), // dst resolve_buf (read-write)
2383                    bgl_uniform_entry(2),        // resolve dims
2384                ],
2385            });
2386        let resolve_pl = self
2387            .device
2388            .create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
2389                label: Some("roxlap-gpu scene_dda.resolve_layout"),
2390                bind_group_layouts: &[Some(&bgl_resolve)],
2391                immediate_size: 0,
2392            });
2393        let pipeline_resolve =
2394            self.device
2395                .create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
2396                    label: Some("roxlap-gpu scene_dda.resolve_pipeline"),
2397                    layout: Some(&resolve_pl),
2398                    module: &resolve_shader,
2399                    entry_point: Some("main"),
2400                    compilation_options: wgpu::PipelineCompilationOptions::default(),
2401                    cache: None,
2402                });
2403        let resolve_bg = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
2404            label: Some("roxlap-gpu scene_dda.resolve_bg"),
2405            layout: &bgl_resolve,
2406            entries: &[
2407                wgpu::BindGroupEntry {
2408                    binding: 0,
2409                    resource: framebuffer.as_entire_binding(),
2410                },
2411                wgpu::BindGroupEntry {
2412                    binding: 1,
2413                    resource: resolve_buf.as_entire_binding(),
2414                },
2415                wgpu::BindGroupEntry {
2416                    binding: 2,
2417                    resource: resolve_dims.as_entire_binding(),
2418                },
2419            ],
2420        });
2421
2422        let blit_shader = self
2423            .device
2424            .create_shader_module(wgpu::ShaderModuleDescriptor {
2425                label: Some("scene_blit.wgsl"),
2426                source: wgpu::ShaderSource::Wgsl(include_str!("../shaders/scene_blit.wgsl").into()),
2427            });
2428        let bgl_blit = self
2429            .device
2430            .create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
2431                label: Some("roxlap-gpu scene_dda.blit_bgl"),
2432                entries: &[
2433                    // Framebuffer storage buffer (read-only in the blit).
2434                    wgpu::BindGroupLayoutEntry {
2435                        binding: 0,
2436                        visibility: wgpu::ShaderStages::FRAGMENT,
2437                        ty: wgpu::BindingType::Buffer {
2438                            ty: wgpu::BufferBindingType::Storage { read_only: true },
2439                            has_dynamic_offset: false,
2440                            min_binding_size: None,
2441                        },
2442                        count: None,
2443                    },
2444                    // Screen-size uniform for the pixel→index math.
2445                    wgpu::BindGroupLayoutEntry {
2446                        binding: 1,
2447                        visibility: wgpu::ShaderStages::FRAGMENT,
2448                        ty: wgpu::BindingType::Buffer {
2449                            ty: wgpu::BufferBindingType::Uniform,
2450                            has_dynamic_offset: false,
2451                            min_binding_size: None,
2452                        },
2453                        count: None,
2454                    },
2455                ],
2456            });
2457        let blit_pl = self
2458            .device
2459            .create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
2460                label: Some("roxlap-gpu scene_dda.blit_layout"),
2461                bind_group_layouts: &[Some(&bgl_blit)],
2462                immediate_size: 0,
2463            });
2464        let pipeline_blit = self
2465            .device
2466            .create_render_pipeline(&wgpu::RenderPipelineDescriptor {
2467                label: Some("roxlap-gpu scene_dda.blit_pipeline"),
2468                layout: Some(&blit_pl),
2469                vertex: wgpu::VertexState {
2470                    module: &blit_shader,
2471                    entry_point: Some("vs_main"),
2472                    compilation_options: wgpu::PipelineCompilationOptions::default(),
2473                    buffers: &[],
2474                },
2475                fragment: Some(wgpu::FragmentState {
2476                    module: &blit_shader,
2477                    entry_point: Some("fs_main"),
2478                    compilation_options: wgpu::PipelineCompilationOptions::default(),
2479                    targets: &[Some(wgpu::ColorTargetState {
2480                        format: surface_format,
2481                        blend: None,
2482                        write_mask: wgpu::ColorWrites::ALL,
2483                    })],
2484                }),
2485                primitive: wgpu::PrimitiveState::default(),
2486                depth_stencil: None,
2487                multisample: wgpu::MultisampleState::default(),
2488                multiview_mask: None,
2489                cache: None,
2490            });
2491        let blit_bg = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
2492            label: Some("roxlap-gpu scene_dda.blit_bg"),
2493            layout: &bgl_blit,
2494            entries: &[
2495                wgpu::BindGroupEntry {
2496                    binding: 0,
2497                    // RP.1 — blit reads the logical resolve buffer.
2498                    resource: resolve_buf.as_entire_binding(),
2499                },
2500                wgpu::BindGroupEntry {
2501                    binding: 1,
2502                    resource: blit_dims.as_entire_binding(),
2503                },
2504            ],
2505        });
2506        // PF.5 (H6) — direct-blit variant reading the march framebuffer:
2507        // used when the resolve pass would be an identity copy (ssaa == 1,
2508        // posterize off ⇒ march size == logical size), letting render_scene
2509        // skip that full-screen pass entirely.
2510        let blit_bg_direct = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
2511            label: Some("roxlap-gpu scene_dda.blit_bg_direct"),
2512            layout: &bgl_blit,
2513            entries: &[
2514                wgpu::BindGroupEntry {
2515                    binding: 0,
2516                    resource: framebuffer.as_entire_binding(),
2517                },
2518                wgpu::BindGroupEntry {
2519                    binding: 1,
2520                    resource: blit_dims.as_entire_binding(),
2521                },
2522            ],
2523        });
2524
2525        // TV.6 — material palette + terrain map buffers, seeded from the
2526        // renderer's current scene-material state (so a map defined before the
2527        // scene pass was built still takes effect).
2528        let (materials_pal_buf, terrain_map_buf) = {
2529            use wgpu::util::DeviceExt;
2530            let pal = self
2531                .device
2532                .create_buffer_init(&wgpu::util::BufferInitDescriptor {
2533                    label: Some("roxlap-gpu scene_dda.materials_pal"),
2534                    contents: bytemuck::cast_slice(self.scene_materials.as_slice()),
2535                    usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
2536                });
2537            // Fixed 256-row map (≤256 materials anyway) → no re-alloc when the
2538            // host changes the map after the scene pass is built.
2539            let mut rows = [[0u32; 2]; 256];
2540            for (slot, &row) in rows.iter_mut().zip(self.scene_terrain_map.iter()) {
2541                *slot = row;
2542            }
2543            let map = self
2544                .device
2545                .create_buffer_init(&wgpu::util::BufferInitDescriptor {
2546                    label: Some("roxlap-gpu scene_dda.terrain_map"),
2547                    contents: bytemuck::cast_slice(&rows),
2548                    usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
2549                });
2550            (pal, map)
2551        };
2552
2553        SceneDdaResources {
2554            storage_size: (width, height),
2555            logical_size: (logical_w, logical_h),
2556            framebuffer,
2557            resolve_buf,
2558            uniform_buf,
2559            bgl_dda,
2560            pipeline_dda,
2561            pipeline_resolve,
2562            resolve_bg,
2563            resolve_dims,
2564            blit_bg,
2565            blit_bg_direct,
2566            pipeline_blit,
2567            blit_dims,
2568            depth_buffer,
2569            depth_readback,
2570            materials_pal_buf,
2571            terrain_map_buf,
2572            // XS.4.3 — 80-byte dummy (≥ one Instance) for the sprite-cast
2573            // bindings when capable but no sprite registry is bound this frame.
2574            sprite_cast_dummy: capable.then(|| {
2575                self.device.create_buffer(&wgpu::BufferDescriptor {
2576                    label: Some("roxlap-gpu scene_dda.sprite_cast_dummy"),
2577                    size: 80,
2578                    usage: wgpu::BufferUsages::STORAGE,
2579                    mapped_at_creation: false,
2580                })
2581            }),
2582        }
2583    }
2584
2585    /// GPU.10.1 — upload a sprite model registry + its instances for
2586    /// the DDA path. An empty instance slice clears all sprites.
2587    pub fn set_sprite_instances(
2588        &mut self,
2589        registry: &sprite_model::SpriteModelRegistry,
2590        instances: &[sprite_model::SpriteInstance],
2591    ) {
2592        if instances.is_empty() {
2593            self.sprite_registry = None;
2594            return;
2595        }
2596        self.sprite_registry = Some(sprite_model::SpriteRegistryResident::upload(
2597            &self.device,
2598            registry,
2599            instances,
2600        ));
2601    }
2602
2603    /// Incrementally append sprite instances **without** rebuilding the
2604    /// registry — the cheap streaming-spawn path (asteroids, projectiles).
2605    /// Returns the index of the first appended instance (`[base, base+N)`).
2606    ///
2607    /// Every appended instance must reference a model already registered
2608    /// by the [`Self::set_sprite_instances`] that established residency
2609    /// (model volumes are not re-uploaded here — build the full
2610    /// `SpriteModelRegistry` up front and seed it once, then stream
2611    /// instances). If no registry is resident yet, this performs the
2612    /// initial full upload and returns `0`.
2613    ///
2614    /// Cost is amortised O(1) per instance (the GPU instance buffer grows
2615    /// by powers of two), versus the full volume + buffer rebuild of
2616    /// [`Self::set_sprite_instances`].
2617    pub fn append_sprite_instances(
2618        &mut self,
2619        registry: &sprite_model::SpriteModelRegistry,
2620        instances: &[sprite_model::SpriteInstance],
2621    ) -> u32 {
2622        match self.sprite_registry.as_mut() {
2623            Some(reg) => reg.append_instances(&self.device, registry, instances),
2624            None => {
2625                self.set_sprite_instances(registry, instances);
2626                0
2627            }
2628        }
2629    }
2630
2631    /// Remove the sprite instance at `index` (swap-remove, O(1), no model
2632    /// re-upload). Returns `Some(old_last)` if a different instance was
2633    /// moved into `index` to fill the hole — its index changed from
2634    /// `old_last` to `index`, so a caller tracking instance handles must
2635    /// update that one. Returns `None` if `index` was the last element /
2636    /// out of range, or no registry is resident.
2637    pub fn remove_sprite_instance(&mut self, index: usize) -> Option<usize> {
2638        self.sprite_registry
2639            .as_mut()
2640            .and_then(|reg| reg.remove_instance(index))
2641    }
2642
2643    /// Flush queued `write_buffer` uploads by submitting an empty command
2644    /// stream. wgpu stages `write_buffer` data and flushes it on the next
2645    /// `Queue::submit`; calling this between batches of uploads (e.g. a
2646    /// flipbook's frames in [`Self::add_sprite_model`]) recycles the device
2647    /// staging pool so a big one-shot batch can't exhaust it (which would
2648    /// then crash egui-wgpu's own `write_buffer`).
2649    pub fn flush_writes(&self) {
2650        self.queue.submit(std::iter::empty::<wgpu::CommandBuffer>());
2651    }
2652
2653    /// Incrementally add model `chain_id` (its full LOD chain) from
2654    /// `registry` to the resident sprite registry **without**
2655    /// re-uploading the existing models — the streaming-in counterpart
2656    /// to [`Self::append_sprite_instances`]. Register the model on the
2657    /// CPU registry first (`add` / `add_lod`), then pass the returned
2658    /// `chain_id` here; afterwards instances may reference it.
2659    ///
2660    /// If no registry is resident yet, this instead performs the
2661    /// initial full upload of `registry` (all current models, zero
2662    /// instances) to establish residency. Cost is amortised O(new
2663    /// model voxels): the shared volume buffers carry slack and
2664    /// bump-append, rebuilding from the registry only on overflow.
2665    pub fn add_sprite_model(
2666        &mut self,
2667        registry: &sprite_model::SpriteModelRegistry,
2668        chain_id: u32,
2669    ) {
2670        match self.sprite_registry.as_mut() {
2671            Some(reg) => reg.add_model(&self.device, &self.queue, registry, chain_id),
2672            None => {
2673                self.sprite_registry = Some(sprite_model::SpriteRegistryResident::upload(
2674                    &self.device,
2675                    registry,
2676                    &[],
2677                ));
2678            }
2679        }
2680    }
2681
2682    /// Remove a model (tombstone its LOD chain) from the resident sprite
2683    /// registry — the counterpart to [`Self::add_sprite_model`]. Frees its
2684    /// `colors`/`dirs` space for reuse by a later add; the smaller
2685    /// `occupancy`/`color_offsets` holes are reclaimed by
2686    /// [`Self::compact_sprite_models`]. Entry / chain ids stay stable, so
2687    /// other models' `chain_id`s remain valid.
2688    ///
2689    /// Instances of the removed model keep their slots but draw as nothing
2690    /// until the caller drops them via [`Self::remove_sprite_instance`].
2691    /// No-op if `chain_id` is unknown / already removed / no registry.
2692    pub fn remove_sprite_model(&mut self, chain_id: u32) {
2693        if let Some(reg) = self.sprite_registry.as_mut() {
2694            reg.remove_model(chain_id);
2695        }
2696    }
2697
2698    /// Reclaim the holes left by [`Self::remove_sprite_model`] by rebuilding
2699    /// the shared volume buffers from the live models only. `registry` must
2700    /// be the resident one. Cost is O(live volume) — call it when
2701    /// [`Self::dead_sprite_model_count`] is high (e.g. exceeds the live
2702    /// count), not every frame. No-op if no registry is resident.
2703    pub fn compact_sprite_models(&mut self, registry: &sprite_model::SpriteModelRegistry) {
2704        if let Some(reg) = self.sprite_registry.as_mut() {
2705            reg.compact(&self.device, &self.queue, registry);
2706        }
2707    }
2708
2709    /// Number of live (non-removed) sprite models (0 if none uploaded).
2710    #[must_use]
2711    pub fn sprite_model_count(&self) -> usize {
2712        self.sprite_registry
2713            .as_ref()
2714            .map_or(0, sprite_model::SpriteRegistryResident::live_model_count)
2715    }
2716
2717    /// Number of removed-but-not-yet-compacted sprite models — the
2718    /// fragmentation signal for deciding when to call
2719    /// [`Self::compact_sprite_models`].
2720    #[must_use]
2721    pub fn dead_sprite_model_count(&self) -> usize {
2722        self.sprite_registry
2723            .as_ref()
2724            .map_or(0, sprite_model::SpriteRegistryResident::dead_model_count)
2725    }
2726
2727    /// Number of resident sprite instances (0 if none uploaded).
2728    #[must_use]
2729    pub fn sprite_instance_count(&self) -> usize {
2730        self.sprite_registry
2731            .as_ref()
2732            .map_or(0, sprite_model::SpriteRegistryResident::instance_count)
2733    }
2734
2735    /// Re-pose the already-resident sprite instances in place (no model
2736    /// volume re-upload) — the cheap per-frame path for animated KFA
2737    /// limbs. `instances` must match the last [`Self::set_sprite_instances`]
2738    /// in length + order. No-op if no sprite registry is resident.
2739    pub fn update_sprite_instance_transforms(
2740        &mut self,
2741        instances: &[sprite_model::SpriteInstance],
2742    ) {
2743        if let Some(reg) = self.sprite_registry.as_mut() {
2744            reg.update_transforms(instances);
2745        }
2746    }
2747
2748    /// GPU.12 incremental — re-upload only LOD chain `chain_id`'s entries
2749    /// after an in-place edit of `registry` (carve / recolour), without
2750    /// rebuilding the whole sprite registry. `registry` must be the one
2751    /// last passed to [`Self::set_sprite_instances`] with chain
2752    /// `chain_id` already edited. No-op if no registry is resident.
2753    pub fn update_sprite_model(
2754        &mut self,
2755        registry: &sprite_model::SpriteModelRegistry,
2756        chain_id: u32,
2757    ) {
2758        if let Some(reg) = self.sprite_registry.as_mut() {
2759            reg.update_model(&self.device, &self.queue, registry, chain_id);
2760        }
2761    }
2762
2763    /// VCL.2 — repoint sprite instance `index` at LOD chain `chain_id`
2764    /// (the per-frame flipbook step for animated voxel clips). `registry`
2765    /// is the resident one; `chain_id`'s volume must already be uploaded
2766    /// (e.g. a clip's frames registered via [`Self::add_sprite_model`]).
2767    /// CPU-side rewrite picked up by the next frame's cull — no volume
2768    /// re-upload. No-op if no registry is resident.
2769    pub fn set_sprite_instance_model(
2770        &mut self,
2771        registry: &sprite_model::SpriteModelRegistry,
2772        index: usize,
2773        chain_id: u32,
2774    ) {
2775        if let Some(reg) = self.sprite_registry.as_mut() {
2776            reg.set_instance_model(registry, index, chain_id);
2777        }
2778    }
2779
2780    /// Set the per-instance `kv6colmul[256]` lighting tables (voxlap's
2781    /// `update_reflects` output, e.g. via `roxlap_core::sprite::
2782    /// sprite_colmul`), in the same order/length as the last
2783    /// [`Self::set_sprite_instances`]. The GPU sprite pass modulates each
2784    /// voxel by its surface normal's entry — matching the CPU rasteriser.
2785    /// No-op if no sprite registry is resident.
2786    pub fn set_sprite_instance_colmul(&mut self, tables: &[[u64; 256]]) {
2787        if let Some(reg) = self.sprite_registry.as_mut() {
2788            reg.set_instance_colmul(tables);
2789        }
2790    }
2791
2792    /// GPU.10.4 — set the LOD pixel threshold: a sprite steps to the
2793    /// next mip once a mip-0 voxel would project below `px` screen
2794    /// pixels. `1.0` is the natural "no sub-pixel voxels" default;
2795    /// larger values force LOD in closer (useful for inspection).
2796    /// Clamped to ≥ 0.25.
2797    pub fn set_sprite_lod_px(&mut self, px: f32) {
2798        self.sprite_lod_px = px.max(0.25);
2799    }
2800
2801    /// GPU.11.1 — set the scene-grid LOD scan distance (world units).
2802    /// A chunk entered at world-t `t` is marched at mip
2803    /// `floor(log2(max(t, msd) / msd))`, clamped to its grid's mip
2804    /// ladder. `0` disables LOD (always mip-0). Larger values push
2805    /// the coarser mips farther out — the axis-aligned-mip-beams
2806    /// mitigation lever (GPU.11.2). Default 64 (matches CPU
2807    /// `mip_scan_dist`).
2808    pub fn set_scene_mip_scan_dist(&mut self, dist: f32) {
2809        self.scene_mip_scan_dist = dist.max(0.0);
2810    }
2811
2812    /// Set per-face grid side-shading — voxlap's
2813    /// `setsideshades(top, bot, left, right, up, down)`. Each value is
2814    /// subtracted (as a u8, matching the CPU `gcsub` high byte) from a
2815    /// hit voxel's brightness byte before shading, so the scene-DDA pass
2816    /// darkens grid faces the same way the CPU rasteriser does. `[0; 6]`
2817    /// disables it (the default). The hit face is taken from the DDA's
2818    /// last-stepped axis + ray direction.
2819    pub fn set_scene_side_shades(&mut self, s: [i8; 6]) {
2820        // Reinterpret each i8 as u8 (voxlap stamps `sxx` into gcsub's
2821        // high byte verbatim), then pack (top, bot, left, right) /
2822        // (up, down, 0, 0) for the two uniform vec4s.
2823        let v = |i: usize| i32::from(s[i] as u8);
2824        self.scene_side_shades = [[v(0), v(1), v(2), v(3)], [v(4), v(5), 0, 0]];
2825    }
2826
2827    /// GPU.10.1 — build the instanced model-DDA pipeline (one thread
2828    /// per pixel). Lazily invoked the first frame a registry is present.
2829    fn build_sprite_model_dda(&self) -> SpriteModelDdaResources {
2830        // XS.4.2 — on sprite-shadow-capable devices, splice the terrain shadow
2831        // snippet over the stub (`shadow_occluded_world` becomes a real terrain
2832        // march; binds occupancy 16..23). Otherwise the stub keeps sprites
2833        // unshadowed and the BGL stays at the base 14 storage buffers.
2834        let capable = self.sprite_shadows_capable;
2835        let src = sprite_shader_source(capable);
2836        let shader = self
2837            .device
2838            .create_shader_module(wgpu::ShaderModuleDescriptor {
2839                label: Some("sprite_model_dda.wgsl"),
2840                source: wgpu::ShaderSource::Wgsl(src.into()),
2841            });
2842        let mut entries = vec![
2843            bgl_uniform_entry(0),
2844            bgl_storage_entry(1, true),  // occupancy
2845            bgl_storage_entry(2, true),  // colors
2846            bgl_storage_entry(3, true),  // color_offsets
2847            bgl_storage_entry(4, true),  // model_meta
2848            bgl_storage_entry(5, true),  // instances
2849            bgl_storage_entry(6, true),  // scene depth
2850            bgl_storage_entry(7, false), // framebuffer (read-write buffer)
2851            bgl_storage_entry(8, true),  // tile_ranges
2852            bgl_storage_entry(9, true),  // tile_instances
2853            bgl_storage_entry(10, true), // per-voxel dir
2854            bgl_storage_entry(11, true), // per-instance kv6colmul
2855            bgl_storage_entry(12, true), // TV — material palette
2856            bgl_storage_entry(13, true), // TV.3 — per-voxel material id
2857            bgl_storage_entry(15, true), // DL.7 — world point lights
2858        ];
2859        if capable {
2860            // XS.4.2 — terrain occupancy set for sprite RECEIVE shadows.
2861            entries.push(bgl_storage_entry(16, true)); // occ_page0
2862            entries.push(bgl_storage_entry(17, true)); // occ_page1
2863            entries.push(bgl_storage_entry(18, true)); // occ_page2
2864            entries.push(bgl_storage_entry(19, true)); // occ_page3
2865            entries.push(bgl_storage_entry(20, true)); // all_chunk_occupancy
2866            entries.push(bgl_storage_entry(21, true)); // all_slot_chunk_idx
2867            entries.push(bgl_storage_entry(22, true)); // grid_static_meta
2868            entries.push(bgl_storage_entry(23, true)); // grid_cameras
2869        }
2870        let bgl = self
2871            .device
2872            .create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
2873                label: Some("roxlap-gpu sprite_model_dda.bgl"),
2874                entries: &entries,
2875            });
2876        let pl = self
2877            .device
2878            .create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
2879                label: Some("roxlap-gpu sprite_model_dda.layout"),
2880                bind_group_layouts: &[Some(&bgl)],
2881                immediate_size: 0,
2882            });
2883        let pipeline = self
2884            .device
2885            .create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
2886                label: Some("roxlap-gpu sprite_model_dda.pipeline"),
2887                layout: Some(&pl),
2888                module: &shader,
2889                entry_point: Some("march"),
2890                compilation_options: wgpu::PipelineCompilationOptions::default(),
2891                cache: None,
2892            });
2893        let uniform_buf = self.device.create_buffer(&wgpu::BufferDescriptor {
2894            label: Some("roxlap-gpu sprite_model_dda.uniform"),
2895            size: std::mem::size_of::<SpriteModelUniform>() as u64,
2896            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
2897            mapped_at_creation: false,
2898        });
2899        // TV — material palette, seeded from the current renderer state so a
2900        // table defined before the sprite pass was built still takes effect.
2901        let materials_buf = {
2902            use wgpu::util::DeviceExt;
2903            self.device
2904                .create_buffer_init(&wgpu::util::BufferInitDescriptor {
2905                    label: Some("roxlap-gpu sprite_model_dda.materials"),
2906                    contents: bytemuck::cast_slice(self.sprite_materials.as_slice()),
2907                    usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
2908                })
2909        };
2910        SpriteModelDdaResources {
2911            bgl,
2912            pipeline,
2913            uniform_buf,
2914            materials_buf,
2915        }
2916    }
2917
2918    /// TV — set the global voxel-material palette for the GPU sprite pass.
2919    /// Mirrors the renderer's [`MaterialTable`](roxlap_formats::material::MaterialTable):
2920    /// every sprite/clip instance's `material` id indexes it for opacity +
2921    /// blend mode. Cheap (2 KB); call it whenever the palette changes (or
2922    /// each frame). While every material is opaque the shader stays on the
2923    /// unchanged first-hit path.
2924    pub fn set_sprite_materials(&mut self, table: &roxlap_formats::material::MaterialTable) {
2925        let (palette, any_translucent) = material_palette(table);
2926        self.sprite_materials = palette;
2927        self.sprite_has_translucent = any_translucent;
2928        if let Some(smd) = &self.sprite_model_dda {
2929            self.queue.write_buffer(
2930                &smd.materials_buf,
2931                0,
2932                bytemuck::cast_slice(self.sprite_materials.as_slice()),
2933            );
2934        }
2935    }
2936
2937    /// TV.6 — set the scene (terrain) material palette + colour→material map
2938    /// for the multi-grid scene pass. Matching-colour terrain voxels render
2939    /// translucent (and/or emissive, EV.2); an empty map / all-opaque
2940    /// non-emissive palette renders unchanged. The map is capped at 256 rows
2941    /// (the fixed buffer size).
2942    pub fn set_scene_terrain_materials(
2943        &mut self,
2944        table: &roxlap_formats::material::MaterialTable,
2945        map: &[(Rgb, u8)],
2946    ) {
2947        let (palette, _) = material_palette(table);
2948        self.scene_materials = palette;
2949        self.scene_terrain_map = map
2950            .iter()
2951            .take(256)
2952            .map(|&(c, m)| [c.0 & 0x00ff_ffff, u32::from(m)])
2953            .collect();
2954        // EV.2 — the material path also activates for emissive mappings
2955        // (an opaque glowing palette must leave the first-hit fast path).
2956        self.scene_terrain_translucent = map.iter().any(|&(_, m)| {
2957            let mm = table.get(m);
2958            !mm.is_opaque() || mm.emissive > 0
2959        });
2960        if let Some(dda) = &self.scene_dda {
2961            self.queue.write_buffer(
2962                &dda.materials_pal_buf,
2963                0,
2964                bytemuck::cast_slice(self.scene_materials.as_slice()),
2965            );
2966            if !self.scene_terrain_map.is_empty() {
2967                self.queue.write_buffer(
2968                    &dda.terrain_map_buf,
2969                    0,
2970                    bytemuck::cast_slice(&self.scene_terrain_map),
2971                );
2972            }
2973        }
2974    }
2975}
2976
2977/// GPU.11 — headless scene-DDA renderer for tests + offline visual
2978/// gates. Owns the `scene_dda.wgsl` compute pipeline with no surface
2979/// and no blit pass; renders a [`GpuSceneResident`] to an in-memory
2980/// RGBA framebuffer via texture readback. The per-substage visual
2981/// gate (render reference scenes, diff PPMs) and the GPU.11.1 mip
2982/// render-diff both ride on this.
2983pub struct HeadlessSceneRenderer {
2984    width: u32,
2985    height: u32,
2986    /// Framebuffer storage buffer (packed `rgba8unorm`, tight rows) —
2987    /// matches the buffer-output `scene_dda.wgsl` (see its note).
2988    framebuffer: wgpu::Buffer,
2989    depth_buffer: wgpu::Buffer,
2990    uniform_buf: wgpu::Buffer,
2991    _sky_texture: wgpu::Texture,
2992    sky_view: wgpu::TextureView,
2993    sky_sampler: wgpu::Sampler,
2994    bgl: wgpu::BindGroupLayout,
2995    pipeline: wgpu::ComputePipeline,
2996    readback: wgpu::Buffer,
2997    /// Per-face side-shades for the gate render (default none). Packed
2998    /// `[(top,bot,left,right), (up,down,_,_)]`; set via
2999    /// [`Self::set_side_shades`].
3000    side_shades: [[i32; 4]; 2],
3001    /// DL — dynamic lights for the render (already grid-local, like the
3002    /// surface path). Default = none (baked-only). Set via
3003    /// [`Self::set_scene_lights`]; lets tests exercise the lit path.
3004    lights: SceneLights,
3005}
3006
3007impl HeadlessSceneRenderer {
3008    /// Build the compute pipeline + output/readback resources for a
3009    /// `width × height` framebuffer. Validates `scene_dda.wgsl` and
3010    /// the [`scene::GridStaticMeta`] std430 layout at pipeline /
3011    /// bind-group time.
3012    #[must_use]
3013    pub fn new(device: &wgpu::Device, queue: &wgpu::Queue, width: u32, height: u32) -> Self {
3014        let framebuffer = device.create_buffer(&wgpu::BufferDescriptor {
3015            label: Some("roxlap-gpu headless.framebuffer"),
3016            size: u64::from(width) * u64::from(height) * 4,
3017            usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC,
3018            mapped_at_creation: false,
3019        });
3020
3021        let uniform_buf = device.create_buffer(&wgpu::BufferDescriptor {
3022            label: Some("roxlap-gpu headless.uniform"),
3023            size: std::mem::size_of::<SceneDdaUniform>() as u64,
3024            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
3025            mapped_at_creation: false,
3026        });
3027        let depth_buffer = device.create_buffer(&wgpu::BufferDescriptor {
3028            label: Some("roxlap-gpu headless.depth"),
3029            size: u64::from(width) * u64::from(height) * 4,
3030            usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
3031            mapped_at_creation: false,
3032        });
3033
3034        let default_sky_pixel = [120u8, 150, 220, 255];
3035        let (sky_texture, sky_view) = create_sky_texture(device, 1, 1, &default_sky_pixel);
3036        // Upload the default sky texel (create_sky_texture only allocates
3037        // — the texel must be written or the shader samples black, which
3038        // is why a grid-less headless render came back black).
3039        queue.write_texture(
3040            wgpu::TexelCopyTextureInfo {
3041                texture: &sky_texture,
3042                mip_level: 0,
3043                origin: wgpu::Origin3d::ZERO,
3044                aspect: wgpu::TextureAspect::All,
3045            },
3046            &default_sky_pixel,
3047            wgpu::TexelCopyBufferLayout {
3048                offset: 0,
3049                bytes_per_row: Some(4),
3050                rows_per_image: Some(1),
3051            },
3052            wgpu::Extent3d {
3053                width: 1,
3054                height: 1,
3055                depth_or_array_layers: 1,
3056            },
3057        );
3058        let sky_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
3059            label: Some("roxlap-gpu headless.sky_sampler"),
3060            address_mode_u: wgpu::AddressMode::Repeat,
3061            address_mode_v: wgpu::AddressMode::Repeat,
3062            mag_filter: wgpu::FilterMode::Linear,
3063            min_filter: wgpu::FilterMode::Linear,
3064            ..Default::default()
3065        });
3066
3067        let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
3068            label: Some("scene_dda.wgsl (headless)"),
3069            // QE.8 — assembled source (common snippet + stub variant);
3070            // the raw file is no longer standalone-valid WGSL.
3071            source: wgpu::ShaderSource::Wgsl(crate::shader_src::scene_shader_source(false).into()),
3072        });
3073        let bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
3074            label: Some("roxlap-gpu headless.bgl"),
3075            entries: &[
3076                bgl_uniform_entry(0),
3077                bgl_storage_entry(1, true),
3078                bgl_storage_entry(2, true),
3079                bgl_storage_entry(3, true),
3080                bgl_storage_entry(4, true),
3081                bgl_storage_entry(5, true),
3082                bgl_storage_entry(6, true),
3083                bgl_storage_entry(7, true),
3084                // Framebuffer storage buffer (read-write).
3085                bgl_storage_entry(8, false),
3086                wgpu::BindGroupLayoutEntry {
3087                    binding: 9,
3088                    visibility: wgpu::ShaderStages::COMPUTE,
3089                    ty: wgpu::BindingType::Texture {
3090                        sample_type: wgpu::TextureSampleType::Float { filterable: true },
3091                        view_dimension: wgpu::TextureViewDimension::D2,
3092                        multisampled: false,
3093                    },
3094                    count: None,
3095                },
3096                wgpu::BindGroupLayoutEntry {
3097                    binding: 10,
3098                    visibility: wgpu::ShaderStages::COMPUTE,
3099                    ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
3100                    count: None,
3101                },
3102                bgl_storage_entry(11, false),
3103                bgl_storage_entry(12, true),
3104                bgl_storage_entry(13, true),
3105                bgl_storage_entry(14, true),
3106                // Per-grid cameras (runtime-sized; one per grid).
3107                bgl_storage_entry(15, true),
3108                // TV.6 — material palette + terrain map (opaque dummies here).
3109                bgl_storage_entry(16, true),
3110                bgl_storage_entry(17, true),
3111                // DL — per-grid point lights (18). Sun dir rides in
3112                // PerGridCamera (binding 15).
3113                bgl_storage_entry(18, true),
3114            ],
3115        });
3116        let pl = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
3117            label: Some("roxlap-gpu headless.layout"),
3118            bind_group_layouts: &[Some(&bgl)],
3119            immediate_size: 0,
3120        });
3121        let pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
3122            label: Some("roxlap-gpu headless.pipeline"),
3123            layout: Some(&pl),
3124            module: &shader,
3125            entry_point: Some("render_scene"),
3126            compilation_options: wgpu::PipelineCompilationOptions::default(),
3127            cache: None,
3128        });
3129
3130        // Readback is a tight buffer-to-buffer copy (no 256-byte row
3131        // padding, unlike the old texture-to-buffer path).
3132        let readback = device.create_buffer(&wgpu::BufferDescriptor {
3133            label: Some("roxlap-gpu headless.readback"),
3134            size: u64::from(width) * u64::from(height) * 4,
3135            usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
3136            mapped_at_creation: false,
3137        });
3138
3139        Self {
3140            width,
3141            height,
3142            framebuffer,
3143            depth_buffer,
3144            uniform_buf,
3145            _sky_texture: sky_texture,
3146            sky_view,
3147            sky_sampler,
3148            bgl,
3149            pipeline,
3150            readback,
3151            side_shades: [[0; 4]; 2],
3152            lights: SceneLights::default(),
3153        }
3154    }
3155
3156    /// Set per-face side-shades for subsequent [`Self::render`] calls —
3157    /// voxlap `setsideshades(top, bot, left, right, up, down)`, each an
3158    /// i8 stamped as u8 (matching the engine path). Lets the gate test
3159    /// the GPU side-shade darkening.
3160    pub fn set_side_shades(&mut self, s: [i8; 6]) {
3161        let v = |i: usize| i32::from(s[i] as u8);
3162        self.side_shades = [[v(0), v(1), v(2), v(3)], [v(4), v(5), 0, 0]];
3163    }
3164
3165    /// Render `scene` from `cameras` (one per grid) and read the
3166    /// framebuffer back as `width*height` packed `0xAABBGGRR` pixels
3167    /// (R in the low byte). Fog is disabled. `mip_scan_dist` drives
3168    /// the GPU.11.1 scene-grid LOD (`0` = always mip-0). Blocks on
3169    /// readback.
3170    ///
3171    /// # Panics
3172    /// If `cameras.len() != scene.grid_count`.
3173    /// Headless render with identity per-grid world transforms (shadows stay
3174    /// intra-grid). See [`Self::render_with_transforms`] for the cross-grid
3175    /// (XS.3) variant.
3176    #[must_use]
3177    #[allow(clippy::too_many_arguments)]
3178    pub fn render(
3179        &self,
3180        device: &wgpu::Device,
3181        queue: &wgpu::Queue,
3182        scene: &GpuSceneResident,
3183        cameras: &[Camera],
3184        fov_y_rad: f32,
3185        max_outer_steps: u32,
3186        mip_scan_dist: f32,
3187    ) -> Vec<u32> {
3188        self.render_with_transforms(
3189            device,
3190            queue,
3191            scene,
3192            cameras,
3193            &[],
3194            fov_y_rad,
3195            max_outer_steps,
3196            mip_scan_dist,
3197        )
3198    }
3199
3200    /// XS.3 — headless render with explicit per-grid world transforms, so the
3201    /// scene shader can lift a shadow ray to world space and test it against
3202    /// every grid (cross-grid shadows). Empty `grid_world` ⇒ identity.
3203    #[must_use]
3204    #[allow(clippy::too_many_arguments)]
3205    pub fn render_with_transforms(
3206        &self,
3207        device: &wgpu::Device,
3208        queue: &wgpu::Queue,
3209        scene: &GpuSceneResident,
3210        cameras: &[Camera],
3211        grid_world: &[GridWorldTransform],
3212        fov_y_rad: f32,
3213        max_outer_steps: u32,
3214        mip_scan_dist: f32,
3215    ) -> Vec<u32> {
3216        assert_eq!(
3217            cameras.len(),
3218            scene.grid_count as usize,
3219            "headless render: {} cameras for {} grids",
3220            cameras.len(),
3221            scene.grid_count,
3222        );
3223
3224        let mut cam_vec: Vec<SceneDdaPerGridCamera> = cameras
3225            .iter()
3226            .map(SceneDdaPerGridCamera::from_camera)
3227            .collect();
3228        // XS.3 — stamp world transforms for cross-grid shadows (identity if absent).
3229        for (c, t) in cam_vec.iter_mut().zip(grid_world.iter()) {
3230            c.set_world_transform(t);
3231        }
3232        // TV.6 — opaque dummies for the material palette + terrain map
3233        // bindings (headless renders opaque-only: terrain_has_translucent=0).
3234        let (dummy_pal, dummy_map) = {
3235            use wgpu::util::DeviceExt;
3236            let pal: Vec<MaterialGpu> = vec![
3237                MaterialGpu {
3238                    alpha: 1.0,
3239                    mode: 0,
3240                    emissive: 0.0,
3241                    _pad: 0,
3242                };
3243                256
3244            ];
3245            let p = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
3246                label: Some("roxlap-gpu headless.materials_pal"),
3247                contents: bytemuck::cast_slice(&pal),
3248                usage: wgpu::BufferUsages::STORAGE,
3249            });
3250            let m = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
3251                label: Some("roxlap-gpu headless.terrain_map"),
3252                contents: bytemuck::cast_slice(&[[0u32; 2]]),
3253                usage: wgpu::BufferUsages::STORAGE,
3254            });
3255            (p, m)
3256        };
3257        // DL — pack any dynamic lights (default none ⇒ the baked-only path,
3258        // matching the oracle goldens). Injects sun dir into cam_vec.sun_dir
3259        // and builds the point-light buffer (binding 18). Shared with the
3260        // surface path.
3261        let dl = self.lights.clone();
3262        inject_grid_sun_dirs(&dl, &mut cam_vec);
3263        let (packed_lights, sun_flags, point_count) =
3264            pack_scene_lights(&dl, scene.grid_count as usize);
3265        let dummy_point_lights = upload_grid_point_lights(device, &packed_lights);
3266        let grid_cameras = upload_grid_cameras(device, &cam_vec);
3267        let uniform = SceneDdaUniform {
3268            fov_y_rad,
3269            grid_count: scene.grid_count,
3270            max_outer_steps,
3271            _pad0: 0,
3272            screen_size: [self.width, self.height],
3273            _pad1: [0; 2],
3274            // Fog off: near/far past any reachable t → factor 0.
3275            fog_color: [0.0, 0.0, 0.0, 1.0e29],
3276            fog_far: 1.0e30,
3277            write_depth: 0,
3278            occ_page_words: scene.occupancy_page_words,
3279            occ_num_pages: scene.occupancy_num_pages,
3280            mip_scan_dist,
3281            terrain_has_translucent: 0, // headless gate: opaque only
3282            terrain_map_count: 0,
3283            _pad4: 0,
3284            // Sky direction from the first grid camera (the world frame
3285            // in these tests); a default forward camera when there are
3286            // none (grid_count == 0) so the sky lookup stays valid.
3287            sky_cam: SceneDdaPerGridCamera::from_camera(&cameras.first().copied().unwrap_or(
3288                Camera {
3289                    position: [0.0; 3],
3290                    right: [1.0, 0.0, 0.0],
3291                    down: [0.0, 0.0, 1.0],
3292                    forward: [0.0, 1.0, 0.0],
3293                    fov_y_rad,
3294                },
3295            )),
3296            side_shades0: self.side_shades[0],
3297            side_shades1: self.side_shades[1],
3298            // DL — light parameters (default = no lights ⇒ sun_flags 0).
3299            sun_color: [
3300                dl.sun_color[0],
3301                dl.sun_color[1],
3302                dl.sun_color[2],
3303                dl.sun_intensity,
3304            ],
3305            ambient_color: [
3306                dl.ambient[0],
3307                dl.ambient[1],
3308                dl.ambient[2],
3309                dl.shadow_strength,
3310            ],
3311            sun_flags,
3312            point_light_count: point_count,
3313            shadow_max_steps: dl.shadow_max_steps,
3314            _pad5: 0,
3315            shadow_bias: dl.shadow_bias,
3316            shadow_max_dist: dl.shadow_max_dist,
3317            _pad6: [0.0; 2],
3318            shadow_tint: [dl.shadow_tint[0], dl.shadow_tint[1], dl.shadow_tint[2], 0.0],
3319            style_bands: dl.style_bands,
3320            sprite_cast_count: 0, // headless renderer has no sprite pass
3321            _pad7: [0; 2],
3322        };
3323        queue.write_buffer(&self.uniform_buf, 0, bytemuck::bytes_of(&uniform));
3324
3325        let bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
3326            label: Some("roxlap-gpu headless.bg"),
3327            layout: &self.bgl,
3328            entries: &[
3329                wgpu::BindGroupEntry {
3330                    binding: 0,
3331                    resource: self.uniform_buf.as_entire_binding(),
3332                },
3333                wgpu::BindGroupEntry {
3334                    binding: 1,
3335                    resource: scene.occupancy_pages[0].as_entire_binding(),
3336                },
3337                wgpu::BindGroupEntry {
3338                    binding: 2,
3339                    resource: scene.all_color_offsets.as_entire_binding(),
3340                },
3341                wgpu::BindGroupEntry {
3342                    binding: 3,
3343                    resource: scene.all_colors.as_entire_binding(),
3344                },
3345                wgpu::BindGroupEntry {
3346                    binding: 4,
3347                    resource: scene.all_chunk_colors_base.as_entire_binding(),
3348                },
3349                wgpu::BindGroupEntry {
3350                    binding: 5,
3351                    resource: scene.all_chunk_occupancy.as_entire_binding(),
3352                },
3353                wgpu::BindGroupEntry {
3354                    binding: 6,
3355                    resource: scene.grid_static_meta.as_entire_binding(),
3356                },
3357                wgpu::BindGroupEntry {
3358                    binding: 7,
3359                    resource: scene.all_slot_chunk_idx.as_entire_binding(),
3360                },
3361                wgpu::BindGroupEntry {
3362                    binding: 8,
3363                    resource: self.framebuffer.as_entire_binding(),
3364                },
3365                wgpu::BindGroupEntry {
3366                    binding: 9,
3367                    resource: wgpu::BindingResource::TextureView(&self.sky_view),
3368                },
3369                wgpu::BindGroupEntry {
3370                    binding: 10,
3371                    resource: wgpu::BindingResource::Sampler(&self.sky_sampler),
3372                },
3373                wgpu::BindGroupEntry {
3374                    binding: 11,
3375                    resource: self.depth_buffer.as_entire_binding(),
3376                },
3377                wgpu::BindGroupEntry {
3378                    binding: 12,
3379                    resource: scene.occupancy_pages[1].as_entire_binding(),
3380                },
3381                wgpu::BindGroupEntry {
3382                    binding: 13,
3383                    resource: scene.occupancy_pages[2].as_entire_binding(),
3384                },
3385                wgpu::BindGroupEntry {
3386                    binding: 14,
3387                    resource: scene.occupancy_pages[3].as_entire_binding(),
3388                },
3389                wgpu::BindGroupEntry {
3390                    binding: 15,
3391                    resource: grid_cameras.as_entire_binding(),
3392                },
3393                wgpu::BindGroupEntry {
3394                    binding: 16,
3395                    resource: dummy_pal.as_entire_binding(),
3396                },
3397                wgpu::BindGroupEntry {
3398                    binding: 17,
3399                    resource: dummy_map.as_entire_binding(),
3400                },
3401                // DL — dummy per-grid point lights (18). Sun dir rides in
3402                // PerGridCamera (binding 15).
3403                wgpu::BindGroupEntry {
3404                    binding: 18,
3405                    resource: dummy_point_lights.as_entire_binding(),
3406                },
3407            ],
3408        });
3409
3410        let mut enc =
3411            device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
3412        {
3413            let mut pass = enc.begin_compute_pass(&wgpu::ComputePassDescriptor {
3414                label: Some("roxlap-gpu headless.pass"),
3415                timestamp_writes: None,
3416            });
3417            pass.set_pipeline(&self.pipeline);
3418            pass.set_bind_group(0, &bg, &[]);
3419            pass.dispatch_workgroups(self.width.div_ceil(8), self.height.div_ceil(8), 1);
3420        }
3421        enc.copy_buffer_to_buffer(
3422            &self.framebuffer,
3423            0,
3424            &self.readback,
3425            0,
3426            u64::from(self.width) * u64::from(self.height) * 4,
3427        );
3428        queue.submit(Some(enc.finish()));
3429
3430        let slice = self.readback.slice(..);
3431        let (tx, rx) = std::sync::mpsc::channel();
3432        slice.map_async(wgpu::MapMode::Read, move |r| {
3433            let _ = tx.send(r);
3434        });
3435        device.poll(wgpu::PollType::wait_indefinitely()).ok();
3436        rx.recv().expect("map_async channel").expect("map_async");
3437
3438        let data = slice.get_mapped_range();
3439        // Tight `width*height` packed pixels — the shader's
3440        // `pack4x8unorm(vec4(r,g,b,a))` already yields `0xAABBGGRR`
3441        // little-endian, so a straight u32 read reconstructs each pixel.
3442        let out: Vec<u32> = data
3443            .chunks_exact(4)
3444            .map(|px| u32::from_le_bytes([px[0], px[1], px[2], px[3]]))
3445            .collect();
3446        drop(data);
3447        self.readback.unmap();
3448        out
3449    }
3450}
3451
3452fn bgl_uniform_entry(binding: u32) -> wgpu::BindGroupLayoutEntry {
3453    wgpu::BindGroupLayoutEntry {
3454        binding,
3455        visibility: wgpu::ShaderStages::COMPUTE,
3456        ty: wgpu::BindingType::Buffer {
3457            ty: wgpu::BufferBindingType::Uniform,
3458            has_dynamic_offset: false,
3459            min_binding_size: None,
3460        },
3461        count: None,
3462    }
3463}
3464
3465fn bgl_storage_entry(binding: u32, read_only: bool) -> wgpu::BindGroupLayoutEntry {
3466    wgpu::BindGroupLayoutEntry {
3467        binding,
3468        visibility: wgpu::ShaderStages::COMPUTE,
3469        ty: wgpu::BindingType::Buffer {
3470            ty: wgpu::BufferBindingType::Storage { read_only },
3471            has_dynamic_offset: false,
3472            min_binding_size: None,
3473        },
3474        count: None,
3475    }
3476}
3477
3478/// Create a fresh sky panorama texture sized `width × height` with
3479/// the initial pixel data uploaded via `write_texture`. Used by
3480/// `GpuRenderer::new` (1×1 default) and `set_sky_panorama` (host-
3481/// supplied panorama).
3482fn create_sky_texture(
3483    device: &wgpu::Device,
3484    width: u32,
3485    height: u32,
3486    _initial_pixels: &[u8],
3487) -> (wgpu::Texture, wgpu::TextureView) {
3488    let tex = device.create_texture(&wgpu::TextureDescriptor {
3489        label: Some("roxlap-gpu sky_texture"),
3490        size: wgpu::Extent3d {
3491            width,
3492            height,
3493            depth_or_array_layers: 1,
3494        },
3495        mip_level_count: 1,
3496        sample_count: 1,
3497        dimension: wgpu::TextureDimension::D2,
3498        format: wgpu::TextureFormat::Rgba8Unorm,
3499        usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
3500        view_formats: &[],
3501    });
3502    let view = tex.create_view(&wgpu::TextureViewDescriptor::default());
3503    (tex, view)
3504}
3505
3506/// GPU.4 needs to upload a whole grid (~hundreds of MiB) as a few
3507/// storage buffers. wgpu's default `max_storage_buffer_binding_size`
3508/// is 128 MiB, which is just enough for the demo's 32×32 ground
3509/// occupancy (~128 MiB) but not the colour array. We request as
3510/// much as the adapter is willing to give — most desktop GPUs cap
3511/// individual storage buffers at 2-4 GiB; iGPUs often offer the
3512/// full system memory.
3513pub(crate) fn pick_required_limits(adapter_limits: &wgpu::Limits) -> wgpu::Limits {
3514    wgpu::Limits {
3515        max_storage_buffer_binding_size: adapter_limits.max_storage_buffer_binding_size,
3516        max_buffer_size: adapter_limits.max_buffer_size,
3517        // Occupancy paging adds up to MAX_OCC_PAGES-1 extra storage
3518        // bindings; with the scene's other buffers + the GPU.9 depth
3519        // buffer the scene_dda stage needs 16. XS.4 GPU sprite shadows
3520        // need more (the sprite pass binds the terrain occupancy set on
3521        // top of its own — up to `SPRITE_SHADOW_MIN_STORAGE_BUFFERS`), so
3522        // request that many when the adapter offers them; capable devices
3523        // light up sprite shadows, others fall back (still ≥16 for the
3524        // base renderer). Both NVK and lavapipe advertise ≫16.
3525        max_storage_buffers_per_shader_stage: adapter_limits
3526            .max_storage_buffers_per_shader_stage
3527            .min(SPRITE_SHADOW_MIN_STORAGE_BUFFERS),
3528        ..wgpu::Limits::default()
3529    }
3530}
3531
3532/// XS.4 — storage buffers per shader stage needed for GPU sprite shadows. The
3533/// sprite pass binds its own 14 + the terrain occupancy set (occupancy pages
3534/// 0..3, chunk occupancy, slot index, grid meta, per-grid cameras) to march
3535/// terrain shadows. Devices granting fewer fall back to unshadowed GPU sprites.
3536pub(crate) const SPRITE_SHADOW_MIN_STORAGE_BUFFERS: u32 = 22;
3537
3538fn pick_present_mode(modes: &[wgpu::PresentMode]) -> wgpu::PresentMode {
3539    // Prefer Mailbox > Immediate > Fifo. Fifo is the universal
3540    // fallback and the only one Wayland-on-Mesa always offers.
3541    for &m in &[wgpu::PresentMode::Mailbox, wgpu::PresentMode::Immediate] {
3542        if modes.contains(&m) {
3543            return m;
3544        }
3545    }
3546    wgpu::PresentMode::Fifo
3547}