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