Skip to main content

spatialrust_scene/
gaussian.rs

1//! Feature-gated Gaussian scene primitives and CPU soft-splat renderer.
2
3use spatialrust_math::{Quat, Vec3};
4
5use crate::{SceneError, SceneResult};
6
7/// One anisotropic Gaussian primitive.
8#[derive(Clone, Debug, PartialEq)]
9pub struct GaussianPrimitive {
10    /// Mean position.
11    pub mean: Vec3<f32>,
12    /// Per-axis scale.
13    pub scale: Vec3<f32>,
14    /// Orientation quaternion.
15    pub rotation: Quat<f32>,
16    /// Opacity in `[0, 1]`.
17    pub opacity: f32,
18    /// RGB color in `[0, 1]`.
19    pub color: [f32; 3],
20}
21
22/// Host-side Gaussian scene container.
23#[derive(Clone, Debug, Default, PartialEq)]
24pub struct GaussianScene {
25    primitives: Vec<GaussianPrimitive>,
26}
27
28impl GaussianScene {
29    /// Creates an empty scene.
30    #[must_use]
31    pub fn new() -> Self {
32        Self::default()
33    }
34
35    /// Appends a validated Gaussian.
36    pub fn push(&mut self, primitive: GaussianPrimitive) -> SceneResult<()> {
37        validate_primitive(&primitive)?;
38        self.primitives.push(primitive);
39        Ok(())
40    }
41
42    /// Returns primitives.
43    #[must_use]
44    pub fn primitives(&self) -> &[GaussianPrimitive] {
45        &self.primitives
46    }
47
48    /// Returns primitive count.
49    #[must_use]
50    pub fn len(&self) -> usize {
51        self.primitives.len()
52    }
53
54    /// Returns true when empty.
55    #[must_use]
56    pub fn is_empty(&self) -> bool {
57        self.primitives.is_empty()
58    }
59}
60
61fn validate_primitive(primitive: &GaussianPrimitive) -> SceneResult<()> {
62    if !(0.0..=1.0).contains(&primitive.opacity) {
63        return Err(SceneError::InvalidConfiguration("opacity must be in [0, 1]".into()));
64    }
65    if primitive.color.iter().any(|c| !(0.0..=1.0).contains(c)) {
66        return Err(SceneError::InvalidConfiguration("color channels must be in [0, 1]".into()));
67    }
68    if !(primitive.scale.x.is_finite()
69        && primitive.scale.y.is_finite()
70        && primitive.scale.z.is_finite())
71        || primitive.scale.x <= 0.0
72        || primitive.scale.y <= 0.0
73        || primitive.scale.z <= 0.0
74    {
75        return Err(SceneError::InvalidConfiguration(
76            "scale components must be finite and > 0".into(),
77        ));
78    }
79    Ok(())
80}
81
82/// Pinhole camera used by the CPU Gaussian soft-splat renderer.
83#[derive(Clone, Copy, Debug, PartialEq)]
84pub struct GaussianCamera {
85    /// Image width in pixels.
86    pub width: u32,
87    /// Image height in pixels.
88    pub height: u32,
89    /// Focal length x (pixels).
90    pub fx: f32,
91    /// Focal length y (pixels).
92    pub fy: f32,
93    /// Principal point x.
94    pub cx: f32,
95    /// Principal point y.
96    pub cy: f32,
97    /// Camera translation in world (world-from-camera origin).
98    pub eye: Vec3<f32>,
99    /// Camera orientation (camera-from-world rotation as quaternion).
100    pub rotation_camera_from_world: Quat<f32>,
101}
102
103impl GaussianCamera {
104    /// Creates a camera looking along +Z from the origin with identity rotation.
105    #[must_use]
106    pub fn look_along_z(width: u32, height: u32, fx: f32, fy: f32) -> Self {
107        Self {
108            width,
109            height,
110            fx,
111            fy,
112            cx: width as f32 * 0.5,
113            cy: height as f32 * 0.5,
114            eye: Vec3::new(0.0, 0.0, 0.0),
115            rotation_camera_from_world: Quat::<f32>::identity(),
116        }
117    }
118}
119
120/// Packed RGBA8 framebuffer from [`render_gaussians_cpu`].
121#[derive(Clone, Debug, PartialEq, Eq)]
122pub struct GaussianFramebuffer {
123    /// Width in pixels.
124    pub width: u32,
125    /// Height in pixels.
126    pub height: u32,
127    /// Interleaved RGBA bytes (`width * height * 4`).
128    pub rgba: Vec<u8>,
129}
130
131impl GaussianFramebuffer {
132    /// Returns pixel count.
133    #[must_use]
134    pub fn pixel_count(&self) -> usize {
135        (self.width as usize) * (self.height as usize)
136    }
137}
138
139/// Renders a Gaussian scene with a CPU alpha soft-splat (front-to-back).
140///
141/// Each primitive is projected with the pinhole camera, sorted by depth, and
142/// composited with a screen-space isotropic Gaussian whose radius tracks the
143/// projected max scale. This is a portable reference path; a GPU rasterizer can
144/// later share the same [`GaussianScene`] container.
145pub fn render_gaussians_cpu(
146    scene: &GaussianScene,
147    camera: &GaussianCamera,
148) -> SceneResult<GaussianFramebuffer> {
149    if camera.width == 0 || camera.height == 0 {
150        return Err(SceneError::InvalidConfiguration("camera dimensions must be non-zero".into()));
151    }
152    if !(camera.fx.is_finite() && camera.fy.is_finite() && camera.fx > 0.0 && camera.fy > 0.0) {
153        return Err(SceneError::InvalidConfiguration("fx/fy must be finite and > 0".into()));
154    }
155
156    let rot = camera.rotation_camera_from_world.normalize().to_mat3();
157    let mut projected = Vec::with_capacity(scene.primitives.len());
158    for prim in &scene.primitives {
159        let world = prim.mean - camera.eye;
160        let cam = rot.mul_vec3(world);
161        if !(cam.z.is_finite() && cam.z > 1e-4) {
162            continue;
163        }
164        let u = camera.fx * (cam.x / cam.z) + camera.cx;
165        let v = camera.fy * (cam.y / cam.z) + camera.cy;
166        let radius_world = prim.scale.x.max(prim.scale.y).max(prim.scale.z);
167        let radius_px = (camera.fx * radius_world / cam.z).max(0.5);
168        projected.push(ProjectedSplat {
169            u,
170            v,
171            depth: cam.z,
172            radius_px,
173            opacity: prim.opacity,
174            color: prim.color,
175        });
176    }
177    projected.sort_by(|a, b| a.depth.partial_cmp(&b.depth).unwrap_or(std::cmp::Ordering::Equal));
178
179    let pixels = (camera.width as usize) * (camera.height as usize);
180    let mut color = vec![0.0f32; pixels * 3];
181    let mut alpha = vec![0.0f32; pixels];
182
183    for splat in &projected {
184        let r = splat.radius_px * 3.0;
185        let min_x = ((splat.u - r).floor() as i32).max(0) as u32;
186        let max_x = ((splat.u + r).ceil() as i32).min(camera.width as i32 - 1) as u32;
187        let min_y = ((splat.v - r).floor() as i32).max(0) as u32;
188        let max_y = ((splat.v + r).ceil() as i32).min(camera.height as i32 - 1) as u32;
189        let sigma2 = (splat.radius_px * splat.radius_px).max(1e-4);
190        for y in min_y..=max_y {
191            for x in min_x..=max_x {
192                let dx = x as f32 + 0.5 - splat.u;
193                let dy = y as f32 + 0.5 - splat.v;
194                let w = (-0.5 * (dx * dx + dy * dy) / sigma2).exp() * splat.opacity;
195                if w <= 1e-5 {
196                    continue;
197                }
198                let idx = (y as usize) * (camera.width as usize) + (x as usize);
199                let one_m_a = 1.0 - alpha[idx];
200                let contrib = w * one_m_a;
201                color[idx * 3] += splat.color[0] * contrib;
202                color[idx * 3 + 1] += splat.color[1] * contrib;
203                color[idx * 3 + 2] += splat.color[2] * contrib;
204                alpha[idx] += contrib;
205            }
206        }
207    }
208
209    let mut rgba = Vec::with_capacity(pixels * 4);
210    for i in 0..pixels {
211        rgba.push(to_u8(color[i * 3]));
212        rgba.push(to_u8(color[i * 3 + 1]));
213        rgba.push(to_u8(color[i * 3 + 2]));
214        rgba.push(to_u8(alpha[i]));
215    }
216    Ok(GaussianFramebuffer { width: camera.width, height: camera.height, rgba })
217}
218
219struct ProjectedSplat {
220    u: f32,
221    v: f32,
222    depth: f32,
223    radius_px: f32,
224    opacity: f32,
225    color: [f32; 3],
226}
227
228fn to_u8(v: f32) -> u8 {
229    (v.clamp(0.0, 1.0) * 255.0).round() as u8
230}
231
232#[cfg(test)]
233mod tests {
234    use super::{render_gaussians_cpu, GaussianCamera, GaussianPrimitive, GaussianScene};
235    use spatialrust_math::{Quat, Vec3};
236
237    #[test]
238    fn renders_opaque_splat_near_center() {
239        let mut scene = GaussianScene::new();
240        scene
241            .push(GaussianPrimitive {
242                mean: Vec3::new(0.0, 0.0, 2.0),
243                scale: Vec3::new(0.15, 0.15, 0.15),
244                rotation: Quat::<f32>::identity(),
245                opacity: 1.0,
246                color: [1.0, 0.0, 0.0],
247            })
248            .unwrap();
249        let camera = GaussianCamera::look_along_z(32, 32, 40.0, 40.0);
250        let fb = render_gaussians_cpu(&scene, &camera).unwrap();
251        assert_eq!(fb.rgba.len(), 32 * 32 * 4);
252        let center = ((16 * 32 + 16) * 4) as usize;
253        assert!(fb.rgba[center] > 20, "expected red contribution at center");
254        assert!(fb.rgba[center + 3] > 20, "expected non-zero alpha");
255    }
256
257    #[test]
258    fn rejects_non_positive_scale() {
259        let mut scene = GaussianScene::new();
260        let err = scene
261            .push(GaussianPrimitive {
262                mean: Vec3::new(0.0, 0.0, 1.0),
263                scale: Vec3::new(0.0, 1.0, 1.0),
264                rotation: Quat::<f32>::identity(),
265                opacity: 1.0,
266                color: [1.0, 1.0, 1.0],
267            })
268            .unwrap_err();
269        assert!(err.to_string().contains("scale"));
270    }
271}