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}