Cloth
Open the live demo · Read the source · View on GitHub
A ClothMesh is a grid of particles and the constraints between them: edges
that resist stretching, and cross-edge pairs that resist folding.
stepCloth advances it with an XPBD solver, which is what stays stable at a
compliance of zero without a spring constant that has to be retuned every
time the substep count changes.
Step 1: A sheet, and something in its way #
ClothMesh.grid pins the top row so the rest has something to hang from
rather than falling forever. A ClothObstacle is a CollisionShape and a
position, the same shapes the rest of this package's pages use, tested
through expandedPlanes rather than through anything cloth-specific.
// Row zero is pinned, which is what stops the sheet falling forever:
// everything else hangs from it.
_cloth = ClothMesh.grid(
cols: _cols,
rows: _rows,
spacing: _spacing,
height: _hangs,
);
_obstacle = ClothObstacle(
CollisionSphere(_ballRadius),
Vector3(0.68, 1.0, 0.6),
);
Step 2: Step it every frame #
Each call divides dt into ClothSettings.substeps XPBD substeps: gravity
and damping predict a new position, the structural and bending constraints
are solved against that prediction, and particles are pushed back outside
every obstacle before the result is folded into velocity and position. Here
it runs live, a step a frame: the sheet falls into place from lying flat, and
then a ball swings through it and a gust of wind, which WindSettings applies
per triangle as drag along its own normal, rises and falls. Wind sets how
hard the gust blows; Swing the ball stops the ball where it is.
// A gust that rises and falls, blowing the sheet towards the viewer.
final double gust = wind * (0.5 + 0.5 * math.sin(_clock * 0.7));
stepCloth(
_cloth,
// Drag 4, not 0.3: until 0.7.4 the wind was added as a velocity, which
// made 0.3 several hundred times stronger than its value.
ClothSettings(wind: WindSettings(velocityZ: gust, drag: 4.0)),
_step,
obstacles: <ClothObstacle>[_obstacle],
);
Step 3: What the sheet should do #
The pinned row never moves, because its inverse mass is zero and nothing an XPBD constraint does can reach a particle with no mass to correct. Left alone, the free edge should hang well below where it started, and a ball pushed into the sheet should never end up with a particle inside it.
// The pinned row never moves: its inverse mass is zero, so no force and
// no correction ever reaches it.
if (_cloth.positions[1] != _hangs) {
throw StateError('the pinned row should not have moved');
}
// Left alone with the ball out of the way, the free edge has to sag
// well below the row it hangs from.
_obstacle.position.setValues(5.0, 5.0, 5.0);
for (var i = 0; i < 300; i++) {
stepCloth(_cloth, const ClothSettings(), _step);
}
final int lastRow = (_rows - 1) * _cols;
for (var col = 0; col < _cols; col++) {
final double y = _cloth.positions[3 * (lastRow + col) + 1];
if (y > _hangs - 0.5 * (_rows - 1) * _spacing) {
throw StateError('the free edge should have hung below its start');
}
}
// And a ball pushed into the sheet must never end up inside it.
_obstacle.position.setValues(0.68, 1.0, 0.0);
for (var i = 0; i < 120; i++) {
stepCloth(
_cloth,
const ClothSettings(),
_step,
obstacles: <ClothObstacle>[_obstacle],
);
}
for (var i = 0; i < _cloth.particleCount; i++) {
final Vector3 at = Vector3(
_cloth.positions[3 * i],
_cloth.positions[3 * i + 1],
_cloth.positions[3 * i + 2],
);
if (at.distanceTo(_obstacle.position) < _ballRadius - 0.03) {
throw StateError('a particle passed through the ball');
}
}
Note. This package has no scene graph of its own. The mesh on this page is rebuilt from
ClothMesh.positionsandClothMesh.trianglesevery frame, with a normal at each particle worked out from the triangles that meet there, the same way the heightfield page draws its own terrain from aFloat32Listof samples.