1use spatialrust_math::{Quat, Vec3};
4
5use crate::{SceneError, SceneResult};
6
7#[derive(Clone, Debug, PartialEq)]
9pub struct GaussianPrimitive {
10 pub mean: Vec3<f32>,
12 pub scale: Vec3<f32>,
14 pub rotation: Quat<f32>,
16 pub opacity: f32,
18 pub color: [f32; 3],
20}
21
22#[derive(Clone, Debug, Default, PartialEq)]
24pub struct GaussianScene {
25 primitives: Vec<GaussianPrimitive>,
26}
27
28impl GaussianScene {
29 #[must_use]
31 pub fn new() -> Self {
32 Self::default()
33 }
34
35 pub fn push(&mut self, primitive: GaussianPrimitive) -> SceneResult<()> {
37 validate_primitive(&primitive)?;
38 self.primitives.push(primitive);
39 Ok(())
40 }
41
42 #[must_use]
44 pub fn primitives(&self) -> &[GaussianPrimitive] {
45 &self.primitives
46 }
47
48 #[must_use]
50 pub fn len(&self) -> usize {
51 self.primitives.len()
52 }
53
54 #[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#[derive(Clone, Copy, Debug, PartialEq)]
84pub struct GaussianCamera {
85 pub width: u32,
87 pub height: u32,
89 pub fx: f32,
91 pub fy: f32,
93 pub cx: f32,
95 pub cy: f32,
97 pub eye: Vec3<f32>,
99 pub rotation_camera_from_world: Quat<f32>,
101}
102
103impl GaussianCamera {
104 #[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#[derive(Clone, Debug, PartialEq, Eq)]
122pub struct GaussianFramebuffer {
123 pub width: u32,
125 pub height: u32,
127 pub rgba: Vec<u8>,
129}
130
131impl GaussianFramebuffer {
132 #[must_use]
134 pub fn pixel_count(&self) -> usize {
135 (self.width as usize) * (self.height as usize)
136 }
137}
138
139pub 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}