Skip to main content

kvas_engine/
cloth.rs

1//! Cloth simulation: Verlet particles joined by structural, shear and bend links.
2//! Wind acts on each triangle by its area and orientation, so cloth billows and flaps.
3//! Links snap when stretched past `tear`, ground / sphere / box colliders make cloth drape and pile up.
4
5use crate::choice_enum;
6use crate::components::*;
7use crate::hierarchy;
8use crate::mesh::{MeshData, Vertex};
9use crate::noise::{Noise, NoiseKind};
10use crate::procedural::signature;
11use crate::reflect::Field;
12use glam::{Mat4, Vec3};
13use kvas_ecs::{Entity, World};
14use serde::{Deserialize, Serialize};
15use std::sync::Arc;
16
17choice_enum!(
18    /// Pin no particles, the upper edge, the upper corners, or the left edge of the cloth.
19    ClothPin { Free, TopEdge, TopCorners, LeftEdge } default TopEdge);
20
21/// Verlet cloth attached to an entity. Structural edits rebuild the particle grid;
22/// wind and other simulation controls can change while running.
23#[derive(Clone, Serialize, Deserialize)]
24#[serde(default)]
25pub struct Cloth {
26    /// Rest width of the sheet in metres.
27    pub width: f32,
28    /// Rest height of the sheet in metres.
29    pub height: f32,
30    /// Particles along the width (more = finer folds, slower).
31    pub segments: i32,
32    /// Boundary particles fixed to the entity transform.
33    pub pin: ClothPin,
34    /// 0..1: how rigid the cloth is (solver iterations).
35    pub stiffness: f32,
36    /// Per-step motion damping; values near zero retain motion.
37    pub damping: f32,
38    /// Multiplier of the cloth solver gravity.
39    pub gravity_scale: f32,
40    /// World-space wind velocity acting on triangle surfaces.
41    pub wind: Vec3,
42    /// How much the wind varies in space and time (0 = steady).
43    pub gust: f32,
44    /// Air resistance of the surface.
45    pub drag: f32,
46    /// Links snap when stretched past this multiple of their length (0 = never tear).
47    pub tear: f32,
48    /// Whether particles collide with the horizontal ground plane.
49    pub collide_ground: bool,
50    /// World-space height of the optional ground plane.
51    pub ground_y: f32,
52    /// Collide with Sphere and Box colliders in the scene.
53    pub collide_objects: bool,
54    #[serde(skip)]
55    #[doc(hidden)]
56    pub state: Option<State>,
57    #[serde(skip)]
58    #[doc(hidden)]
59    pub sig: u64,
60}
61
62impl Default for Cloth {
63    fn default() -> Self {
64        Self {
65            width: 3.0,
66            height: 2.0,
67            segments: 24,
68            pin: ClothPin::TopEdge,
69            stiffness: 0.8,
70            damping: 0.02,
71            gravity_scale: 1.0,
72            wind: Vec3::new(2.5, 0.0, 1.2),
73            gust: 1.0,
74            drag: 0.8,
75            tear: 0.0,
76            collide_ground: true,
77            ground_y: 0.0,
78            collide_objects: true,
79            state: None,
80            sig: 0,
81        }
82    }
83}
84
85impl Component for Cloth {
86    fn fields(&mut self) -> Vec<Field<'_>> {
87        vec![
88            Field::f32("width", &mut self.width),
89            Field::f32("height", &mut self.height),
90            Field::i32("segments", &mut self.segments),
91            Field::choice("pin", &mut self.pin),
92            Field::f32("stiffness", &mut self.stiffness),
93            Field::f32("damping", &mut self.damping),
94            Field::f32("gravity_scale", &mut self.gravity_scale),
95            Field::vec3("wind", &mut self.wind),
96            Field::f32("gust", &mut self.gust),
97            Field::f32("drag", &mut self.drag),
98            Field::f32("tear", &mut self.tear),
99            Field::bool("collide_ground", &mut self.collide_ground),
100            Field::f32("ground_y", &mut self.ground_y),
101            Field::bool("collide_objects", &mut self.collide_objects),
102        ]
103    }
104}
105
106impl Cloth {
107    /// Fraction of links that have not torn (1 = intact).
108    pub fn integrity(&self) -> f32 {
109        self.state.as_ref().map_or(1.0, |s| s.links.iter().filter(|l| l.alive).count() as f32 / s.links.len().max(1) as f32)
110    }
111
112    /// Current particle positions in world space (empty before the first update).
113    pub fn particles(&self) -> &[Vec3] {
114        self.state.as_ref().map_or(&[], |s| &s.pos)
115    }
116}
117
118#[derive(Clone)]
119struct Link {
120    a: usize,
121    b: usize,
122    rest: f32,
123    alive: bool,
124}
125
126/// Simulation state (internal).
127#[doc(hidden)]
128#[derive(Clone)]
129pub struct State {
130    nx: usize,
131    ny: usize,
132    /// Rest layout in the entity's local space (used for pinning and for the initial pose).
133    local: Vec<Vec3>,
134    pos: Vec<Vec3>,
135    prev: Vec<Vec3>,
136    pinned: Vec<bool>,
137    links: Vec<Link>,
138    /// Triangles with the three links that must be intact for them to exist.
139    tris: Vec<([usize; 3], [usize; 3])>,
140    version: u64,
141}
142
143enum Obstacle {
144    Sphere { center: Vec3, radius: f32 },
145    Box { to_local: Mat4, to_world: Mat4, half: Vec3 },
146}
147
148impl State {
149    fn new(c: &Cloth, model: Mat4) -> State {
150        let nx = c.segments.clamp(2, 80) as usize;
151        let ny = ((nx as f32 * c.height / c.width.max(0.01)).round() as usize).clamp(2, 80);
152        let idx = |i: usize, j: usize| j * (nx + 1) + i;
153        let mut local = Vec::new();
154        let mut pinned = Vec::new();
155        for j in 0..=ny {
156            for i in 0..=nx {
157                local.push(Vec3::new((i as f32 / nx as f32 - 0.5) * c.width, -(j as f32 / ny as f32) * c.height, 0.0));
158                pinned.push(match c.pin {
159                    ClothPin::Free => false,
160                    ClothPin::TopEdge => j == 0,
161                    ClothPin::TopCorners => j == 0 && (i == 0 || i == nx),
162                    ClothPin::LeftEdge => i == 0,
163                });
164            }
165        }
166        let pos: Vec<Vec3> = local.iter().map(|&p| model.transform_point3(p)).collect();
167        let mut links = Vec::new();
168        let link = |a: usize, b: usize, links: &mut Vec<Link>| {
169            links.push(Link { a, b, rest: pos[a].distance(pos[b]), alive: true });
170            links.len() - 1
171        };
172        let mut tris = Vec::new();
173        let (mut h, mut v) = (vec![0usize; (nx + 1) * (ny + 1)], vec![0usize; (nx + 1) * (ny + 1)]);
174        for j in 0..=ny {
175            for i in 0..=nx {
176                if i < nx {
177                    h[idx(i, j)] = link(idx(i, j), idx(i + 1, j), &mut links);
178                }
179                if j < ny {
180                    v[idx(i, j)] = link(idx(i, j), idx(i, j + 1), &mut links);
181                }
182            }
183        }
184        for j in 0..ny {
185            for i in 0..nx {
186                let diag = link(idx(i + 1, j), idx(i, j + 1), &mut links); // B-C
187                link(idx(i, j), idx(i + 1, j + 1), &mut links); // A-D (shear only)
188                let (a, b, cc, d) = (idx(i, j), idx(i + 1, j), idx(i, j + 1), idx(i + 1, j + 1));
189                tris.push(([a, b, cc], [h[a], v[a], diag]));
190                tris.push(([b, d, cc], [v[b], h[cc], diag]));
191            }
192        }
193        for j in 0..=ny {
194            for i in 0..=nx {
195                if i + 2 <= nx {
196                    link(idx(i, j), idx(i + 2, j), &mut links);
197                }
198                if j + 2 <= ny {
199                    link(idx(i, j), idx(i, j + 2), &mut links);
200                }
201            }
202        }
203        State { nx, ny, prev: pos.clone(), pos, local, pinned, links, tris, version: 0 }
204    }
205
206    fn tri_alive(&self, t: usize) -> bool {
207        self.tris[t].1.iter().all(|&l| self.links[l].alive)
208    }
209
210    fn step(&mut self, c: &Cloth, obstacles: &[Obstacle], model: Mat4, time: f32, dt: f32) {
211        let gust_noise = Noise::new(NoiseKind::Perlin).with_seed(11).with_octaves(2);
212        let iterations = 3 + (c.stiffness.clamp(0.0, 1.0) * 9.0) as usize;
213        let gravity = Vec3::new(0.0, -9.81 * c.gravity_scale, 0.0);
214        let n = self.pos.len();
215
216        // Aerodynamic force per triangle, shared by its three particles.
217        let mut force = vec![Vec3::ZERO; n];
218        for t in 0..self.tris.len() {
219            if !self.tri_alive(t) {
220                continue;
221            }
222            let [a, b, cc] = self.tris[t].0;
223            let (pa, pb, pc) = (self.pos[a], self.pos[b], self.pos[cc]);
224            let cross = (pb - pa).cross(pc - pa);
225            let area2 = cross.length();
226            if area2 < 1e-9 {
227                continue;
228            }
229            let normal = cross / area2;
230            let centroid = (pa + pb + pc) / 3.0;
231            let gust = 1.0 + c.gust * gust_noise.sample3(centroid.x * 0.35, centroid.z * 0.35 + centroid.y * 0.2, time * 0.9);
232            let velocity = ((pa - self.prev[a]) + (pb - self.prev[b]) + (pc - self.prev[cc])) / (3.0 * dt.max(1e-4));
233            let relative = c.wind * gust - velocity;
234            let push = normal * normal.dot(relative) * c.drag * 1.5;
235            for p in [a, b, cc] {
236                force[p] += push / 3.0;
237            }
238        }
239
240        for (i, force) in force.iter().enumerate().take(n) {
241            if self.pinned[i] {
242                self.pos[i] = model.transform_point3(self.local[i]);
243                self.prev[i] = self.pos[i];
244                continue;
245            }
246            let velocity = (self.pos[i] - self.prev[i]) * (1.0 - c.damping.clamp(0.0, 0.5));
247            self.prev[i] = self.pos[i];
248            self.pos[i] += velocity + (gravity + *force) * dt * dt;
249        }
250
251        for _ in 0..iterations {
252            for l in 0..self.links.len() {
253                let Link { a, b, rest, alive } = self.links[l];
254                if !alive {
255                    continue;
256                }
257                let delta = self.pos[b] - self.pos[a];
258                let dist = delta.length();
259                if dist < 1e-7 {
260                    continue;
261                }
262                if c.tear > 1.0 && dist > rest * c.tear {
263                    self.links[l].alive = false;
264                    continue;
265                }
266                let correction = delta * ((dist - rest) / dist);
267                match (self.pinned[a], self.pinned[b]) {
268                    (false, false) => {
269                        self.pos[a] += correction * 0.5;
270                        self.pos[b] -= correction * 0.5;
271                    }
272                    (true, false) => self.pos[b] -= correction,
273                    (false, true) => self.pos[a] += correction,
274                    (true, true) => {}
275                }
276            }
277            self.collide(c, obstacles);
278        }
279    }
280
281    fn collide(&mut self, c: &Cloth, obstacles: &[Obstacle]) {
282        const THICKNESS: f32 = 0.02;
283        for i in 0..self.pos.len() {
284            if self.pinned[i] {
285                continue;
286            }
287            let mut p = self.pos[i];
288            if c.collide_ground && p.y < c.ground_y + THICKNESS {
289                p.y = c.ground_y + THICKNESS;
290                // Friction: drag the previous position along so cloth does not skate.
291                self.prev[i].x += (p.x - self.prev[i].x) * 0.25;
292                self.prev[i].z += (p.z - self.prev[i].z) * 0.25;
293            }
294            for o in obstacles {
295                match o {
296                    Obstacle::Sphere { center, radius } => {
297                        let d = p - *center;
298                        let r = radius + THICKNESS;
299                        let len = d.length();
300                        if len < r && len > 1e-6 {
301                            let normal = d / len;
302                            p = *center + normal * r;
303                            self.prev[i] = contact_friction(p, self.prev[i], normal);
304                        }
305                    }
306                    Obstacle::Box { to_local, to_world, half } => {
307                        let l = to_local.transform_point3(p);
308                        let margin = Vec3::splat(THICKNESS) / to_world.to_scale_rotation_translation().0.max(Vec3::splat(1e-3));
309                        let h = *half + margin;
310                        if l.abs().cmple(h).all() {
311                            let depth = h - l.abs();
312                            let mut out = l;
313                            if depth.x <= depth.y && depth.x <= depth.z {
314                                out.x = h.x * l.x.signum();
315                            } else if depth.y <= depth.z {
316                                out.y = h.y * l.y.signum();
317                            } else {
318                                out.z = h.z * l.z.signum();
319                            }
320                            p = to_world.transform_point3(out);
321                            let axis = (out - l).normalize_or_zero();
322                            self.prev[i] = contact_friction(p, self.prev[i], to_world.transform_vector3(axis).normalize_or_zero());
323                        }
324                    }
325                }
326            }
327            self.pos[i] = p;
328        }
329    }
330
331    /// Double-sided triangle mesh in the entity's local space; torn triangles are left out.
332    fn mesh(&self, to_local: Mat4) -> MeshData {
333        let n = self.pos.len();
334        let local: Vec<Vec3> = self.pos.iter().map(|&p| to_local.transform_point3(p)).collect();
335        let mut normals = vec![Vec3::ZERO; n];
336        let mut live = Vec::new();
337        for (t, (verts, _)) in self.tris.iter().enumerate() {
338            if !self.tri_alive(t) {
339                continue;
340            }
341            let [a, b, c] = *verts;
342            let face = (local[b] - local[a]).cross(local[c] - local[a]);
343            for v in [a, b, c] {
344                normals[v] += face;
345            }
346            live.push(*verts);
347        }
348        let mut m = MeshData::default();
349        for side in [1.0f32, -1.0] {
350            for i in 0..n {
351                let (x, y) = (i % (self.nx + 1), i / (self.nx + 1));
352                m.vertices.push(Vertex {
353                    pos: local[i].into(),
354                    normal: (normals[i].normalize_or_zero() * side).into(),
355                    uv: [x as f32 / self.nx as f32, y as f32 / self.ny as f32],
356                });
357            }
358        }
359        for [a, b, c] in live {
360            m.indices.extend([a as u32, b as u32, c as u32]);
361            let o = n as u32;
362            m.indices.extend([c as u32 + o, b as u32 + o, a as u32 + o]);
363        }
364        m
365    }
366}
367
368/// Keeps most of the sliding motion out of a contact: removes the normal velocity (no bounce) and
369/// 70 % of the tangential velocity, so cloth grips what it rests on instead of skating off.
370fn contact_friction(pos: Vec3, prev: Vec3, normal: Vec3) -> Vec3 {
371    let v = pos - prev;
372    let tangential = v - normal * v.dot(normal);
373    pos - tangential * 0.3
374}
375
376fn gather_obstacles(world: &World, skip: Entity) -> Vec<Obstacle> {
377    let mut out = Vec::new();
378    for (e, col) in world.iter::<Collider>() {
379        if e == skip || col.is_trigger {
380            continue;
381        }
382        let Some(g) = world.get::<GlobalTransform>(e).map(|g| g.0) else { continue };
383        if !g.is_finite() || g.determinant() == 0.0 { continue; }
384        match col.shape {
385            ShapeKind::Sphere => {
386                let (scale, _, pos) = g.to_scale_rotation_translation();
387                out.push(Obstacle::Sphere { center: pos, radius: col.size.x.abs() * 0.5 * scale.abs().max_element() });
388            }
389            ShapeKind::Box => out.push(Obstacle::Box { to_local: g.inverse(), to_world: g, half: col.size.abs() * 0.5 }),
390            _ => {}
391        }
392    }
393    out
394}
395
396/// Advances every cloth and refreshes its `DynamicMesh`. With `simulate` false only the initial
397/// (rest) shape is built, which is what the editor shows outside Play mode.
398/// Simulation deltas are sanitized and limited to 0.1 seconds for solver stability.
399/// Cached world matrices are refreshed so same-frame transform edits are visible.
400pub fn update(world: &mut World, time: f32, dt: f32, simulate: bool) {
401    if world.count::<Cloth>() == 0 { return; }
402    hierarchy::update_globals(world);
403    let dt = if dt.is_finite() { dt.clamp(0.0, 0.1) } else { 0.0 };
404    let time = if time.is_finite() { time } else { 0.0 };
405    for e in world.entities_with::<Cloth>() {
406        let model = world.get::<GlobalTransform>(e).map_or_else(|| hierarchy::global_matrix(world, e), |g| g.0);
407        if !model.is_finite() || model.determinant() == 0.0 { continue; }
408        let obstacles =
409            if simulate && world.get::<Cloth>(e).is_some_and(|c| c.collide_objects) { gather_obstacles(world, e) } else { Vec::new() };
410        let cloth = world.get_mut::<Cloth>(e).unwrap();
411        // Only the layout restarts the simulation; wind, damping, tearing... are applied live.
412        let sig = signature(&(cloth.width, cloth.height, cloth.segments, cloth.pin));
413        if cloth.state.is_none() || cloth.sig != sig {
414            cloth.state = Some(State::new(cloth, model));
415            cloth.sig = sig;
416        }
417        let mut state = cloth.state.take().unwrap();
418        if simulate && dt > 0.0 {
419            let substeps = 2;
420            for _ in 0..substeps {
421                state.step(cloth, &obstacles, model, time, dt / substeps as f32);
422            }
423        } else if !simulate {
424            // Edit mode: keep the rest shape attached to the entity as it is moved.
425            state.pos = state.local.iter().map(|&p| model.transform_point3(p)).collect();
426            state.prev = state.pos.clone();
427        }
428        state.version = state.version.wrapping_add(1);
429        let mesh = state.mesh(model.inverse());
430        let version = state.version;
431        cloth.state = Some(state);
432        world.insert(e, DynamicMesh { data: Arc::new(mesh), version });
433        if !world.has::<MeshRenderer>(e) {
434            world.insert(e, MeshRenderer { color: [0.75, 0.2, 0.2, 1.0], roughness: 0.9, ..Default::default() });
435        }
436    }
437}
438
439#[cfg(test)]
440mod tests {
441    use super::*;
442    use crate::scene::Scene;
443
444    fn cloth_scene(cloth: Cloth, at: Vec3) -> (Scene, Entity) {
445        let mut scene = Scene::default();
446        let e = hierarchy::spawn_named(&mut scene.world, "Cloth");
447        scene.world.insert(e, Transform::at(at));
448        scene.world.insert(e, cloth);
449        hierarchy::update_globals(&mut scene.world);
450        (scene, e)
451    }
452
453    fn run(scene: &mut Scene, seconds: f32) {
454        for i in 0..(seconds * 60.0) as usize {
455            update(&mut scene.world, i as f32 / 60.0, 1.0 / 60.0, true);
456        }
457    }
458
459    #[test]
460    fn pinned_cloth_hangs_and_keeps_its_length() {
461        let (mut scene, e) = cloth_scene(Cloth { wind: Vec3::ZERO, ..Default::default() }, Vec3::new(0.0, 5.0, 0.0));
462        run(&mut scene, 4.0);
463        let c = scene.world.get::<Cloth>(e).unwrap();
464        let ys: Vec<f32> = c.particles().iter().map(|p| p.y).collect();
465        let (top, bottom) = (ys.iter().cloned().fold(f32::MIN, f32::max), ys.iter().cloned().fold(f32::MAX, f32::min));
466        assert!((top - 5.0).abs() < 0.01, "pinned row stays put: {top}");
467        assert!(bottom < 3.3 && bottom > 2.6, "hangs about its own height below the pin: {bottom}");
468        assert_eq!(c.integrity(), 1.0);
469    }
470
471    #[test]
472    fn free_cloth_falls_onto_the_ground_and_stays_above_it() {
473        let (mut scene, e) = cloth_scene(Cloth { pin: ClothPin::Free, wind: Vec3::ZERO, ..Default::default() }, Vec3::new(0.0, 3.0, 0.0));
474        run(&mut scene, 5.0);
475        let min = scene.world.get::<Cloth>(e).unwrap().particles().iter().map(|p| p.y).fold(f32::MAX, f32::min);
476        assert!(min >= 0.0 && min < 0.2, "cloth rests on the ground: {min}");
477    }
478
479    #[test]
480    fn cloth_drapes_over_a_sphere() {
481        let (mut scene, e) = cloth_scene(
482            Cloth { pin: ClothPin::Free, wind: Vec3::ZERO, collide_ground: false, ..Default::default() },
483            Vec3::new(0.0, 3.0, 0.0),
484        );
485        let ball = hierarchy::spawn_named(&mut scene.world, "Ball");
486        scene.world.insert(ball, Transform::at(Vec3::new(0.0, 1.0, 1.0)));
487        scene.world.insert(ball, Collider { shape: ShapeKind::Sphere, size: Vec3::splat(1.0), ..Default::default() });
488        hierarchy::update_globals(&mut scene.world);
489        // The cloth hangs in the XY plane from y=3 to y=1; rotate it flat so it falls onto the ball.
490        scene.world.get_mut::<Transform>(e).unwrap().rotation = Vec3::new(-90.0, 0.0, 0.0);
491        hierarchy::update_globals(&mut scene.world);
492        run(&mut scene, 4.0);
493        let c = scene.world.get::<Cloth>(e).unwrap();
494        let center = Vec3::new(0.0, 1.0, 1.0);
495        assert!(c.particles().iter().all(|p| p.distance(center) > 0.49), "no particle inside the ball");
496        assert!(c.particles().iter().any(|p| p.y > 1.45), "some cloth is held up by the ball");
497    }
498
499    #[test]
500    fn strong_wind_tears_weak_cloth() {
501        let tough = Cloth { wind: Vec3::new(0.0, 0.0, 40.0), gust: 0.0, tear: 0.0, ..Default::default() };
502        let weak = Cloth { tear: 1.08, ..tough.clone() };
503        let ((mut a, ea), (mut b, eb)) = (cloth_scene(tough, Vec3::new(0.0, 5.0, 0.0)), cloth_scene(weak, Vec3::new(0.0, 5.0, 0.0)));
504        run(&mut a, 3.0);
505        run(&mut b, 3.0);
506        assert_eq!(a.world.get::<Cloth>(ea).unwrap().integrity(), 1.0);
507        assert!(b.world.get::<Cloth>(eb).unwrap().integrity() < 1.0, "torn cloth loses links");
508    }
509
510    #[test]
511    fn wind_pushes_hanging_cloth_downwind() {
512        let (mut scene, e) =
513            cloth_scene(Cloth { wind: Vec3::new(0.0, 0.0, 6.0), gust: 0.0, ..Default::default() }, Vec3::new(0.0, 5.0, 0.0));
514        run(&mut scene, 4.0);
515        let mean_z = scene.world.get::<Cloth>(e).unwrap().particles().iter().map(|p| p.z).sum::<f32>()
516            / scene.world.get::<Cloth>(e).unwrap().particles().len() as f32;
517        assert!(mean_z > 0.3, "cloth swings with the wind: {mean_z}");
518    }
519
520    #[test]
521    fn changing_wind_at_run_time_does_not_restart_the_simulation() {
522        let (mut scene, e) = cloth_scene(Cloth { pin: ClothPin::Free, wind: Vec3::ZERO, ..Default::default() }, Vec3::new(0.0, 6.0, 0.0));
523        for i in 0..60 {
524            scene.world.get_mut::<Cloth>(e).unwrap().wind = Vec3::new(i as f32 * 0.01, 0.0, 0.0); // what a wind script does
525            update(&mut scene.world, i as f32 / 60.0, 1.0 / 60.0, true);
526        }
527        let top = scene.world.get::<Cloth>(e).unwrap().particles().iter().map(|p| p.y).fold(f32::MIN, f32::max);
528        assert!(top < 5.0, "cloth keeps falling instead of resetting every frame: {top}");
529    }
530
531    #[test]
532    fn mesh_is_double_sided_and_skips_torn_triangles() {
533        let (mut scene, e) = cloth_scene(Cloth { segments: 4, ..Default::default() }, Vec3::ZERO);
534        update(&mut scene.world, 0.0, 0.0, false);
535        let full = scene.world.get::<DynamicMesh>(e).unwrap().data.indices.len();
536        assert_eq!(full, 4 * 3 * 2 * 3 * 2, "4x3 quads, 2 triangles each, two sides");
537    }
538}