Skip to main content

roxlap_gpu/
readback.rs

1//! QE.8b — blocking GPU readbacks + unproject, split verbatim out
2//! of `lib.rs`: click-time depth picking, whole-frame colour capture
3//! (QE.7a), and the vertical-FOV pinhole pixel→ray helper the
4//! picking path shares with the facade.
5
6use crate::GpuRenderer;
7
8impl GpuRenderer {
9    /// Read back the per-pixel world-t depth at window pixel `(x, y)`
10    /// from the last rendered frame, for screen→world picking. Returns
11    /// the distance `t` along the (normalised) view ray to the nearest
12    /// scene-grid surface, so the host reconstructs the world hit as
13    /// `cam.pos + t * normalize(ray_dir)`. `None` for out-of-bounds
14    /// pixels, sky / no-hit (the `T_INF` sentinel), or when no scene
15    /// frame has been rendered.
16    ///
17    /// The depth buffer is the SCENE pass's output (terrain + grids),
18    /// untouched by the sprite pass (which reads it read-only), so a
19    /// cursor sprite under the pointer does not occlude the pick.
20    ///
21    /// Synchronous: copies the depth buffer to a mapped staging buffer
22    /// and blocks on `device.poll(Wait)`. Cheap enough for click-time
23    /// picks; do not call it every frame.
24    ///
25    /// Requires the last frame to have written depth, which happens
26    /// when sprites are present (`write_depth`). The pick demo always
27    /// has a cursor sprite, so this holds.
28    ///
29    /// Compiles on wasm, but the wasm facade never calls it: WebGPU's
30    /// `device.poll` doesn't block for the GPU, so the blocking
31    /// `recv()` here would hang the single browser thread. Picking is
32    /// deferred on the wasm GPU path (the facade returns `None`).
33    #[must_use]
34    pub fn read_depth_pixel(&self, x: u32, y: u32) -> Option<f32> {
35        let dda = self.scene_dda.as_ref()?;
36        let (w, h) = dda.storage_size;
37        if x >= w || y >= h {
38            return None;
39        }
40        let mut enc = self
41            .device
42            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
43                label: Some("roxlap-gpu depth readback"),
44            });
45        // PF.5 (H4) — copy ONLY the picked pixel's 4 bytes, not the whole
46        // depth buffer (8+ MB at high res): the pick still blocks on the
47        // poll below, but the copy + map are now O(1). The 4-byte offset
48        // meets wgpu's copy alignment.
49        let offset = (u64::from(y) * u64::from(w) + u64::from(x)) * 4;
50        enc.copy_buffer_to_buffer(&dda.depth_buffer, offset, &dda.depth_readback, 0, 4);
51        self.queue.submit(std::iter::once(enc.finish()));
52
53        let slice = dda.depth_readback.slice(..4);
54        let (tx, rx) = std::sync::mpsc::channel();
55        slice.map_async(wgpu::MapMode::Read, move |r| {
56            let _ = tx.send(r);
57        });
58        self.device.poll(wgpu::PollType::wait_indefinitely()).ok();
59        rx.recv().ok()?.ok()?;
60
61        let t = {
62            let data = slice.get_mapped_range();
63            let bytes: [u8; 4] = data[0..4].try_into().ok()?;
64            f32::from_le_bytes(bytes)
65        };
66        dda.depth_readback.unmap();
67
68        // Reject sky / no-hit (T_INF == 1e30 in the shader) + non-finite.
69        if !t.is_finite() || t >= 1.0e29 {
70            return None;
71        }
72        Some(t)
73    }
74
75    /// QE.7a — read back the last rendered frame's colour at the
76    /// **logical** resolution (post-SSAA/posterize, pre-upscale) as
77    /// `0x00RRGGBB` pixels — the GPU side of frame capture, closing
78    /// the "screenshots impossible on the GPU backend" parity gap.
79    ///
80    /// Blocking (encode copy → submit → map, like
81    /// [`Self::read_depth_pixel`]): a screenshot hotkey, not a
82    /// per-frame path. `None` before the first scene render. Compiles
83    /// on wasm but must not be called there — WebGPU's `poll` can't
84    /// block, so the facade returns `None` on the wasm GPU path.
85    #[must_use]
86    pub fn read_frame_pixels(&self) -> Option<(Vec<u32>, u32, u32)> {
87        let dda = self.scene_dda.as_ref()?;
88        let (w, h) = dda.logical_size;
89        if w == 0 || h == 0 {
90            return None;
91        }
92        // Mirror `render_scene`'s identity-resolve choice: with ssaa 1
93        // + posterize off the resolve pass was skipped and the march
94        // framebuffer IS the logical image.
95        let identity = dda.storage_size == dda.logical_size && self.posterize.is_none();
96        let src = if identity {
97            &dda.framebuffer
98        } else {
99            &dda.resolve_buf
100        };
101        let size = u64::from(w) * u64::from(h) * 4;
102        let staging = self.device.create_buffer(&wgpu::BufferDescriptor {
103            label: Some("roxlap-gpu capture staging"),
104            size,
105            usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
106            mapped_at_creation: false,
107        });
108        let mut enc = self
109            .device
110            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
111                label: Some("roxlap-gpu capture readback"),
112            });
113        enc.copy_buffer_to_buffer(src, 0, &staging, 0, size);
114        self.queue.submit(std::iter::once(enc.finish()));
115
116        let slice = staging.slice(..);
117        let (tx, rx) = std::sync::mpsc::channel();
118        slice.map_async(wgpu::MapMode::Read, move |r| {
119            let _ = tx.send(r);
120        });
121        self.device.poll(wgpu::PollType::wait_indefinitely()).ok();
122        rx.recv().ok()?.ok()?;
123
124        let pixels = {
125            let data = slice.get_mapped_range();
126            // The shaders store `pack4x8unorm(r, g, b, a)` — r in the
127            // low byte. Repack to the facade's `0x00RRGGBB`.
128            data.chunks_exact(4)
129                .map(|px| {
130                    let v = u32::from_le_bytes([px[0], px[1], px[2], px[3]]);
131                    let (r, g, b) = (v & 0xff, (v >> 8) & 0xff, (v >> 16) & 0xff);
132                    (r << 16) | (g << 8) | b
133                })
134                .collect()
135        };
136        staging.unmap();
137        Some((pixels, w, h))
138    }
139
140    /// World-space view-ray direction (un-normalised) for window pixel
141    /// `(x, y)`, under the GPU marcher's projection — the canonical GPU
142    /// unproject, mirroring `scene_dda.wgsl`'s `render_scene`
143    /// (vertical-FOV pinhole). Uses the last-rendered frame's target
144    /// size + FOV; `None` before the first scene render. Pair with
145    /// [`Self::read_depth_pixel`] for screen→world picking.
146    #[must_use]
147    pub fn pixel_ray(
148        &self,
149        right: [f64; 3],
150        down: [f64; 3],
151        forward: [f64; 3],
152        x: f64,
153        y: f64,
154    ) -> Option<[f64; 3]> {
155        let dda = self.scene_dda.as_ref()?;
156        let (w, h) = dda.storage_size;
157        if w == 0 || h == 0 || self.last_fov_y_rad <= 0.0 {
158            return None;
159        }
160        Some(pinhole_pixel_ray(
161            right,
162            down,
163            forward,
164            x,
165            y,
166            f64::from(w),
167            f64::from(h),
168            f64::from(self.last_fov_y_rad),
169        ))
170    }
171}
172
173/// World-space view-ray direction (un-normalised) for window pixel
174/// `(x, y)` under a vertical-FOV pinhole — the projection
175/// `scene_dda.wgsl`'s `render_scene` uses. Shared by
176/// [`GpuRenderer::pixel_ray`]; standalone so it's unit-testable without
177/// a device. `right`/`down`/`forward` are the camera basis.
178#[must_use]
179#[allow(clippy::too_many_arguments)]
180pub fn pinhole_pixel_ray(
181    right: [f64; 3],
182    down: [f64; 3],
183    forward: [f64; 3],
184    x: f64,
185    y: f64,
186    w: f64,
187    h: f64,
188    fov_y_rad: f64,
189) -> [f64; 3] {
190    let half_h = (fov_y_rad * 0.5).tan();
191    let half_w = half_h * (w / h);
192    let ndc_x = (x + 0.5) / w * 2.0 - 1.0;
193    let ndc_y_top = 1.0 - (y + 0.5) / h * 2.0;
194    let (kx, ky) = (ndc_x * half_w, ndc_y_top * half_h);
195    [
196        forward[0] + kx * right[0] - ky * down[0],
197        forward[1] + kx * right[1] - ky * down[1],
198        forward[2] + kx * right[2] - ky * down[2],
199    ]
200}
201
202#[cfg(test)]
203mod pixel_ray_tests {
204    use super::pinhole_pixel_ray;
205
206    const RIGHT: [f64; 3] = [1.0, 0.0, 0.0];
207    const DOWN: [f64; 3] = [0.0, 1.0, 0.0];
208    const FWD: [f64; 3] = [0.0, 0.0, 1.0]; // voxlap z-down "look down"
209
210    // Frame centre (NDC 0,0) points straight along `forward`.
211    #[test]
212    fn centre_pixel_is_forward() {
213        let d = pinhole_pixel_ray(
214            RIGHT,
215            DOWN,
216            FWD,
217            639.5,
218            359.5,
219            1280.0,
220            720.0,
221            60_f64.to_radians(),
222        );
223        assert!(
224            d[0].abs() < 1e-9 && d[1].abs() < 1e-9,
225            "centre ≈ forward, got {d:?}"
226        );
227        assert!((d[2] - 1.0).abs() < 1e-9);
228    }
229
230    // Right edge pixel tilts +right by tan(hfov/2); the lateral
231    // component equals half_w = tan(fov_y/2)*aspect at the very edge.
232    #[test]
233    fn right_edge_tilts_by_half_w() {
234        let fov = 60_f64.to_radians();
235        let d = pinhole_pixel_ray(RIGHT, DOWN, FWD, 1279.5, 359.5, 1280.0, 720.0, fov);
236        let half_w = (fov * 0.5).tan() * (1280.0 / 720.0);
237        assert!((d[0] - half_w).abs() < 1e-6, "x={}, half_w={half_w}", d[0]);
238        assert!(d[0] > 0.0, "right edge tilts +right");
239    }
240}