Skip to main content

gizmo_physics_rigid/
fracture.rs

1use gizmo_math::Vec3;
2use gizmo_physics_core::BodyHandle;
3use rand::{rngs::StdRng, RngExt, SeedableRng};
4
5/// Patlama hız değişimi (force/mass) üst sınırı. İnce kıymık parçalar çok küçük kütleye
6/// sahip olduğundan `force/mass` patlayıp parçaları absürt hızlarda fırlatıyordu (tünelleme
7/// / kararsızlık). Hız değişimini bu makul sınıra kırp.
8const MAX_EXPLOSION_DV: f32 = 50.0;
9
10#[derive(Clone, Debug)]
11pub struct ProceduralChunk {
12    pub vertices: Vec<Vec3>,
13    pub normals: Vec<Vec3>,
14    pub indices: Vec<u32>,
15    pub center_of_mass: Vec3,
16    pub volume: f32, // approximated
17}
18
19#[derive(Clone, Copy)]
20struct MathPlane {
21    normal: Vec3,
22    d: f32, // dot(N, P) - d = 0 => dot(N, P) = d
23}
24
25impl MathPlane {
26    // Normal points OUTSIDE
27    fn distance(&self, pt: Vec3) -> f32 {
28        self.normal.dot(pt) - self.d
29    }
30
31    fn from_point_normal(pt: Vec3, normal: Vec3) -> Self {
32        Self {
33            normal: normal.normalize(),
34            d: normal.normalize().dot(pt),
35        }
36    }
37}
38
39/// Volume **and** center-of-mass of a convex polyhedron (closed triangle mesh),
40/// via signed-tetrahedron decomposition relative to the vertex centroid.
41///
42/// The COM returned is the true **volume centroid** (center of mass of a uniform-
43/// density solid), NOT the vertex average. For asymmetric voronoi chunks the two
44/// differ (e.g. a pyramid's mass sits at h/4, not the h/5 vertex mean), and the
45/// vertex average biased the chunk's rotational dynamics. Each triangle `(a,b,c)`
46/// plus the reference point forms a tetrahedron with signed volume `v = a·(b×c)/6`
47/// (relative coords) and centroid `(a+b+c)/4`; the mesh COM is the volume-weighted
48/// mean of the tetra centroids, `Σ v·g / Σ v`. Sign cancels between numerator and
49/// denominator, so winding handedness (as long as it is *consistent*) does not
50/// matter. Volume is `|Σ v|` — unchanged from the previous volume-only routine, so
51/// per-chunk mass distribution is preserved.
52fn compute_convex_mass_props(vertices: &[Vec3], indices: &[u32]) -> (Vec3, f32) {
53    let vertex_centroid =
54        vertices.iter().copied().fold(Vec3::ZERO, |a, b| a + b) / vertices.len().max(1) as f32;
55    if indices.len() < 3 {
56        return (vertex_centroid, 0.001);
57    }
58    let mut vol6 = 0.0f32; // 6 × signed volume (Σ of triangle a·(b×c))
59    let mut com_acc = Vec3::ZERO; // Σ (a+b+c)·v6_i, relative to vertex_centroid
60    for tri in indices.chunks_exact(3) {
61        let a = vertices[tri[0] as usize] - vertex_centroid;
62        let b = vertices[tri[1] as usize] - vertex_centroid;
63        let c = vertices[tri[2] as usize] - vertex_centroid;
64        let v6 = a.dot(b.cross(c));
65        vol6 += v6;
66        com_acc += (a + b + c) * v6;
67    }
68    let volume = (vol6 / 6.0).abs().max(0.001);
69    // C_rel = Σ(v_i·g_i)/Σ(v_i) = Σ(v6·(a+b+c))/(4·Σv6). Degenerate (near-zero
70    // signed volume, e.g. sliver/coplanar) → fall back to the vertex centroid.
71    let com = if vol6.abs() > 1e-8 {
72        vertex_centroid + com_acc / (4.0 * vol6)
73    } else {
74        vertex_centroid
75    };
76    (com, volume)
77}
78
79pub fn voronoi_shatter(extents: Vec3, num_pieces: u32, seed: u64) -> Vec<ProceduralChunk> {
80    // A degenerate half-extent (0 or negative — e.g. a thin panel/floor tile authored as a
81    // zero-thickness box) would make `rng.random_range(-e..e)` sample an empty range, which
82    // is an unconditional panic in rand 0.10 (fires in release too), crashing the physics
83    // step mid-shatter. Floor each axis to a small positive extent — same `.max(1e-3)` idiom
84    // used for volume/radius elsewhere in this module.
85    let extents = extents.abs().max(Vec3::splat(1e-3));
86    let mut rng = StdRng::seed_from_u64(seed);
87
88    // 1. Generate seeds
89    let mut seeds = Vec::with_capacity(num_pieces as usize);
90    for _ in 0..num_pieces {
91        seeds.push(Vec3::new(
92            rng.random_range(-extents.x..extents.x),
93            rng.random_range(-extents.y..extents.y),
94            rng.random_range(-extents.z..extents.z),
95        ));
96    }
97
98    let mut chunks = Vec::with_capacity(num_pieces as usize);
99
100    let box_planes = vec![
101        MathPlane::from_point_normal(Vec3::new(extents.x, 0.0, 0.0), Vec3::new(1.0, 0.0, 0.0)),
102        MathPlane::from_point_normal(Vec3::new(-extents.x, 0.0, 0.0), Vec3::new(-1.0, 0.0, 0.0)),
103        MathPlane::from_point_normal(Vec3::new(0.0, extents.y, 0.0), Vec3::new(0.0, 1.0, 0.0)),
104        MathPlane::from_point_normal(Vec3::new(0.0, -extents.y, 0.0), Vec3::new(0.0, -1.0, 0.0)),
105        MathPlane::from_point_normal(Vec3::new(0.0, 0.0, extents.z), Vec3::new(0.0, 0.0, 1.0)),
106        MathPlane::from_point_normal(Vec3::new(0.0, 0.0, -extents.z), Vec3::new(0.0, 0.0, -1.0)),
107    ];
108
109    // Reusable buffers to avoid memory allocation jitter
110    let mut planes = Vec::with_capacity(box_planes.len() + num_pieces as usize);
111    let mut raw_vertices = Vec::with_capacity(256);
112    let mut out_vertices = Vec::with_capacity(256);
113    let mut out_normals = Vec::with_capacity(256);
114    let mut out_indices = Vec::with_capacity(512);
115    let mut face_verts = Vec::with_capacity(64);
116
117    for i in 0..num_pieces as usize {
118        let p_i = seeds[i];
119
120        planes.clear();
121        planes.extend_from_slice(&box_planes);
122
123        for (j, &p_j) in seeds[..num_pieces as usize].iter().enumerate() {
124            if i == j {
125                continue;
126            }
127            let dir = p_j - p_i;
128            let length = dir.length();
129            if length < 0.001 {
130                continue;
131            }
132            let normal = dir / length;
133            let mid = (p_i + p_j) * 0.5;
134            planes.push(MathPlane::from_point_normal(mid, normal));
135        }
136
137        // Find vertices via plane intersections
138        raw_vertices.clear();
139        let num_planes = planes.len();
140
141        for p1 in 0..num_planes {
142            for p2 in (p1 + 1)..num_planes {
143                for p3 in (p2 + 1)..num_planes {
144                    if let Some(intersection) =
145                        intersect_planes(&planes[p1], &planes[p2], &planes[p3])
146                    {
147                        // Check if it's inside all other planes
148                        let mut is_inside = true;
149                        for (k, plane) in planes.iter().enumerate() {
150                            if k == p1 || k == p2 || k == p3 {
151                                continue;
152                            }
153                            if plane.distance(intersection) > 0.001 {
154                                // Slight epsilon
155                                is_inside = false;
156                                break;
157                            }
158                        }
159                        if is_inside {
160                            // Don't add duplicates
161                            let mut dup = false;
162                            for &v in &raw_vertices {
163                                let diff: Vec3 = v - intersection;
164                                if diff.length_squared() < 0.0001 {
165                                    dup = true;
166                                    break;
167                                }
168                            }
169                            if !dup {
170                                raw_vertices.push(intersection);
171                            }
172                        }
173                    }
174                }
175            }
176        }
177
178        // If something went wrong and we couldn't form a 3D boundary, skip
179        if raw_vertices.len() < 4 {
180            continue;
181        }
182
183        out_vertices.clear();
184        out_normals.clear();
185        out_indices.clear();
186
187        // Accumulate face triangles
188        // A face is formed by a subset of raw_vertices that lie on one of the `planes`.
189        for plane in &planes {
190            face_verts.clear();
191            for &v in &raw_vertices {
192                if plane.distance(v).abs() < 0.005 {
193                    face_verts.push(v);
194                }
195            }
196            if face_verts.len() >= 3 {
197                // Sort vertices around the plane normal, projecting onto a 2D coordinate system
198                let face_center = face_verts.iter().copied().fold(Vec3::ZERO, |a, b| a + b)
199                    / face_verts.len() as f32;
200
201                // create local basis — guard against degenerate ref_v
202                let n = plane.normal;
203                let mut ref_v = Vec3::ZERO;
204                for fv in &face_verts {
205                    let candidate = *fv - face_center;
206                    if candidate.length_squared() > 1e-8 {
207                        ref_v = candidate.normalize();
208                        break;
209                    }
210                }
211                // If all vertices coincide with face_center (degenerate), skip face
212                if ref_v.length_squared() < 0.5 {
213                    continue;
214                }
215                // Ensure ref_v is not parallel to normal
216                let cross_test = n.cross(ref_v);
217                if cross_test.length_squared() < 1e-8 {
218                    // Pick an arbitrary perpendicular
219                    ref_v = if n.x.abs() > 0.9 {
220                        Vec3::new(0.0, 1.0, 0.0)
221                    } else {
222                        Vec3::new(1.0, 0.0, 0.0)
223                    };
224                }
225                let tangent = n.cross(ref_v).normalize();
226                let bitangent = n.cross(tangent).normalize();
227
228                face_verts.sort_by(|a, b| {
229                    let dir_a = *a - face_center;
230                    let dir_b = *b - face_center;
231                    let angle_a = f32::atan2(dir_a.dot(tangent), dir_a.dot(bitangent));
232                    let angle_b = f32::atan2(dir_b.dot(tangent), dir_b.dot(bitangent));
233                    angle_a
234                        .partial_cmp(&angle_b)
235                        .unwrap_or(std::cmp::Ordering::Equal)
236                });
237
238                // Fan triangulation
239                let base_idx = out_vertices.len() as u32;
240
241                // To keep hard edges, duplicate the vertices for this face and calculate proper normals
242                let norm = plane.normal;
243                for v in &face_verts {
244                    out_vertices.push(*v);
245                    out_normals.push(norm);
246                }
247
248                for k in 1..(face_verts.len() - 1) {
249                    out_indices.push(base_idx);
250                    out_indices.push(base_idx + k as u32);
251                    out_indices.push(base_idx + k as u32 + 1);
252                }
253            }
254        }
255
256        if out_indices.is_empty() {
257            continue;
258        }
259
260        // True volume centroid (mass center of a uniform solid), not the vertex
261        // average — the mesh is a closed convex polyhedron here.
262        let (center_of_mass, volume) = compute_convex_mass_props(&out_vertices, &out_indices);
263        chunks.push(ProceduralChunk {
264            vertices: out_vertices.clone(),
265            normals: out_normals.clone(),
266            indices: out_indices.clone(),
267            center_of_mass,
268            volume,
269        });
270    }
271
272    chunks
273}
274
275// Intersects three planes and finds the intersection point
276fn intersect_planes(p1: &MathPlane, p2: &MathPlane, p3: &MathPlane) -> Option<Vec3> {
277    let cross = p2.normal.cross(p3.normal);
278    let det = p1.normal.dot(cross);
279    if det.abs() < 0.0001 {
280        return None; // Planes do not intersect at a single point (parallel)
281    }
282
283    let inv_det = 1.0 / det;
284    let res =
285        (cross * p1.d) + (p3.normal.cross(p1.normal) * p2.d) + (p1.normal.cross(p2.normal) * p3.d);
286
287    Some(res * inv_det)
288}
289
290/// Helper function to create physics chunks from a fracturing event.
291/// Returns a list of (RigidBody, Transform, Collider, ProceduralChunk) for the ECS to spawn.
292pub fn generate_fracture_chunks(
293    original_transform: &gizmo_physics_core::Transform,
294    original_body: &crate::components::RigidBody,
295    original_velocity: &crate::components::Velocity,
296    extents: Vec3,
297    num_pieces: u32,
298    impact_point: Vec3,
299    impact_force: f32,
300) -> Vec<(
301    crate::components::RigidBody,
302    gizmo_physics_core::Transform,
303    gizmo_physics_core::Collider,
304    crate::components::Velocity,
305    ProceduralChunk,
306)> {
307    let chunks = voronoi_shatter(extents, num_pieces, rand::random::<u64>());
308
309    let mut results = Vec::with_capacity(chunks.len());
310    let total_volume: f32 = chunks.iter().map(|c| c.volume).sum();
311    let original_mass = original_body.mass;
312
313    for chunk in chunks {
314        // Calculate fraction of mass
315        let mass = if total_volume > 0.0 {
316            original_mass * (chunk.volume / total_volume)
317        } else {
318            0.1
319        };
320
321        // Create new rigid body. Friction/restitution live on the collider
322        // material, not the body, so fragments inherit them via their colliders.
323        let mut rb = crate::components::RigidBody::new(mass, original_body.use_gravity);
324        rb.center_of_mass = chunk.center_of_mass;
325
326        // Inherit exact same velocity + explosion force away from impact point
327        let mut vel = *original_velocity;
328
329        // Calculate explosion force direction
330        let world_chunk_center =
331            original_transform.position + original_transform.rotation * chunk.center_of_mass;
332        let dir = world_chunk_center - impact_point;
333        if dir.length_squared() > 0.001 {
334            let explosion_dir = dir.normalize();
335            // Force drops off with distance (simplified)
336            let force = impact_force * 0.1 / (dir.length() + 1.0);
337            let dv = (force / mass).min(MAX_EXPLOSION_DV);
338            vel.linear += explosion_dir * dv;
339
340            // Add some random spin
341            vel.angular += Vec3::new(
342                rand::random::<f32>() - 0.5,
343                rand::random::<f32>() - 0.5,
344                rand::random::<f32>() - 0.5,
345            ) * dv
346                * 0.5;
347        }
348
349        // Create convex hull collider
350        let hull = gizmo_physics_core::quickhull::compute_convex_hull(&chunk.vertices);
351        let collider = gizmo_physics_core::Collider::from_shape(
352            gizmo_physics_core::ColliderShape::ConvexHull(gizmo_physics_core::ConvexHullShape {
353                vertices: std::sync::Arc::new(hull.vertices),
354                faces: std::sync::Arc::new(hull.faces),
355            }),
356        );
357
358        rb.update_inertia_from_collider(&collider);
359
360        let transform = gizmo_physics_core::Transform {
361            position: original_transform.position, // The vertices in the chunk are local to the original center
362            rotation: original_transform.rotation,
363            scale: original_transform.scale,
364            ..*original_transform
365        };
366
367        results.push((rb, transform, collider, vel, chunk));
368    }
369
370    results
371}
372
373/// Stores pre-fractured chunks to avoid expensive runtime calculations (Pre-fracture Caching).
374/// Ideal for AAA games where destruction must not drop frames.
375#[derive(Default)]
376pub struct PreFracturedCache {
377    /// Maps an BodyHandle ID to its pre-calculated fracture data
378    pub cache: std::collections::HashMap<BodyHandle, Vec<ProceduralChunk>>,
379}
380
381impl PreFracturedCache {
382    pub fn new() -> Self {
383        Self {
384            cache: std::collections::HashMap::new(),
385        }
386    }
387
388    /// Pre-calculates fracture chunks for an entity and stores them in the cache.
389    /// This should be called during a loading screen.
390    pub fn pre_fracture(
391        &mut self,
392        entity: BodyHandle,
393        extents: Vec3,
394        num_pieces: u32,
395        seed: u64,
396    ) {
397        let chunks = voronoi_shatter(extents, num_pieces, seed);
398        self.cache.insert(entity, chunks);
399    }
400
401    /// Spawns the chunks from the cache if available, taking only O(N) time to clone instead of O(N^3).
402    /// If not in cache, optionally falls back to runtime calculation.
403    pub fn get_fracture_chunks(
404        &self,
405        entity: BodyHandle,
406        original_transform: &gizmo_physics_core::Transform,
407        original_body: &crate::components::RigidBody,
408        original_velocity: &crate::components::Velocity,
409        impact_point: Vec3,
410        impact_force: f32,
411    ) -> Option<
412        Vec<(
413            crate::components::RigidBody,
414            gizmo_physics_core::Transform,
415            gizmo_physics_core::Collider,
416            crate::components::Velocity,
417            ProceduralChunk,
418        )>,
419    > {
420        let chunks = self.cache.get(&entity)?;
421
422        let mut results = Vec::with_capacity(chunks.len());
423        let total_volume: f32 = chunks.iter().map(|c| c.volume).sum();
424        let original_mass = original_body.mass;
425
426        for chunk in chunks {
427            let mass = if total_volume > 0.0 {
428                original_mass * (chunk.volume / total_volume)
429            } else {
430                0.1
431            };
432
433            let mut rb = crate::components::RigidBody::new(mass, original_body.use_gravity);
434            rb.center_of_mass = chunk.center_of_mass;
435
436            let mut vel = *original_velocity;
437            let world_chunk_center =
438                original_transform.position + original_transform.rotation * chunk.center_of_mass;
439            let dir = world_chunk_center - impact_point;
440            if dir.length_squared() > 0.001 {
441                let explosion_dir = dir.normalize();
442                let force = impact_force * 0.1 / (dir.length() + 1.0);
443                let dv = (force / mass).min(MAX_EXPLOSION_DV);
444                vel.linear += explosion_dir * dv;
445
446                // Deterministic spin based on chunk properties (since cache is pre-calculated)
447                vel.angular += Vec3::new(
448                    (chunk.center_of_mass.x * 12.345).fract() - 0.5,
449                    (chunk.center_of_mass.y * 67.890).fract() - 0.5,
450                    (chunk.center_of_mass.z * 42.123).fract() - 0.5,
451                ) * dv
452                    * 0.5;
453            }
454
455            let hull = gizmo_physics_core::quickhull::compute_convex_hull(&chunk.vertices);
456            let collider = gizmo_physics_core::Collider::from_shape(
457                gizmo_physics_core::ColliderShape::ConvexHull(gizmo_physics_core::ConvexHullShape {
458                    vertices: std::sync::Arc::new(hull.vertices),
459                    faces: std::sync::Arc::new(hull.faces),
460                }),
461            );
462
463            rb.update_inertia_from_collider(&collider);
464
465            let transform = gizmo_physics_core::Transform {
466                position: original_transform.position,
467                rotation: original_transform.rotation,
468                scale: original_transform.scale,
469                ..*original_transform
470            };
471
472            results.push((rb, transform, collider, vel, chunk.clone()));
473        }
474
475        Some(results)
476    }
477}
478
479#[cfg(test)]
480mod tests {
481    use super::*;
482    use crate::components::{RigidBody, Velocity};
483    use gizmo_physics_core::BodyHandle;
484    use gizmo_physics_core::Transform;
485
486    #[test]
487    fn voronoi_shatter_does_not_panic_on_degenerate_extents() {
488        // Regression: a thin/flat breakable (a zero or negative half-extent) made
489        // `rng.random_range(-e..e)` sample an empty range, an unconditional panic in rand
490        // 0.10 that crashed the physics step. Degenerate extents must be floored, not panic.
491        let flat = voronoi_shatter(Vec3::new(1.0, 0.0, 1.0), 6, 42);
492        assert!(!flat.is_empty(), "flat box should still produce chunks");
493        let neg = voronoi_shatter(Vec3::new(-1.0, 0.5, 0.0), 4, 7);
494        assert!(!neg.is_empty(), "negative/zero extents must be handled, not panic");
495        // Sanity: a normal box still shatters.
496        assert!(!voronoi_shatter(Vec3::splat(1.0), 8, 1).is_empty());
497    }
498
499    /// COM = gerçek HACİM merkezi (düzgün-yoğunluklu katının kütle merkezi), vertex
500    /// ortalaması DEĞİL. Ayırt edici şekil: kare piramit — kütle merkezi tabandan h/4,
501    /// vertex ortalaması ise h/5. Eski kod (vertex-centroid) bu testte FAIL eder.
502    #[test]
503    fn convex_mass_props_returns_volume_centroid_not_vertex_average() {
504        // Taban z=0'da 2×2 kare (alan 4), tepe (0,0,3) → hacim = ⅓·4·3 = 4.
505        let verts = vec![
506            Vec3::new(-1.0, -1.0, 0.0),
507            Vec3::new(1.0, -1.0, 0.0),
508            Vec3::new(1.0, 1.0, 0.0),
509            Vec3::new(-1.0, 1.0, 0.0),
510            Vec3::new(0.0, 0.0, 3.0),
511        ];
512        // Vertex ortalaması = (0,0,0.6); hacim merkezi = (0,0,0.75).
513        let mut tris: Vec<[u32; 3]> = vec![
514            [0, 1, 2],
515            [0, 2, 3], // taban
516            [0, 1, 4],
517            [1, 2, 4],
518            [2, 3, 4],
519            [3, 0, 4], // yan yüzler
520        ];
521        // Fonksiyon TUTARLI sarım ister; her üçgeni dışa-normal olacak şekilde düzelt.
522        let centroid = verts.iter().copied().fold(Vec3::ZERO, |a, b| a + b) / verts.len() as f32;
523        let mut indices = Vec::new();
524        for t in &mut tris {
525            let (a, b, c) = (
526                verts[t[0] as usize],
527                verts[t[1] as usize],
528                verts[t[2] as usize],
529            );
530            let n = (b - a).cross(c - a);
531            let face_center = (a + b + c) / 3.0;
532            if n.dot(face_center - centroid) < 0.0 {
533                t.swap(1, 2);
534            }
535            indices.extend_from_slice(t);
536        }
537
538        let (com, vol) = compute_convex_mass_props(&verts, &indices);
539        assert!((vol - 4.0).abs() < 1e-3, "hacim ~4 olmalı, bulundu {vol}");
540        assert!(
541            com.x.abs() < 1e-4 && com.y.abs() < 1e-4,
542            "simetri: com.xy ≈ 0, bulundu {com:?}"
543        );
544        assert!(
545            (com.z - 0.75).abs() < 1e-3,
546            "hacim merkezi z ≈ 0.75 olmalı (vertex ortalaması 0.6 DEĞİL), bulundu {}",
547            com.z
548        );
549        assert!(
550            (com.z - 0.6).abs() > 0.1,
551            "COM vertex ortalamasından (0.6) belirgin farklı olmalı"
552        );
553    }
554
555    /// İnce kıymık (çok küçük hacim/kütle) parçalar, büyük çarpma kuvvetinde bile
556    /// makul hızda fırlatılmalı — `force/mass` patlaması MAX_EXPLOSION_DV ile kırpılır.
557    #[test]
558    fn explosion_velocity_clamped_for_tiny_chunks() {
559        let tetra = || {
560            vec![
561                Vec3::ZERO,
562                Vec3::X * 0.1,
563                Vec3::Y * 0.1,
564                Vec3::Z * 0.1,
565            ]
566        };
567        let big = ProceduralChunk {
568            vertices: tetra(),
569            normals: vec![],
570            indices: vec![],
571            center_of_mass: Vec3::new(1.0, 0.0, 0.0),
572            volume: 10.0,
573        };
574        let tiny = ProceduralChunk {
575            vertices: tetra(),
576            normals: vec![],
577            indices: vec![],
578            center_of_mass: Vec3::new(-1.0, 0.0, 0.0),
579            volume: 0.001, // minik → çok küçük kütle
580        };
581
582        let mut cache = PreFracturedCache::new();
583        let e = BodyHandle::from_id(1);
584        cache.cache.insert(e, vec![big, tiny]);
585
586        let tr = Transform::new(Vec3::ZERO);
587        let body = RigidBody::new(10.0, true);
588        let out = cache
589            .get_fracture_chunks(e, &tr, &body, &Velocity::default(), Vec3::new(0.0, 5.0, 0.0), 1.0e6)
590            .unwrap();
591
592        assert_eq!(out.len(), 2);
593        for (_rb, _t, _c, v, _chunk) in &out {
594            assert!(v.linear.is_finite() && v.angular.is_finite(), "hız sonlu olmalı");
595            assert!(
596                v.linear.length() < 100.0,
597                "patlama hızı makul sınırda olmalı (clamp), oldu: {}",
598                v.linear.length()
599            );
600        }
601    }
602}