Skip to main content

roxlap_gpu/
overlay.rs

1//! QE.8b — deferred overlay passes, split verbatim out of `lib.rs`:
2//! the world-space debug-line pass (`line.wgsl`), the textured
3//! image-quad pass (`image.wgsl`) with its retained image textures,
4//! and the egui HUD paint (`hud` feature). All draw `LoadOp::Load`
5//! over the pending frame, between `render_scene` and `present`.
6
7use bytemuck::{Pod, Zeroable};
8
9use crate::GpuRenderer;
10
11/// WGPU-backed renderer. Owns the device, queue, and surface
12/// bound to the host's window. [`GpuRenderer::render`] is the GPU.1
13/// clear-to-colour path; [`GpuRenderer::render_scene`] is the
14/// multi-grid scene marcher.
15///
16/// The window is consumed only at construction — `wgpu`'s
17/// `Surface<'static>` keeps its own `Arc` clone of the handle, so
18/// the renderer holds no window field of its own.
19/// A world-space line segment for [`GpuRenderer::draw_lines_deferred`].
20/// `color` is straight RGBA in `0..=1` (the alpha drives the over-blend);
21/// `width_px` is the screen-space thickness; `depth_test` occludes the
22/// segment behind nearer marched geometry.
23#[derive(Clone, Copy, Debug)]
24pub struct GpuLine {
25    /// First endpoint, world voxel units.
26    pub a: [f32; 3],
27    /// Second endpoint, world voxel units.
28    pub b: [f32; 3],
29    /// Straight (non-premultiplied) RGBA, each channel `0..=1`; alpha
30    /// drives the over-blend onto the frame.
31    pub color: [f32; 4],
32    /// Screen-space line thickness in pixels; values below `1.0` are
33    /// clamped up to 1 px.
34    pub width_px: f32,
35    /// `true` ⇒ fragments behind nearer marched scene geometry are
36    /// discarded (with a small bias so surface-hugging lines don't
37    /// z-fight); `false` ⇒ always drawn on top.
38    pub depth_test: bool,
39}
40
41/// World camera basis for projecting [`GpuLine`] endpoints — the same
42/// pinhole the scene-DDA pass marches with (`right`/`down`/`forward`
43/// orthonormal, `pos` in world voxel units).
44#[derive(Clone, Copy, Debug)]
45pub struct GpuLineCamera {
46    /// Eye position in world voxel units.
47    pub pos: [f32; 3],
48    /// Unit basis toward screen-right (must match the scene camera's
49    /// right-handed `right × down == forward` basis).
50    pub right: [f32; 3],
51    /// Unit basis toward screen-down (+z is down in voxlap space).
52    pub down: [f32; 3],
53    /// Unit view direction; endpoints with a forward component below
54    /// the near plane are clipped before projection.
55    pub forward: [f32; 3],
56}
57
58/// Near plane (camera-forward distance) below which a [`GpuLine`] endpoint
59/// is clipped, so the pinhole divide stays finite.
60pub(crate) const LINE_NEAR_Z: f32 = 0.0625;
61/// Depth-test slack (euclidean world distance) so a line resting on the
62/// surface it traces doesn't z-fight the marched geometry.
63const LINE_DEPTH_BIAS: f32 = 0.5;
64
65/// One expanded-quad vertex (`build_line_vertices` output). `pos` is NDC;
66/// `depth` is the euclidean world distance of the source endpoint (the
67/// marcher's `best_t` metric); `depth_test` is `1.0`/`0.0`.
68#[repr(C)]
69#[derive(Clone, Copy, Pod, Zeroable)]
70struct LineVertex {
71    pos: [f32; 2],
72    depth: f32,
73    depth_test: f32,
74    color: [f32; 4],
75}
76
77/// `line.wgsl` / `image.wgsl` fragment uniform (std140; padded to 32 bytes
78/// so the uniform's struct stride is a 16-byte multiple).
79#[repr(C)]
80#[derive(Clone, Copy, Pod, Zeroable)]
81struct LineParams {
82    /// Target (swapchain) size — the range of the fragment's `clip.xy`.
83    screen_w: u32,
84    screen_h: u32,
85    depth_bias: f32,
86    no_depth: u32,
87    /// 1 when the viewport flip is on. The depth buffer is written
88    /// unflipped (the blit mirrors at read time), but these passes flip the
89    /// vertex NDC X, so the fragment must mirror its depth lookup to match.
90    flip_x: u32,
91    /// RP.0 — the **render** (logical) size the depth buffer is stored at.
92    /// The fragment scales its swapchain `clip.xy` into this grid for the
93    /// depth lookup. Equal to `screen_*` under `Native` (identity).
94    depth_w: u32,
95    depth_h: u32,
96    _pad: u32,
97}
98
99/// Lazy-built debug-line pipeline (L3.2). The bind group is rebuilt each
100/// draw (it references the current `scene_dda.depth_buffer`, which the
101/// swapchain resize recreates); the pipeline / layout / uniform persist.
102pub(crate) struct LineResources {
103    pipeline: wgpu::RenderPipeline,
104    bgl: wgpu::BindGroupLayout,
105    uniform_buf: wgpu::Buffer,
106    /// 1-word stand-in bound when no scene depth exists (sprite-only /
107    /// empty scene); `no_depth = 1` keeps the shader from indexing it.
108    dummy_depth: wgpu::Buffer,
109}
110
111/// Project + expand world-space [`GpuLine`]s into screen-space quad
112/// vertices (6 per visible segment) for `line.wgsl`. Mirrors the
113/// scene-DDA pinhole (`forward + ndc_x·half_w·right − ndc_y·half_h·down`)
114/// so lines land on the marched geometry, carrying each endpoint's
115/// euclidean world distance as the depth-test key (= the marcher's
116/// `best_t`). Segments fully behind the near plane are dropped; the rest
117/// are clipped to it.
118fn build_line_vertices(
119    cam: &GpuLineCamera,
120    lines: &[GpuLine],
121    w: u32,
122    h: u32,
123    fov_y: f32,
124    flip_x: bool,
125) -> Vec<LineVertex> {
126    let aspect = w as f32 / h as f32;
127    let half_h = (fov_y * 0.5).tan();
128    let half_w = half_h * aspect;
129    let (wf, hf) = (w as f32, h as f32);
130
131    let cam_coords = |p: [f32; 3]| -> [f32; 3] {
132        let d = [p[0] - cam.pos[0], p[1] - cam.pos[1], p[2] - cam.pos[2]];
133        [
134            cam.right[0] * d[0] + cam.right[1] * d[1] + cam.right[2] * d[2],
135            cam.down[0] * d[0] + cam.down[1] * d[1] + cam.down[2] * d[2],
136            cam.forward[0] * d[0] + cam.forward[1] * d[1] + cam.forward[2] * d[2],
137        ]
138    };
139    // Camera-space point → (NDC xy, euclidean depth). NDC y is up (+1 top),
140    // matching WebGPU clip space; depth is the marcher's world-t metric.
141    let project = |q: [f32; 3]| -> ([f32; 2], f32) {
142        let inv = 1.0 / q[2];
143        let nx = q[0] * inv / half_w;
144        let ny = -q[1] * inv / half_h;
145        let depth = (q[0] * q[0] + q[1] * q[1] + q[2] * q[2]).sqrt();
146        ([nx, ny], depth)
147    };
148
149    let mut out = Vec::with_capacity(lines.len() * 6);
150    for line in lines {
151        let ca = cam_coords(line.a);
152        let cb = cam_coords(line.b);
153        let (cfa, cfb) = (ca[2], cb[2]);
154        if cfa < LINE_NEAR_Z && cfb < LINE_NEAR_Z {
155            continue;
156        }
157        // Near-clip in segment-parameter space on the forward component.
158        let (mut t0, mut t1) = (0.0f32, 1.0f32);
159        let dz = cfb - cfa;
160        if dz.abs() > f32::EPSILON {
161            let tn = (LINE_NEAR_Z - cfa) / dz;
162            if dz > 0.0 {
163                t0 = t0.max(tn);
164            } else {
165                t1 = t1.min(tn);
166            }
167        }
168        if t0 > t1 {
169            continue;
170        }
171        let lerp3 = |t: f32| {
172            [
173                ca[0] + (cb[0] - ca[0]) * t,
174                ca[1] + (cb[1] - ca[1]) * t,
175                ca[2] + (cb[2] - ca[2]) * t,
176            ]
177        };
178        let (n0, d0) = project(lerp3(t0));
179        let (n1, d1) = project(lerp3(t1));
180
181        // Expand in pixel space for a uniform screen-space thickness.
182        let to_px = |n: [f32; 2]| [(n[0] * 0.5 + 0.5) * wf, (0.5 - n[1] * 0.5) * hf];
183        let to_ndc = |p: [f32; 2]| [p[0] / wf * 2.0 - 1.0, 1.0 - p[1] / hf * 2.0];
184        let p0 = to_px(n0);
185        let p1 = to_px(n1);
186        let (dx, dy) = (p1[0] - p0[0], p1[1] - p0[1]);
187        let len = (dx * dx + dy * dy).sqrt().max(1e-6);
188        let half = line.width_px.max(1.0) * 0.5;
189        let (ex, ey) = (-dy / len * half, dx / len * half);
190
191        let c0a = to_ndc([p0[0] + ex, p0[1] + ey]);
192        let c0b = to_ndc([p0[0] - ex, p0[1] - ey]);
193        let c1a = to_ndc([p1[0] + ex, p1[1] + ey]);
194        let c1b = to_ndc([p1[0] - ex, p1[1] - ey]);
195        let dt = if line.depth_test { 1.0 } else { 0.0 };
196        // Mirror the overlay's NDC x to match the flipped scene blit.
197        let vert = |pos: [f32; 2], depth: f32| LineVertex {
198            pos: [if flip_x { -pos[0] } else { pos[0] }, pos[1]],
199            depth,
200            depth_test: dt,
201            color: line.color,
202        };
203        // Two triangles, cull disabled so winding is irrelevant.
204        out.push(vert(c0a, d0));
205        out.push(vert(c0b, d0));
206        out.push(vert(c1a, d1));
207        out.push(vert(c1a, d1));
208        out.push(vert(c0b, d0));
209        out.push(vert(c1b, d1));
210    }
211    out
212}
213
214/// A world-space 2D image-sprite quad for [`GpuRenderer::draw_images_deferred`].
215/// `corners` are the four world points `TL, TR, BL, BR` (UVs `(0,0) (1,0)
216/// (0,1) (1,1)`); `image` indexes a texture uploaded via
217/// [`GpuRenderer::upload_image`]; `tint` is straight RGBA in `0..=1`
218/// (multiplied into every texel); `depth_test` occludes the quad behind
219/// nearer marched geometry. The facade resolves orientation + back-face
220/// culling, so this is pure geometry.
221#[derive(Clone, Copy, Debug)]
222pub struct GpuImageQuad {
223    /// The four world-space corner points in `TL, TR, BL, BR` order
224    /// (voxel units), mapping to UVs `(0,0) (1,0) (0,1) (1,1)`. Need
225    /// not be planar-axis-aligned; the quad is split into two
226    /// perspective-correct triangles.
227    pub corners: [[f32; 3]; 4],
228    /// Texture handle returned by [`GpuRenderer::upload_image`].
229    /// Quads referencing an id that was never uploaded are skipped.
230    pub image: usize,
231    /// Straight RGBA multiplier `0..=1` applied to every texel
232    /// (`[1.0; 4]` = untinted); the combined alpha drives the
233    /// over-blend.
234    pub tint: [f32; 4],
235    /// `true` ⇒ fragments behind nearer marched scene geometry are
236    /// discarded; `false` ⇒ always drawn on top.
237    pub depth_test: bool,
238    /// Texels with alpha below this (`0..=1`) are discarded in the FS.
239    /// `0.0` keeps the plain over-blend.
240    pub alpha_cutoff: f32,
241}
242
243/// One expanded textured-quad vertex (`build_image_vertices` output).
244/// `ndc` is the projected NDC xy; `w` is the source `forward` depth, fed
245/// back into a homogeneous clip position so the rasterizer interpolates
246/// `uv` perspective-correctly; `depth` is the euclidean world distance
247/// (the marcher's `best_t`) for the manual depth test.
248#[repr(C)]
249#[derive(Clone, Copy, Pod, Zeroable)]
250struct ImageVertex {
251    ndc: [f32; 2],
252    w: f32,
253    depth: f32,
254    depth_test: f32,
255    cutoff: f32,
256    uv: [f32; 2],
257    tint: [f32; 4],
258}
259
260/// Lazy-built image-sprite pipeline (mirrors [`LineResources`]). The
261/// per-draw bind group adds the quad's texture + a sampler to the line
262/// pass's uniform + scene-depth bindings.
263pub(crate) struct ImageResources {
264    pipeline: wgpu::RenderPipeline,
265    bgl: wgpu::BindGroupLayout,
266    uniform_buf: wgpu::Buffer,
267    dummy_depth: wgpu::Buffer,
268    sampler: wgpu::Sampler,
269}
270
271/// A retained image-sprite texture (uploaded via
272/// [`GpuRenderer::upload_image`], referenced by [`GpuImageQuad::image`]).
273pub(crate) struct ImageResident {
274    view: wgpu::TextureView,
275    // Held so the view stays valid + the texture shows in profiler dumps.
276    _texture: wgpu::Texture,
277}
278
279/// Camera-space textured-quad vertex (near-clip working set): the
280/// `(right, down, forward)` components + the texture `uv`.
281#[derive(Clone, Copy)]
282struct ImgClipV {
283    cam: [f32; 3],
284    uv: [f32; 2],
285}
286
287/// Clip a convex camera-space polygon against the near plane
288/// (`forward >= LINE_NEAR_Z`), interpolating UVs at each crossing.
289fn clip_near_image(poly: &[ImgClipV]) -> Vec<ImgClipV> {
290    let n = poly.len();
291    let mut out: Vec<ImgClipV> = Vec::with_capacity(n + 1);
292    for i in 0..n {
293        let cur = poly[i];
294        let prev = poly[(i + n - 1) % n];
295        let cur_in = cur.cam[2] >= LINE_NEAR_Z;
296        let prev_in = prev.cam[2] >= LINE_NEAR_Z;
297        if cur_in != prev_in {
298            let t = (LINE_NEAR_Z - prev.cam[2]) / (cur.cam[2] - prev.cam[2]);
299            out.push(ImgClipV {
300                cam: [
301                    prev.cam[0] + (cur.cam[0] - prev.cam[0]) * t,
302                    prev.cam[1] + (cur.cam[1] - prev.cam[1]) * t,
303                    LINE_NEAR_Z,
304                ],
305                uv: [
306                    prev.uv[0] + (cur.uv[0] - prev.uv[0]) * t,
307                    prev.uv[1] + (cur.uv[1] - prev.uv[1]) * t,
308                ],
309            });
310        }
311        if cur_in {
312            out.push(cur);
313        }
314    }
315    out
316}
317
318/// Project + near-clip a world-space [`GpuImageQuad`] into perspective-correct
319/// textured-quad vertices for `image.wgsl`. Mirrors the scene-DDA pinhole
320/// (the same one [`build_line_vertices`] uses), carrying each vertex's
321/// euclidean world distance as the depth-test key. Quads fully behind the
322/// near plane produce no vertices.
323fn build_image_vertices(
324    cam: &GpuLineCamera,
325    quad: &GpuImageQuad,
326    w: u32,
327    h: u32,
328    fov_y: f32,
329    flip_x: bool,
330) -> Vec<ImageVertex> {
331    let aspect = w as f32 / h as f32;
332    let half_h = (fov_y * 0.5).tan();
333    let half_w = half_h * aspect;
334    let dt = if quad.depth_test { 1.0 } else { 0.0 };
335
336    let cam_coords = |p: [f32; 3]| -> [f32; 3] {
337        let d = [p[0] - cam.pos[0], p[1] - cam.pos[1], p[2] - cam.pos[2]];
338        [
339            cam.right[0] * d[0] + cam.right[1] * d[1] + cam.right[2] * d[2],
340            cam.down[0] * d[0] + cam.down[1] * d[1] + cam.down[2] * d[2],
341            cam.forward[0] * d[0] + cam.forward[1] * d[1] + cam.forward[2] * d[2],
342        ]
343    };
344    let project = |v: ImgClipV| -> ImageVertex {
345        let (cx, cy, cz) = (v.cam[0], v.cam[1], v.cam[2]);
346        let nx = cx / (cz * half_w);
347        ImageVertex {
348            // Mirror NDC x to match the flipped scene blit.
349            ndc: [if flip_x { -nx } else { nx }, -cy / (cz * half_h)],
350            w: cz,
351            depth: (cx * cx + cy * cy + cz * cz).sqrt(),
352            depth_test: dt,
353            cutoff: quad.alpha_cutoff,
354            uv: v.uv,
355            tint: quad.tint,
356        }
357    };
358
359    // Per-corner UV: TL(0,0) TR(1,0) BL(0,1) BR(1,1).
360    let uvs = [[0.0, 0.0], [1.0, 0.0], [0.0, 1.0], [1.0, 1.0]];
361    let verts: Vec<ImgClipV> = quad
362        .corners
363        .iter()
364        .zip(uvs)
365        .map(|(c, uv)| ImgClipV {
366            cam: cam_coords(*c),
367            uv,
368        })
369        .collect();
370
371    let mut out = Vec::with_capacity(12);
372    for tri in [[0usize, 1, 2], [1, 3, 2]] {
373        let poly = [verts[tri[0]], verts[tri[1]], verts[tri[2]]];
374        let clipped = clip_near_image(&poly);
375        if clipped.len() < 3 {
376            continue;
377        }
378        for i in 1..clipped.len() - 1 {
379            out.push(project(clipped[0]));
380            out.push(project(clipped[i]));
381            out.push(project(clipped[i + 1]));
382        }
383    }
384    out
385}
386
387impl GpuRenderer {
388    /// Draw depth-tested world-space [`GpuLine`]s over the pending frame
389    /// (L3.2). Projects each endpoint with `cam` (the marcher's pinhole) +
390    /// the last frame's FOV / surface size, expands to screen-space quads,
391    /// and runs a `LoadOp::Load` pass into the pending swapchain view — so
392    /// the lines land on the marched frame and a later `present` /
393    /// `paint_egui` still finishes it (the pending frame is left intact).
394    /// Depth-tested lines are occluded by nearer marched geometry (compared
395    /// against the scene-DDA depth buffer's `best_t`); call after `render`,
396    /// before `present` / `paint_egui`. No-op if no frame is pending.
397    pub fn draw_lines_deferred(&mut self, cam: &GpuLineCamera, lines: &[GpuLine]) {
398        if self.pending_frame.is_none() || lines.is_empty() {
399            return;
400        }
401        let (w, h) = (self.surface_config.width, self.surface_config.height);
402        // RP.0 — project with the render (logical) aspect so the lines align
403        // with the upscaled scene; the depth buffer is render-sized too.
404        let (rw, rh) = self.render_dims();
405        let fov = self.last_fov_y_rad;
406        if w == 0 || h == 0 || fov <= 0.0 {
407            return; // no frame marched yet — no projection to reuse
408        }
409        let verts = build_line_vertices(cam, lines, rw, rh, fov, self.flip_x);
410        if verts.is_empty() {
411            return;
412        }
413        self.ensure_line_resources();
414        let res = self.line_resources.as_ref().expect("just built");
415
416        // Skip the depth test when there's no current scene depth to read —
417        // either no buffer at all (sprite-only / never-rendered) or this
418        // frame was a color-only clear so the buffer is stale (an empty
419        // scene drawn after a grid scene). The 1-word dummy / stale buffer
420        // is still bound to satisfy the layout; `no_depth = 1` keeps the
421        // shader from indexing it.
422        let no_depth = u32::from(self.scene_dda.is_none() || !self.dirty.scene_depth_valid);
423        let params = LineParams {
424            screen_w: w,
425            screen_h: h,
426            depth_bias: LINE_DEPTH_BIAS,
427            no_depth,
428            flip_x: u32::from(self.flip_x),
429            depth_w: rw,
430            depth_h: rh,
431            _pad: 0,
432        };
433        self.queue
434            .write_buffer(&res.uniform_buf, 0, bytemuck::bytes_of(&params));
435
436        // PF.13 (H7-lite) — the bind group depends only on the uniform
437        // buffer (stable) and the depth buffer identity, so cache it and
438        // rebuild only when the depth buffer is swapped (resize / scene
439        // rebuild). wgpu buffers compare by identity.
440        let depth_key: Option<wgpu::Buffer> =
441            self.scene_dda.as_ref().map(|dda| dda.depth_buffer.clone());
442        if !matches!(&self.line_bg_cache, Some((_, key)) if *key == depth_key) {
443            let depth_resource = match &self.scene_dda {
444                Some(dda) => dda.depth_buffer.as_entire_binding(),
445                None => res.dummy_depth.as_entire_binding(),
446            };
447            let bg = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
448                label: Some("roxlap-gpu line.bg"),
449                layout: &res.bgl,
450                entries: &[
451                    wgpu::BindGroupEntry {
452                        binding: 0,
453                        resource: res.uniform_buf.as_entire_binding(),
454                    },
455                    wgpu::BindGroupEntry {
456                        binding: 1,
457                        resource: depth_resource,
458                    },
459                ],
460            });
461            self.line_bg_cache = Some((bg, depth_key));
462        }
463        let bg = &self.line_bg_cache.as_ref().expect("just ensured").0;
464
465        // Grow-only persistent vertex buffer (L3.3): one `write_buffer`
466        // per overlay, reused across frames. Power-of-two capacity keeps
467        // re-allocation rare as the segment count drifts.
468        let needed = std::mem::size_of_val(verts.as_slice()) as u64;
469        if self.line_vbuf_cap < needed {
470            let cap = needed.next_power_of_two().max(4096);
471            self.line_vbuf = Some(self.device.create_buffer(&wgpu::BufferDescriptor {
472                label: Some("roxlap-gpu line.vbuf"),
473                size: cap,
474                usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
475                mapped_at_creation: false,
476            }));
477            self.line_vbuf_cap = cap;
478        }
479        let vbuf = self.line_vbuf.as_ref().expect("ensured above");
480        self.queue
481            .write_buffer(vbuf, 0, bytemuck::cast_slice(&verts));
482
483        let view = &self.pending_frame.as_ref().expect("checked above").1;
484        let mut encoder = self
485            .device
486            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
487                label: Some("roxlap-gpu lines"),
488            });
489        {
490            // `LoadOp::Load` keeps the marcher's frame; the lines draw over
491            // it. Manual depth test in the FS (no depth-stencil attachment).
492            let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
493                label: Some("roxlap-gpu line paint"),
494                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
495                    view,
496                    depth_slice: None,
497                    resolve_target: None,
498                    ops: wgpu::Operations {
499                        load: wgpu::LoadOp::Load,
500                        store: wgpu::StoreOp::Store,
501                    },
502                })],
503                depth_stencil_attachment: None,
504                timestamp_writes: None,
505                occlusion_query_set: None,
506                multiview_mask: None,
507            });
508            pass.set_pipeline(&res.pipeline);
509            pass.set_bind_group(0, bg, &[]);
510            pass.set_vertex_buffer(0, vbuf.slice(..));
511            pass.draw(0..verts.len() as u32, 0..1);
512        }
513        self.queue.submit(std::iter::once(encoder.finish()));
514        // pending_frame left intact — present/paint_egui finishes the frame.
515    }
516
517    /// Lazy-build the [`LineResources`] (`line.wgsl` pipeline + uniform +
518    /// dummy depth buffer). The colour target uses the surface format with
519    /// straight-alpha over-blending; no depth-stencil attachment (the depth
520    /// test is manual in the fragment shader against the scene depth buffer).
521    fn ensure_line_resources(&mut self) {
522        if self.line_resources.is_some() {
523            return;
524        }
525        let shader = self
526            .device
527            .create_shader_module(wgpu::ShaderModuleDescriptor {
528                label: Some("line.wgsl"),
529                source: wgpu::ShaderSource::Wgsl(include_str!("../shaders/line.wgsl").into()),
530            });
531        let bgl = self
532            .device
533            .create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
534                label: Some("roxlap-gpu line.bgl"),
535                entries: &[
536                    wgpu::BindGroupLayoutEntry {
537                        binding: 0,
538                        visibility: wgpu::ShaderStages::FRAGMENT,
539                        ty: wgpu::BindingType::Buffer {
540                            ty: wgpu::BufferBindingType::Uniform,
541                            has_dynamic_offset: false,
542                            min_binding_size: None,
543                        },
544                        count: None,
545                    },
546                    wgpu::BindGroupLayoutEntry {
547                        binding: 1,
548                        visibility: wgpu::ShaderStages::FRAGMENT,
549                        ty: wgpu::BindingType::Buffer {
550                            ty: wgpu::BufferBindingType::Storage { read_only: true },
551                            has_dynamic_offset: false,
552                            min_binding_size: None,
553                        },
554                        count: None,
555                    },
556                ],
557            });
558        let layout = self
559            .device
560            .create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
561                label: Some("roxlap-gpu line.layout"),
562                bind_group_layouts: &[Some(&bgl)],
563                immediate_size: 0,
564            });
565        let pipeline = self
566            .device
567            .create_render_pipeline(&wgpu::RenderPipelineDescriptor {
568                label: Some("roxlap-gpu line.pipeline"),
569                layout: Some(&layout),
570                vertex: wgpu::VertexState {
571                    module: &shader,
572                    entry_point: Some("vs_main"),
573                    compilation_options: wgpu::PipelineCompilationOptions::default(),
574                    buffers: &[wgpu::VertexBufferLayout {
575                        array_stride: std::mem::size_of::<LineVertex>() as u64,
576                        step_mode: wgpu::VertexStepMode::Vertex,
577                        attributes: &wgpu::vertex_attr_array![
578                            0 => Float32x2, // pos (NDC)
579                            1 => Float32,   // depth
580                            2 => Float32,   // depth_test
581                            3 => Float32x4, // color
582                        ],
583                    }],
584                },
585                fragment: Some(wgpu::FragmentState {
586                    module: &shader,
587                    entry_point: Some("fs_main"),
588                    compilation_options: wgpu::PipelineCompilationOptions::default(),
589                    targets: &[Some(wgpu::ColorTargetState {
590                        format: self.surface_config.format,
591                        blend: Some(wgpu::BlendState::ALPHA_BLENDING),
592                        write_mask: wgpu::ColorWrites::ALL,
593                    })],
594                }),
595                primitive: wgpu::PrimitiveState {
596                    cull_mode: None,
597                    ..Default::default()
598                },
599                depth_stencil: None,
600                multisample: wgpu::MultisampleState::default(),
601                multiview_mask: None,
602                cache: None,
603            });
604        let uniform_buf = self.device.create_buffer(&wgpu::BufferDescriptor {
605            label: Some("roxlap-gpu line.uniform"),
606            size: std::mem::size_of::<LineParams>() as u64,
607            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
608            mapped_at_creation: false,
609        });
610        let dummy_depth = self.device.create_buffer(&wgpu::BufferDescriptor {
611            label: Some("roxlap-gpu line.dummy_depth"),
612            size: 4,
613            usage: wgpu::BufferUsages::STORAGE,
614            mapped_at_creation: false,
615        });
616        self.line_resources = Some(LineResources {
617            pipeline,
618            bgl,
619            uniform_buf,
620            dummy_depth,
621        });
622    }
623
624    /// Upload (or replace) an RGBA8 image as a sampled texture, returning
625    /// a stable id for [`GpuImageQuad::image`]. `rgba` is row-major,
626    /// `width * height * 4` bytes, straight (un-premultiplied) alpha.
627    /// Reuses a dropped slot when one exists. Returns `0` for malformed
628    /// input (an id that draws nothing).
629    pub fn upload_image(&mut self, rgba: &[u8], width: u32, height: u32) -> usize {
630        if width == 0 || height == 0 || rgba.len() != (width as usize) * (height as usize) * 4 {
631            return 0;
632        }
633        let texture = self.device.create_texture(&wgpu::TextureDescriptor {
634            label: Some("roxlap-gpu image_sprite"),
635            size: wgpu::Extent3d {
636                width,
637                height,
638                depth_or_array_layers: 1,
639            },
640            mip_level_count: 1,
641            sample_count: 1,
642            dimension: wgpu::TextureDimension::D2,
643            format: wgpu::TextureFormat::Rgba8Unorm,
644            usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
645            view_formats: &[],
646        });
647        self.queue.write_texture(
648            wgpu::TexelCopyTextureInfo {
649                texture: &texture,
650                mip_level: 0,
651                origin: wgpu::Origin3d::ZERO,
652                aspect: wgpu::TextureAspect::All,
653            },
654            rgba,
655            wgpu::TexelCopyBufferLayout {
656                offset: 0,
657                bytes_per_row: Some(width * 4),
658                rows_per_image: Some(height),
659            },
660            wgpu::Extent3d {
661                width,
662                height,
663                depth_or_array_layers: 1,
664            },
665        );
666        let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
667        let resident = ImageResident {
668            view,
669            _texture: texture,
670        };
671        if let Some(slot) = self.images.iter().position(Option::is_none) {
672            self.images[slot] = Some(resident);
673            // PF.13 (H7-lite) — the slot now holds a different texture;
674            // any cached bind group for the old occupant is stale.
675            self.image_bg_cache.remove(&slot);
676            slot
677        } else {
678            self.images.push(Some(resident));
679            self.images.len() - 1
680        }
681    }
682
683    /// Release an image uploaded with [`Self::upload_image`] (the slot
684    /// becomes reusable).
685    pub fn drop_image(&mut self, id: usize) {
686        if let Some(slot) = self.images.get_mut(id) {
687            *slot = None;
688            self.image_bg_cache.remove(&id);
689        }
690    }
691
692    /// Draw world-space 2D image sprites ([`GpuImageQuad`]) over the
693    /// pending frame — the textured-quad sibling of
694    /// [`Self::draw_lines_deferred`]. Projects each quad with `cam` (the
695    /// marcher's pinhole) + the last frame's FOV / surface size, expands +
696    /// near-clips to triangles, and runs one `LoadOp::Load` pass with a
697    /// draw per quad (each binds its own texture). UVs are perspective-correct;
698    /// depth-tested quads are occluded by nearer marched geometry. Call
699    /// after `render`, before `present` / `paint_egui`. No-op if no frame
700    /// is pending.
701    pub fn draw_images_deferred(&mut self, cam: &GpuLineCamera, quads: &[GpuImageQuad]) {
702        if self.pending_frame.is_none() || quads.is_empty() {
703            return;
704        }
705        let (w, h) = (self.surface_config.width, self.surface_config.height);
706        // RP.0 — project with the render (logical) aspect (see
707        // `draw_lines_deferred`); depth buffer is render-sized.
708        let (rw, rh) = self.render_dims();
709        let fov = self.last_fov_y_rad;
710        if w == 0 || h == 0 || fov <= 0.0 {
711            return;
712        }
713
714        // Concatenate every quad's verts into one buffer, recording each
715        // quad's (range, texture) so they share a single render pass.
716        let mut verts: Vec<ImageVertex> = Vec::new();
717        let mut draws: Vec<(u32, u32, usize)> = Vec::new();
718        for quad in quads {
719            if !matches!(self.images.get(quad.image), Some(Some(_))) {
720                continue; // dropped / never-uploaded id
721            }
722            let v = build_image_vertices(cam, quad, rw, rh, fov, self.flip_x);
723            if v.is_empty() {
724                continue;
725            }
726            let start = verts.len() as u32;
727            verts.extend_from_slice(&v);
728            draws.push((start, verts.len() as u32, quad.image));
729        }
730        if draws.is_empty() {
731            return;
732        }
733
734        self.ensure_image_resources();
735        // See `draw_lines_deferred`: skip depth when there's no valid
736        // current-frame scene depth (none built, or a color-only clear).
737        let no_depth = u32::from(self.scene_dda.is_none() || !self.dirty.scene_depth_valid);
738        let params = LineParams {
739            screen_w: w,
740            screen_h: h,
741            depth_bias: LINE_DEPTH_BIAS,
742            no_depth,
743            flip_x: u32::from(self.flip_x),
744            depth_w: rw,
745            depth_h: rh,
746            _pad: 0,
747        };
748        {
749            let res = self.image_resources.as_ref().expect("just built");
750            self.queue
751                .write_buffer(&res.uniform_buf, 0, bytemuck::bytes_of(&params));
752        }
753
754        // Grow-only persistent vertex buffer (mirrors the line vbuf).
755        let needed = std::mem::size_of_val(verts.as_slice()) as u64;
756        if self.image_vbuf_cap < needed {
757            let cap = needed.next_power_of_two().max(4096);
758            self.image_vbuf = Some(self.device.create_buffer(&wgpu::BufferDescriptor {
759                label: Some("roxlap-gpu image.vbuf"),
760                size: cap,
761                usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
762                mapped_at_creation: false,
763            }));
764            self.image_vbuf_cap = cap;
765        }
766        let vbuf = self.image_vbuf.as_ref().expect("ensured above");
767        self.queue
768            .write_buffer(vbuf, 0, bytemuck::cast_slice(&verts));
769
770        // One bind group per image id (the texture view differs per
771        // image). PF.13 (H7-lite) — cached across frames keyed by image
772        // id, valid while the depth buffer identity holds; a static HUD
773        // costs zero bind-group creations per frame. Entries evict on
774        // image drop / slot re-upload.
775        let res = self.image_resources.as_ref().expect("just built");
776        let depth_key: Option<wgpu::Buffer> =
777            self.scene_dda.as_ref().map(|dda| dda.depth_buffer.clone());
778        if self.image_bg_depth != depth_key {
779            self.image_bg_cache.clear();
780            self.image_bg_depth = depth_key;
781        }
782        let depth_resource = match &self.scene_dda {
783            Some(dda) => dda.depth_buffer.as_entire_binding(),
784            None => res.dummy_depth.as_entire_binding(),
785        };
786        for &(_, _, image_id) in &draws {
787            if self.image_bg_cache.contains_key(&image_id) {
788                continue;
789            }
790            let resident = self.images[image_id].as_ref().expect("checked present");
791            let bg = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
792                label: Some("roxlap-gpu image.bg"),
793                layout: &res.bgl,
794                entries: &[
795                    wgpu::BindGroupEntry {
796                        binding: 0,
797                        resource: res.uniform_buf.as_entire_binding(),
798                    },
799                    wgpu::BindGroupEntry {
800                        binding: 1,
801                        resource: depth_resource.clone(),
802                    },
803                    wgpu::BindGroupEntry {
804                        binding: 2,
805                        resource: wgpu::BindingResource::TextureView(&resident.view),
806                    },
807                    wgpu::BindGroupEntry {
808                        binding: 3,
809                        resource: wgpu::BindingResource::Sampler(&res.sampler),
810                    },
811                ],
812            });
813            self.image_bg_cache.insert(image_id, bg);
814        }
815
816        let view = &self.pending_frame.as_ref().expect("checked above").1;
817        let mut encoder = self
818            .device
819            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
820                label: Some("roxlap-gpu images"),
821            });
822        {
823            let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
824                label: Some("roxlap-gpu image paint"),
825                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
826                    view,
827                    depth_slice: None,
828                    resolve_target: None,
829                    ops: wgpu::Operations {
830                        load: wgpu::LoadOp::Load,
831                        store: wgpu::StoreOp::Store,
832                    },
833                })],
834                depth_stencil_attachment: None,
835                timestamp_writes: None,
836                occlusion_query_set: None,
837                multiview_mask: None,
838            });
839            pass.set_pipeline(&res.pipeline);
840            pass.set_vertex_buffer(0, vbuf.slice(..));
841            for &(start, end, image_id) in &draws {
842                let bg = self.image_bg_cache.get(&image_id).expect("just ensured");
843                pass.set_bind_group(0, bg, &[]);
844                pass.draw(start..end, 0..1);
845            }
846        }
847        self.queue.submit(std::iter::once(encoder.finish()));
848        // pending_frame left intact — present/paint_egui finishes it.
849    }
850
851    /// Lazy-build the [`ImageResources`] (`image.wgsl` pipeline + uniform +
852    /// nearest sampler + dummy depth). Straight-alpha over-blend, no
853    /// depth-stencil attachment (the depth test is manual in the FS).
854    fn ensure_image_resources(&mut self) {
855        if self.image_resources.is_some() {
856            return;
857        }
858        let shader = self
859            .device
860            .create_shader_module(wgpu::ShaderModuleDescriptor {
861                label: Some("image.wgsl"),
862                source: wgpu::ShaderSource::Wgsl(include_str!("../shaders/image.wgsl").into()),
863            });
864        let bgl = self
865            .device
866            .create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
867                label: Some("roxlap-gpu image.bgl"),
868                entries: &[
869                    wgpu::BindGroupLayoutEntry {
870                        binding: 0,
871                        visibility: wgpu::ShaderStages::FRAGMENT,
872                        ty: wgpu::BindingType::Buffer {
873                            ty: wgpu::BufferBindingType::Uniform,
874                            has_dynamic_offset: false,
875                            min_binding_size: None,
876                        },
877                        count: None,
878                    },
879                    wgpu::BindGroupLayoutEntry {
880                        binding: 1,
881                        visibility: wgpu::ShaderStages::FRAGMENT,
882                        ty: wgpu::BindingType::Buffer {
883                            ty: wgpu::BufferBindingType::Storage { read_only: true },
884                            has_dynamic_offset: false,
885                            min_binding_size: None,
886                        },
887                        count: None,
888                    },
889                    wgpu::BindGroupLayoutEntry {
890                        binding: 2,
891                        visibility: wgpu::ShaderStages::FRAGMENT,
892                        ty: wgpu::BindingType::Texture {
893                            sample_type: wgpu::TextureSampleType::Float { filterable: true },
894                            view_dimension: wgpu::TextureViewDimension::D2,
895                            multisampled: false,
896                        },
897                        count: None,
898                    },
899                    wgpu::BindGroupLayoutEntry {
900                        binding: 3,
901                        visibility: wgpu::ShaderStages::FRAGMENT,
902                        ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
903                        count: None,
904                    },
905                ],
906            });
907        let layout = self
908            .device
909            .create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
910                label: Some("roxlap-gpu image.layout"),
911                bind_group_layouts: &[Some(&bgl)],
912                immediate_size: 0,
913            });
914        let pipeline = self
915            .device
916            .create_render_pipeline(&wgpu::RenderPipelineDescriptor {
917                label: Some("roxlap-gpu image.pipeline"),
918                layout: Some(&layout),
919                vertex: wgpu::VertexState {
920                    module: &shader,
921                    entry_point: Some("vs_main"),
922                    compilation_options: wgpu::PipelineCompilationOptions::default(),
923                    buffers: &[wgpu::VertexBufferLayout {
924                        array_stride: std::mem::size_of::<ImageVertex>() as u64,
925                        step_mode: wgpu::VertexStepMode::Vertex,
926                        attributes: &wgpu::vertex_attr_array![
927                            0 => Float32x2, // ndc
928                            1 => Float32,   // w
929                            2 => Float32,   // depth
930                            3 => Float32,   // depth_test
931                            4 => Float32,   // cutoff
932                            5 => Float32x2, // uv
933                            6 => Float32x4, // tint
934                        ],
935                    }],
936                },
937                fragment: Some(wgpu::FragmentState {
938                    module: &shader,
939                    entry_point: Some("fs_main"),
940                    compilation_options: wgpu::PipelineCompilationOptions::default(),
941                    targets: &[Some(wgpu::ColorTargetState {
942                        format: self.surface_config.format,
943                        blend: Some(wgpu::BlendState::ALPHA_BLENDING),
944                        write_mask: wgpu::ColorWrites::ALL,
945                    })],
946                }),
947                primitive: wgpu::PrimitiveState {
948                    cull_mode: None,
949                    ..Default::default()
950                },
951                depth_stencil: None,
952                multisample: wgpu::MultisampleState::default(),
953                multiview_mask: None,
954                cache: None,
955            });
956        let uniform_buf = self.device.create_buffer(&wgpu::BufferDescriptor {
957            label: Some("roxlap-gpu image.uniform"),
958            size: std::mem::size_of::<LineParams>() as u64,
959            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
960            mapped_at_creation: false,
961        });
962        let dummy_depth = self.device.create_buffer(&wgpu::BufferDescriptor {
963            label: Some("roxlap-gpu image.dummy_depth"),
964            size: 4,
965            usage: wgpu::BufferUsages::STORAGE,
966            mapped_at_creation: false,
967        });
968        let sampler = self.device.create_sampler(&wgpu::SamplerDescriptor {
969            label: Some("roxlap-gpu image.sampler"),
970            // Nearest + clamp: pixel-art references want crisp texels and
971            // no wrap bleed at the quad edges.
972            address_mode_u: wgpu::AddressMode::ClampToEdge,
973            address_mode_v: wgpu::AddressMode::ClampToEdge,
974            address_mode_w: wgpu::AddressMode::ClampToEdge,
975            mag_filter: wgpu::FilterMode::Nearest,
976            min_filter: wgpu::FilterMode::Nearest,
977            mipmap_filter: wgpu::MipmapFilterMode::Nearest,
978            ..Default::default()
979        });
980        self.image_resources = Some(ImageResources {
981            pipeline,
982            bgl,
983            uniform_buf,
984            dummy_depth,
985            sampler,
986        });
987    }
988
989    /// Overlay an `egui` UI on the pending frame, then present it
990    /// (`hud` feature). `jobs` are the host's tessellated primitives
991    /// (`egui::Context::tessellate`), `textures` the per-frame texture
992    /// delta from `egui::FullOutput`, `pixels_per_point` the UI scale.
993    ///
994    /// Draws with `LoadOp::Load` over the marcher's frame (a separate
995    /// encoder submitted after the scene's), so the UI composites on top
996    /// of the world. No-op if no frame is pending.
997    #[cfg(feature = "hud")]
998    pub fn paint_egui(
999        &mut self,
1000        jobs: &[egui::ClippedPrimitive],
1001        textures: &egui::TexturesDelta,
1002        pixels_per_point: f32,
1003    ) {
1004        let Some((surf_tex, surf_view)) = self.pending_frame.take() else {
1005            return;
1006        };
1007        let format = self.surface_config.format;
1008        let egui_rend = self.egui_renderer.get_or_insert_with(|| {
1009            egui_wgpu::Renderer::new(
1010                &self.device,
1011                format,
1012                egui_wgpu::RendererOptions {
1013                    msaa_samples: 1,
1014                    depth_stencil_format: None,
1015                    dithering: false,
1016                    ..Default::default()
1017                },
1018            )
1019        });
1020
1021        let screen = egui_wgpu::ScreenDescriptor {
1022            size_in_pixels: [self.surface_config.width, self.surface_config.height],
1023            pixels_per_point,
1024        };
1025        for (id, delta) in &textures.set {
1026            egui_rend.update_texture(&self.device, &self.queue, *id, delta);
1027        }
1028        let mut encoder = self
1029            .device
1030            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
1031                label: Some("roxlap-gpu egui"),
1032            });
1033        let user_bufs =
1034            egui_rend.update_buffers(&self.device, &self.queue, &mut encoder, jobs, &screen);
1035        {
1036            // `LoadOp::Load` keeps the marcher's frame; egui draws over it.
1037            let mut pass = encoder
1038                .begin_render_pass(&wgpu::RenderPassDescriptor {
1039                    label: Some("roxlap-gpu egui paint"),
1040                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1041                        view: &surf_view,
1042                        depth_slice: None,
1043                        resolve_target: None,
1044                        ops: wgpu::Operations {
1045                            load: wgpu::LoadOp::Load,
1046                            store: wgpu::StoreOp::Store,
1047                        },
1048                    })],
1049                    depth_stencil_attachment: None,
1050                    timestamp_writes: None,
1051                    occlusion_query_set: None,
1052                    multiview_mask: None,
1053                })
1054                // egui-wgpu 0.29 requires a `'static` pass (see its docs).
1055                .forget_lifetime();
1056            egui_rend.render(&mut pass, jobs, &screen);
1057        }
1058        for id in &textures.free {
1059            egui_rend.free_texture(id);
1060        }
1061        self.queue.submit(
1062            user_bufs
1063                .into_iter()
1064                .chain(std::iter::once(encoder.finish())),
1065        );
1066        surf_tex.present();
1067    }
1068}
1069
1070#[cfg(test)]
1071mod tests {
1072    /// A 2×2 world quad centred straight ahead projects to vertices whose
1073    /// homogeneous `w` equals the camera-forward distance (so the shader's
1074    /// `clip = ndc·w` recovers perspective-correct UVs) and whose `depth`
1075    /// is the euclidean range. Verifies geometry without a GPU device.
1076    #[test]
1077    fn image_vertices_carry_forward_w_and_euclidean_depth() {
1078        let cam = crate::GpuLineCamera {
1079            pos: [0.0, 0.0, 0.0],
1080            right: [1.0, 0.0, 0.0],
1081            down: [0.0, 1.0, 0.0],
1082            forward: [0.0, 0.0, 1.0],
1083        };
1084        // Quad 10 units ahead (forward = +Z), spanning x∈[-1,1], y∈[-1,1].
1085        let quad = crate::GpuImageQuad {
1086            corners: [
1087                [-1.0, -1.0, 10.0], // TL
1088                [1.0, -1.0, 10.0],  // TR
1089                [-1.0, 1.0, 10.0],  // BL
1090                [1.0, 1.0, 10.0],   // BR
1091            ],
1092            image: 0,
1093            tint: [1.0, 1.0, 1.0, 1.0],
1094            depth_test: true,
1095            alpha_cutoff: 0.0,
1096        };
1097        let verts = super::build_image_vertices(&cam, &quad, 800, 600, 60_f32.to_radians(), false);
1098        assert_eq!(verts.len(), 6, "two triangles, no near-clip");
1099        for v in &verts {
1100            assert!((v.w - 10.0).abs() < 1e-4, "w == forward distance");
1101            assert!(v.depth >= 10.0, "euclidean depth >= forward distance");
1102            assert_eq!(v.depth_test, 1.0);
1103        }
1104    }
1105}