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    /// The scene pass always writes depth (L3.1), so the last rendered
26    /// frame is pickable with or without sprites in it.
27    ///
28    /// Compiles on wasm, but the wasm facade never calls it: WebGPU's
29    /// `device.poll` doesn't block for the GPU, so the blocking
30    /// `recv()` here would hang the single browser thread. The wasm
31    /// facade calls [`Self::read_depth_pixel_async`] instead (PW.1 —
32    /// one-frame latency).
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    /// PW.1 — the async counterpart of [`Self::read_depth_pixel`] for
76    /// the wasm GPU path, where `map_async` only resolves on browser
77    /// event-loop turns and blocking would hang the single thread.
78    ///
79    /// Each call: (1) **harvests** the previous readback if its map
80    /// has resolved (the browser resolves it between RAF frames), (2)
81    /// **re-arms** — submits a fresh 4-byte copy + map for THIS call's
82    /// pixel if nothing is in flight (clicks arriving while one is
83    /// mapping are coalesced away; the next call re-arms with its own,
84    /// newest pixel), and (3) returns the **latest completed** depth —
85    /// usually `None` on the first call and the value on the next
86    /// (one-frame latency; the result may correspond to the previously
87    /// requested pixel). Same `T_INF`/non-finite sky filtering as the
88    /// sync path.
89    ///
90    /// The staging buffer is created per pick (4 bytes) and owned by
91    /// the pick state, NOT the shared `depth_readback`: the copy
92    /// executes against the depth buffer at submit time, so a resize /
93    /// scene swap between calls cannot invalidate an in-flight pick.
94    ///
95    /// Compiles and works on every target (the state machine
96    /// unit-tests natively), but native hosts should call the sync
97    /// [`Self::read_depth_pixel`]: without the browser event loop the
98    /// map only resolves if something polls the device between calls.
99    #[must_use]
100    pub fn read_depth_pixel_async(&self, x: u32, y: u32) -> Option<f32> {
101        let mut st = self.async_pick.lock().expect("async-pick lock");
102
103        // (1) Harvest a resolved map: read the 4 bytes, drop the
104        // staging buffer (mapped buffers unmap on drop).
105        if st.pending.is_in_flight() {
106            let resolved = st.map_result.lock().expect("map-result lock").take();
107            if let Some(res) = resolved {
108                let staging = st.staging.take();
109                let depth = res.ok().and(staging).and_then(|buf| {
110                    let data = buf.slice(..4).get_mapped_range();
111                    let bytes: [u8; 4] = data[0..4].try_into().ok()?;
112                    let t = f32::from_le_bytes(bytes);
113                    // Reject sky / no-hit (T_INF == 1e30) + non-finite.
114                    (t.is_finite() && t < 1.0e29).then_some(t)
115                });
116                st.pending.complete(depth);
117            }
118        }
119
120        // (2) Re-arm for THIS pixel (request() refuses while in flight).
121        if let Some(dda) = self.scene_dda.as_ref() {
122            let (w, h) = dda.storage_size;
123            if x < w && y < h && st.pending.request(x, y) {
124                let staging = self.device.create_buffer(&wgpu::BufferDescriptor {
125                    label: Some("roxlap-gpu async depth pick"),
126                    size: 4,
127                    usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
128                    mapped_at_creation: false,
129                });
130                let mut enc = self
131                    .device
132                    .create_command_encoder(&wgpu::CommandEncoderDescriptor {
133                        label: Some("roxlap-gpu async depth pick"),
134                    });
135                let offset = (u64::from(y) * u64::from(w) + u64::from(x)) * 4;
136                enc.copy_buffer_to_buffer(&dda.depth_buffer, offset, &staging, 0, 4);
137                self.queue.submit(std::iter::once(enc.finish()));
138
139                // A fresh result cell per submission: a late callback
140                // from an abandoned pick writes into an orphaned cell.
141                let cell = std::sync::Arc::new(std::sync::Mutex::new(None));
142                let cb = std::sync::Arc::clone(&cell);
143                staging.slice(..4).map_async(wgpu::MapMode::Read, move |r| {
144                    *cb.lock().expect("map-result lock (callback)") = Some(r);
145                });
146                st.map_result = cell;
147                st.staging = Some(staging);
148            }
149        }
150
151        // (3) The latest completed pick (sky/no-hit folds to None).
152        st.pending.latest().and_then(|(_pixel, depth)| depth)
153    }
154
155    /// QE.7a — read back the last rendered frame's colour at the
156    /// **logical** resolution (post-SSAA/posterize, pre-upscale) as
157    /// `0x00RRGGBB` pixels — the GPU side of frame capture, closing
158    /// the "screenshots impossible on the GPU backend" parity gap.
159    ///
160    /// Blocking (encode copy → submit → map, like
161    /// [`Self::read_depth_pixel`]): a screenshot hotkey, not a
162    /// per-frame path. `None` before the first scene render. Compiles
163    /// on wasm but must not be called there — WebGPU's `poll` can't
164    /// block, so the facade returns `None` on the wasm GPU path.
165    #[must_use]
166    pub fn read_frame_pixels(&self) -> Option<(Vec<u32>, u32, u32)> {
167        let dda = self.scene_dda.as_ref()?;
168        let (w, h) = dda.logical_size;
169        if w == 0 || h == 0 {
170            return None;
171        }
172        // Mirror `render_scene`'s identity-resolve choice: with ssaa 1,
173        // posterize off AND no tint last frame (WT.2), the resolve pass
174        // was skipped and the march framebuffer IS the logical image.
175        // Drift trap: these two conditions MUST stay in lockstep, or a
176        // capture returns the ungraded march buffer while the screen
177        // shows the graded resolve_buf.
178        let identity =
179            dda.storage_size == dda.logical_size && self.posterize.is_none() && self.tint.is_none();
180        let src = if identity {
181            &dda.framebuffer
182        } else {
183            &dda.resolve_buf
184        };
185        let size = u64::from(w) * u64::from(h) * 4;
186        let staging = self.device.create_buffer(&wgpu::BufferDescriptor {
187            label: Some("roxlap-gpu capture staging"),
188            size,
189            usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
190            mapped_at_creation: false,
191        });
192        let mut enc = self
193            .device
194            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
195                label: Some("roxlap-gpu capture readback"),
196            });
197        enc.copy_buffer_to_buffer(src, 0, &staging, 0, size);
198        self.queue.submit(std::iter::once(enc.finish()));
199
200        let slice = staging.slice(..);
201        let (tx, rx) = std::sync::mpsc::channel();
202        slice.map_async(wgpu::MapMode::Read, move |r| {
203            let _ = tx.send(r);
204        });
205        self.device.poll(wgpu::PollType::wait_indefinitely()).ok();
206        rx.recv().ok()?.ok()?;
207
208        let pixels = {
209            let data = slice.get_mapped_range();
210            // The shaders store `pack4x8unorm(r, g, b, a)` — r in the
211            // low byte. Repack to the facade's `0x00RRGGBB`.
212            data.chunks_exact(4)
213                .map(|px| {
214                    let v = u32::from_le_bytes([px[0], px[1], px[2], px[3]]);
215                    let (r, g, b) = (v & 0xff, (v >> 8) & 0xff, (v >> 16) & 0xff);
216                    (r << 16) | (g << 8) | b
217                })
218                .collect()
219        };
220        staging.unmap();
221        Some((pixels, w, h))
222    }
223
224    /// World-space view-ray direction (un-normalised) for window pixel
225    /// `(x, y)`, under the GPU marcher's projection — the canonical GPU
226    /// unproject, mirroring `scene_dda.wgsl`'s `render_scene`
227    /// (vertical-FOV pinhole). Uses the last-rendered frame's target
228    /// size + FOV; `None` before the first scene render. Pair with
229    /// [`Self::read_depth_pixel`] for screen→world picking.
230    #[must_use]
231    pub fn pixel_ray(
232        &self,
233        right: [f64; 3],
234        down: [f64; 3],
235        forward: [f64; 3],
236        x: f64,
237        y: f64,
238    ) -> Option<[f64; 3]> {
239        let dda = self.scene_dda.as_ref()?;
240        let (w, h) = dda.storage_size;
241        if w == 0 || h == 0 || self.last_fov_y_rad <= 0.0 {
242            return None;
243        }
244        Some(pinhole_pixel_ray(
245            right,
246            down,
247            forward,
248            x,
249            y,
250            f64::from(w),
251            f64::from(h),
252            f64::from(self.last_fov_y_rad),
253        ))
254    }
255}
256
257/// World-space view-ray direction (un-normalised) for window pixel
258/// `(x, y)` under a vertical-FOV pinhole — the projection
259/// `scene_dda.wgsl`'s `render_scene` uses. Shared by
260/// [`GpuRenderer::pixel_ray`]; standalone so it's unit-testable without
261/// a device. `right`/`down`/`forward` are the camera basis.
262#[must_use]
263#[allow(clippy::too_many_arguments)]
264pub fn pinhole_pixel_ray(
265    right: [f64; 3],
266    down: [f64; 3],
267    forward: [f64; 3],
268    x: f64,
269    y: f64,
270    w: f64,
271    h: f64,
272    fov_y_rad: f64,
273) -> [f64; 3] {
274    let half_h = (fov_y_rad * 0.5).tan();
275    let half_w = half_h * (w / h);
276    let ndc_x = (x + 0.5) / w * 2.0 - 1.0;
277    let ndc_y_top = 1.0 - (y + 0.5) / h * 2.0;
278    let (kx, ky) = (ndc_x * half_w, ndc_y_top * half_h);
279    [
280        forward[0] + kx * right[0] - ky * down[0],
281        forward[1] + kx * right[1] - ky * down[1],
282        forward[2] + kx * right[2] - ky * down[2],
283    ]
284}
285
286#[cfg(test)]
287mod pixel_ray_tests {
288    use super::pinhole_pixel_ray;
289
290    const RIGHT: [f64; 3] = [1.0, 0.0, 0.0];
291    const DOWN: [f64; 3] = [0.0, 1.0, 0.0];
292    const FWD: [f64; 3] = [0.0, 0.0, 1.0]; // voxlap z-down "look down"
293
294    // Frame centre (NDC 0,0) points straight along `forward`.
295    #[test]
296    fn centre_pixel_is_forward() {
297        let d = pinhole_pixel_ray(
298            RIGHT,
299            DOWN,
300            FWD,
301            639.5,
302            359.5,
303            1280.0,
304            720.0,
305            60_f64.to_radians(),
306        );
307        assert!(
308            d[0].abs() < 1e-9 && d[1].abs() < 1e-9,
309            "centre ≈ forward, got {d:?}"
310        );
311        assert!((d[2] - 1.0).abs() < 1e-9);
312    }
313
314    // Right edge pixel tilts +right by tan(hfov/2); the lateral
315    // component equals half_w = tan(fov_y/2)*aspect at the very edge.
316    #[test]
317    fn right_edge_tilts_by_half_w() {
318        let fov = 60_f64.to_radians();
319        let d = pinhole_pixel_ray(RIGHT, DOWN, FWD, 1279.5, 359.5, 1280.0, 720.0, fov);
320        let half_w = (fov * 0.5).tan() * (1280.0 / 720.0);
321        assert!((d[0] - half_w).abs() < 1e-6, "x={}, half_w={half_w}", d[0]);
322        assert!(d[0] > 0.0, "right edge tilts +right");
323    }
324}