Skip to main content

oxihuman_viewer/
particle_renderer.rs

1//! Particle effect rendering data (billboards, sprites).
2
3#[allow(dead_code)]
4#[derive(Clone, Copy, PartialEq, Debug)]
5pub enum ParticleBlend {
6    Additive,
7    AlphaBlend,
8    Multiply,
9}
10
11#[allow(dead_code)]
12#[derive(Clone)]
13pub struct RenderParticle {
14    pub position: [f32; 3],
15    pub size: f32,
16    pub color: [f32; 4],
17    pub rotation: f32,
18    pub tex_frame: u32,
19    pub age: f32,
20    pub lifetime: f32,
21}
22
23#[allow(dead_code)]
24pub struct ParticleSystem {
25    pub particles: Vec<RenderParticle>,
26    pub blend_mode: ParticleBlend,
27    pub texture_id: u32,
28    pub sort_by_depth: bool,
29    pub max_particles: usize,
30    pub camera_position: [f32; 3],
31}
32
33#[allow(dead_code)]
34pub fn new_particle_system(max: usize, blend: ParticleBlend) -> ParticleSystem {
35    ParticleSystem {
36        particles: Vec::with_capacity(max),
37        blend_mode: blend,
38        texture_id: 0,
39        sort_by_depth: false,
40        max_particles: max,
41        camera_position: [0.0, 0.0, 0.0],
42    }
43}
44
45/// Emit a particle. Returns false if the system is full.
46#[allow(dead_code)]
47pub fn emit_particle(sys: &mut ParticleSystem, p: RenderParticle) -> bool {
48    if sys.particles.len() >= sys.max_particles {
49        return false;
50    }
51    sys.particles.push(p);
52    true
53}
54
55/// Advance particle ages by `dt` and remove dead particles.
56#[allow(dead_code)]
57pub fn update_particles(sys: &mut ParticleSystem, dt: f32) {
58    for p in sys.particles.iter_mut() {
59        p.age += dt;
60    }
61    sys.particles.retain(|p| p.age < p.lifetime);
62}
63
64/// Sort particles back-to-front by distance from the camera.
65#[allow(dead_code)]
66pub fn sort_particles_by_depth(sys: &mut ParticleSystem) {
67    let cam = sys.camera_position;
68    sys.particles.sort_by(|a, b| {
69        let da = dist_sq(a.position, cam);
70        let db = dist_sq(b.position, cam);
71        // back-to-front: larger distance first
72        db.partial_cmp(&da).unwrap_or(std::cmp::Ordering::Equal)
73    });
74}
75
76fn dist_sq(a: [f32; 3], b: [f32; 3]) -> f32 {
77    let dx = a[0] - b[0];
78    let dy = a[1] - b[1];
79    let dz = a[2] - b[2];
80    dx * dx + dy * dy + dz * dz
81}
82
83#[allow(dead_code)]
84pub fn particle_count(sys: &ParticleSystem) -> usize {
85    sys.particles.len()
86}
87
88#[allow(dead_code)]
89pub fn alive_particle_count(sys: &ParticleSystem) -> usize {
90    sys.particles
91        .iter()
92        .filter(|p| is_particle_alive(p))
93        .count()
94}
95
96/// Returns billboard corner positions for each alive particle.
97#[allow(dead_code)]
98pub fn particles_as_quads(sys: &ParticleSystem) -> Vec<[[f32; 3]; 4]> {
99    // Use a default camera-facing right/up for simplicity
100    let right = [1.0_f32, 0.0, 0.0];
101    let up = [0.0_f32, 1.0, 0.0];
102    sys.particles
103        .iter()
104        .filter(|p| is_particle_alive(p))
105        .map(|p| billboard_corners(p.position, p.size, right, up))
106        .collect()
107}
108
109/// Compute 4 billboard corner positions given position, size, and camera right/up vectors.
110#[allow(dead_code)]
111pub fn billboard_corners(pos: [f32; 3], size: f32, right: [f32; 3], up: [f32; 3]) -> [[f32; 3]; 4] {
112    let half = size * 0.5;
113    let r = [right[0] * half, right[1] * half, right[2] * half];
114    let u = [up[0] * half, up[1] * half, up[2] * half];
115    [
116        [
117            pos[0] - r[0] - u[0],
118            pos[1] - r[1] - u[1],
119            pos[2] - r[2] - u[2],
120        ], // bottom-left
121        [
122            pos[0] + r[0] - u[0],
123            pos[1] + r[1] - u[1],
124            pos[2] + r[2] - u[2],
125        ], // bottom-right
126        [
127            pos[0] + r[0] + u[0],
128            pos[1] + r[1] + u[1],
129            pos[2] + r[2] + u[2],
130        ], // top-right
131        [
132            pos[0] - r[0] + u[0],
133            pos[1] - r[1] + u[1],
134            pos[2] - r[2] + u[2],
135        ], // top-left
136    ]
137}
138
139/// Returns (u0, v0, u1, v1) UV rect for a given frame in a sprite atlas.
140#[allow(dead_code)]
141pub fn particle_uv_frame(frame: u32, atlas_cols: u32, atlas_rows: u32) -> [f32; 4] {
142    let cols = atlas_cols.max(1);
143    let rows = atlas_rows.max(1);
144    let col = frame % cols;
145    let row = frame / cols % rows;
146    let inv_cols = 1.0 / cols as f32;
147    let inv_rows = 1.0 / rows as f32;
148    let u0 = col as f32 * inv_cols;
149    let v0 = row as f32 * inv_rows;
150    let u1 = u0 + inv_cols;
151    let v1 = v0 + inv_rows;
152    [u0, v0, u1, v1]
153}
154
155#[allow(dead_code)]
156pub fn clear_particles(sys: &mut ParticleSystem) {
157    sys.particles.clear();
158}
159
160#[allow(dead_code)]
161pub fn is_particle_alive(p: &RenderParticle) -> bool {
162    p.age < p.lifetime
163}
164
165/// Returns age / lifetime clamped to [0, 1].
166#[allow(dead_code)]
167pub fn particle_normalized_age(p: &RenderParticle) -> f32 {
168    if p.lifetime <= 0.0 {
169        return 1.0;
170    }
171    (p.age / p.lifetime).clamp(0.0, 1.0)
172}
173
174#[allow(dead_code)]
175pub fn average_particle_age(sys: &ParticleSystem) -> f32 {
176    if sys.particles.is_empty() {
177        return 0.0;
178    }
179    let total: f32 = sys.particles.iter().map(|p| p.age).sum();
180    total / sys.particles.len() as f32
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186
187    fn make_particle(age: f32, lifetime: f32) -> RenderParticle {
188        RenderParticle {
189            position: [0.0, 0.0, 0.0],
190            size: 1.0,
191            color: [1.0, 1.0, 1.0, 1.0],
192            rotation: 0.0,
193            tex_frame: 0,
194            age,
195            lifetime,
196        }
197    }
198
199    #[test]
200    fn test_new_particle_system() {
201        let sys = new_particle_system(100, ParticleBlend::Additive);
202        assert_eq!(sys.max_particles, 100);
203        assert_eq!(sys.blend_mode, ParticleBlend::Additive);
204        assert!(sys.particles.is_empty());
205    }
206
207    #[test]
208    fn test_emit_particle() {
209        let mut sys = new_particle_system(2, ParticleBlend::AlphaBlend);
210        let p = make_particle(0.0, 1.0);
211        assert!(emit_particle(&mut sys, p.clone()));
212        assert!(emit_particle(&mut sys, p.clone()));
213        // third should fail - system full
214        assert!(!emit_particle(&mut sys, p));
215    }
216
217    #[test]
218    fn test_update_particles_ages() {
219        let mut sys = new_particle_system(10, ParticleBlend::Additive);
220        emit_particle(&mut sys, make_particle(0.0, 2.0));
221        update_particles(&mut sys, 0.5);
222        assert!((sys.particles[0].age - 0.5).abs() < f32::EPSILON);
223    }
224
225    #[test]
226    fn test_update_particles_removes_dead() {
227        let mut sys = new_particle_system(10, ParticleBlend::Additive);
228        emit_particle(&mut sys, make_particle(0.0, 0.1));
229        emit_particle(&mut sys, make_particle(0.0, 5.0));
230        update_particles(&mut sys, 0.5);
231        assert_eq!(particle_count(&sys), 1);
232    }
233
234    #[test]
235    fn test_particle_count() {
236        let mut sys = new_particle_system(10, ParticleBlend::Additive);
237        assert_eq!(particle_count(&sys), 0);
238        emit_particle(&mut sys, make_particle(0.0, 1.0));
239        assert_eq!(particle_count(&sys), 1);
240    }
241
242    #[test]
243    fn test_alive_particle_count() {
244        let mut sys = new_particle_system(10, ParticleBlend::Additive);
245        emit_particle(&mut sys, make_particle(0.5, 1.0)); // alive
246        emit_particle(&mut sys, make_particle(2.0, 1.0)); // dead
247        assert_eq!(alive_particle_count(&sys), 1);
248    }
249
250    #[test]
251    fn test_billboard_corners_produces_4_points() {
252        let corners = billboard_corners([0.0, 0.0, 0.0], 1.0, [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]);
253        assert_eq!(corners.len(), 4);
254        // Verify corners are distinct
255        assert_ne!(corners[0], corners[1]);
256        assert_ne!(corners[0], corners[2]);
257    }
258
259    #[test]
260    fn test_particle_uv_frame() {
261        // 4x4 atlas, frame 0 -> (0, 0, 0.25, 0.25)
262        let uv = particle_uv_frame(0, 4, 4);
263        assert!((uv[0] - 0.0).abs() < f32::EPSILON);
264        assert!((uv[1] - 0.0).abs() < f32::EPSILON);
265        assert!((uv[2] - 0.25).abs() < f32::EPSILON);
266        assert!((uv[3] - 0.25).abs() < f32::EPSILON);
267    }
268
269    #[test]
270    fn test_particle_uv_frame_second() {
271        // 4x4 atlas, frame 1 -> (0.25, 0, 0.5, 0.25)
272        let uv = particle_uv_frame(1, 4, 4);
273        assert!((uv[0] - 0.25).abs() < f32::EPSILON);
274        assert!((uv[2] - 0.5).abs() < f32::EPSILON);
275    }
276
277    #[test]
278    fn test_clear_particles() {
279        let mut sys = new_particle_system(10, ParticleBlend::Additive);
280        emit_particle(&mut sys, make_particle(0.0, 1.0));
281        clear_particles(&mut sys);
282        assert_eq!(particle_count(&sys), 0);
283    }
284
285    #[test]
286    fn test_is_particle_alive() {
287        let alive = make_particle(0.5, 1.0);
288        let dead = make_particle(1.5, 1.0);
289        assert!(is_particle_alive(&alive));
290        assert!(!is_particle_alive(&dead));
291    }
292
293    #[test]
294    fn test_particle_normalized_age() {
295        let p = make_particle(0.5, 2.0);
296        let n = particle_normalized_age(&p);
297        assert!((n - 0.25).abs() < f32::EPSILON);
298    }
299
300    #[test]
301    fn test_particle_normalized_age_clamped() {
302        let p = make_particle(5.0, 1.0);
303        assert!((particle_normalized_age(&p) - 1.0).abs() < f32::EPSILON);
304    }
305
306    #[test]
307    fn test_average_particle_age() {
308        let mut sys = new_particle_system(10, ParticleBlend::Additive);
309        emit_particle(&mut sys, make_particle(1.0, 5.0));
310        emit_particle(&mut sys, make_particle(3.0, 5.0));
311        let avg = average_particle_age(&sys);
312        assert!((avg - 2.0).abs() < f32::EPSILON);
313    }
314
315    #[test]
316    fn test_average_particle_age_empty() {
317        let sys = new_particle_system(10, ParticleBlend::Additive);
318        assert!((average_particle_age(&sys)).abs() < f32::EPSILON);
319    }
320}