Skip to main content

spatialrust_web/
gpu.rs

1use std::sync::Arc;
2
3use spatialrust_gpu::{WgpuPowerPreference, WgpuRuntime};
4use spatialrust_render_wgpu::{GpuGeometry, HeadlessRender, RenderOptions, WgpuRenderer};
5use spatialrust_viz::{LinearRgba, VisualStyle};
6
7use crate::{WebError, WebResult, WebViewerState};
8
9/// One WebGPU/device-resident frame tied to a portable state revision.
10pub struct WebGpuFrame {
11    /// Viewer state revision rendered.
12    pub state_revision: u64,
13    /// Device-resident color/depth result and explicit transfer receipt.
14    pub render: HeadlessRender,
15}
16
17/// Web-compatible bridge from portable viewer state to the shared wgpu backend.
18///
19/// The bridge owns no scene geometry. Callers explicitly upload through
20/// [`Self::renderer`] and pass the resulting device-resident handle to
21/// [`Self::render`]. A frame remains device-resident unless the caller invokes
22/// the renderer's explicit readback API.
23pub struct WebGpuViewer {
24    state: WebViewerState,
25    renderer: WgpuRenderer,
26}
27
28impl WebGpuViewer {
29    /// Asynchronously creates a WebGPU runtime and bridge.
30    ///
31    /// This is the normal `wasm32` entry point; it never blocks the browser
32    /// main thread while requesting an adapter/device.
33    pub async fn new_async(
34        state: WebViewerState,
35        preference: WgpuPowerPreference,
36    ) -> WebResult<Self> {
37        let runtime = WgpuRuntime::new_headless_async(preference)
38            .await
39            .map_err(|error| WebError::WebGpu(error.to_string()))?;
40        Self::try_new(state, Arc::new(runtime))
41    }
42
43    /// Creates a bridge on a caller-selected wgpu runtime.
44    pub fn try_new(state: WebViewerState, runtime: Arc<WgpuRuntime>) -> WebResult<Self> {
45        state.validate()?;
46        Ok(Self { state, renderer: WgpuRenderer::new(runtime) })
47    }
48
49    /// Portable state.
50    #[must_use]
51    pub const fn state(&self) -> &WebViewerState {
52        &self.state
53    }
54
55    /// Replaces portable state after strict validation.
56    pub fn set_state(&mut self, state: WebViewerState) -> WebResult<()> {
57        state.validate()?;
58        self.state = state;
59        Ok(())
60    }
61
62    /// Renderer used for explicit geometry upload/recycle/readback.
63    #[must_use]
64    pub const fn renderer(&self) -> &WgpuRenderer {
65        &self.renderer
66    }
67
68    /// Renders one already-uploaded layer with the current camera/viewport.
69    pub fn render(
70        &self,
71        geometry: &GpuGeometry,
72        style: VisualStyle,
73        clear_color: LinearRgba,
74    ) -> WebResult<WebGpuFrame> {
75        let options = RenderOptions::try_new(
76            self.state.viewer.viewport.width,
77            self.state.viewer.viewport.height,
78            self.state.viewer.camera,
79            style,
80            clear_color,
81        )
82        .map_err(|error| WebError::WebGpu(error.to_string()))?;
83        let render = self
84            .renderer
85            .render_headless(geometry, &options)
86            .map_err(|error| WebError::WebGpu(error.to_string()))?;
87        Ok(WebGpuFrame { state_revision: self.state.revision, render })
88    }
89}
90
91#[cfg(test)]
92mod tests {
93    use std::sync::Arc;
94
95    use spatialrust_gpu::WgpuRuntime;
96    use spatialrust_math::Vec3;
97    use spatialrust_viewer::{ViewerState, ViewportSize};
98    use spatialrust_viz::{
99        Camera, LinearRgba, PointCloudView, PointColor, PointStyle, PositionColumns3, Projection,
100        VisualPrimitive, VisualStyle,
101    };
102
103    use crate::WebViewerState;
104
105    #[test]
106    fn web_bridge_matches_shared_headless_renderer_pixels_and_revision() {
107        let runtime = Arc::new(WgpuRuntime::new_headless().unwrap());
108        let camera = Camera::try_new(
109            Vec3::new(0.0, 0.0, 5.0),
110            Vec3::new(0.0, 0.0, 0.0),
111            Vec3::new(0.0, 1.0, 0.0),
112            Projection::Perspective { vertical_fov_radians: 1.0, near: 0.1, far: 100.0 },
113        )
114        .unwrap();
115        let mut state = WebViewerState::try_new(
116            ViewerState::try_new(camera, ViewportSize::try_new(64, 64).unwrap()).unwrap(),
117        )
118        .unwrap();
119        state.revision = 7;
120        let viewer = super::WebGpuViewer::try_new(state, runtime).unwrap();
121        let x = [0.0];
122        let y = [0.0];
123        let z = [0.0];
124        let points = PointCloudView::positions_only(PositionColumns3::try_new(&x, &y, &z).unwrap());
125        let (geometry, upload) = viewer.renderer().upload(VisualPrimitive::Points(points)).unwrap();
126        assert_eq!(upload.total_bytes().unwrap(), 12);
127        let style = VisualStyle::Points(
128            PointStyle::try_new(
129                8.0,
130                PointColor::Uniform(LinearRgba::try_new(1.0, 0.0, 0.0, 1.0).unwrap()),
131            )
132            .unwrap(),
133        );
134        let frame = viewer.render(&geometry, style, LinearRgba::BLACK).unwrap();
135        assert_eq!(frame.state_revision, 7);
136        let image = viewer.renderer().readback_rgba(&frame.render.target).unwrap();
137        let center = ((32 * 64 + 32) * 4) as usize;
138        assert!(image.rgba[center] > 200);
139        assert!(image.rgba[center + 1] < 20);
140        assert_eq!(image.rgba.len(), 64 * 64 * 4);
141    }
142}