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}