fix(cloth-icm): stop fold chatter on the ground in drop scene
Build, Test & Push Frontend / quality-check (push) Failing after 2m43s
Build, Test & Push Frontend / docker (push) Skipped

Folds lying on the ground were pushed out by proximity and pulled back
by stretch constraints every two frames, so the cloth never settled.
Remove the velocity along the proximity push and smooth velocities
with the neighbour mean once per frame. Drop scene only.
The drop scene is now 40x40 with gap 0.02 and an initial spin, so
more folds cross. Add the cloth ICM next steps to the roadmap.
This commit is contained in:
lobo committed 2026-11-01 12:16:52 +01:00
1 parent 9c55298ab7
commit 6b9ede516a
8 files changed
+226 -33

No files matched your search

+7 -1
View File
@@ -15,11 +15,17 @@ Open ideas for the playground frontend. Tick a box when done.
## Tests ## Tests
- [ ] `seo.service.ts`: spec for title/meta update on route change and language change. - [ ] `seo.service.ts`: spec for title/meta update on route change and language change.
- [ ] **Cloth ICM drop specs near timeout**: drop specs in `cloth-icm-solver.spec.ts` take 2.5-3 s each vs Vitest's 5 s default; give them an explicit timeout before a slow CI runner fails them.
- [ ] Pure logic without specs: `pathfinding-animator.ts`, cloth and pendulum GPU strategies (the math around them, not the shaders), `fractal3d`. - [ ] Pure logic without specs: `pathfinding-animator.ts`, cloth and pendulum GPU strategies (the math around them, not the shaders), `fractal3d`.
## Features ## Features
- [ ] **Cloth ICM drop scene**: hits mostly solved, cloth settling and CCD open. With ICM on, drop scene 35x35 (spacing 0.145, same 4.9 m cloth), gap 0.035, one mid-frame proximity re-detection: ~0 intersections after landing (avg 1.5/frame and 82 % of frames at 0 after frame 150; 0 at the marks 100-450 except 11 at 600), 9-10 ms/frame headless; was 263 at frame 900 on 50x50. ICM off: avg 148 (max 356) after frame 150 (125 at frames 100-240), 68 avg over 1800 frames, bounded but reaches the 3 m/s speed cap at times. Finer grids or a gap >= 0.04 at this size make ICM off blow up (thousands of hits, 60-100 ms frames). Cloth still does not settle (fold flutter beside the sphere, max speed 0.6-1.0 m/s with ICM on, also with collisions off). Next: Bridson 2002 CCD + velocity repulsion, then damping of the fold flutter. - [ ] **Cloth ICM next steps** (page `algorithms/cloth-icm`, CPU; detection + contours in `collision/`, one response file per paper in `response/`):
- [x] **Fold flutter damping**: drop cloth never settled (fold vertices flutter at 0.6-1.0 m/s). Done 2026-11-01: cause was the proximity push on folds lying on the ground (push out, next frame stretch constraints pull back, ~1 m/s chatter), not XPBD. Velocity smoothing (neighbour mean) + proximity velocity clamp, drop scene only, drop gap 0.035 -> 0.02: after landing (frames 600-900) max speed median 0.107, p90 0.207 (was 1.2 / 2.0 at 35x35), mean 0.012, same fall and drape, landing frame 20 as before, 11-12 ms/frame headless on 40x40 (was 10.3 on 35x35). Tangled scene bit-identical.
- [ ] **Bridson 2002**: CCD + velocity-level repulsion, so fast motion cannot jump through the gap; allows a finer drop grid. Large.
- [ ] **Papers extending ICM**: Ye et al. 2017 (untangling via discrete collision detection), Buffet et al. 2019 (implicit untangling, layered clothing). New `response/` file + scene each. Medium per paper.
- [ ] **GPU port**: detection on the GPU, contours stay on the CPU (serial walk). Very large.
- [ ] **Cloth ICM drop scene** (state): hits mostly solved, cloth settles. Drop scene 40x40 (spacing 0.145, 5.7 m cloth, user-set), gap 0.02, initial spin 1.5 rad/s around y, one mid-frame proximity re-detection (every 5), velocity smoothing + proximity velocity clamp. Frames 100-240: ICM on avg 1.0 hits per frame (59 % of frames at 0, max 10), ICM off avg 9.8 (max 43), bounded over 900 frames (max 92 after frame 150). Settled: max vertex speed median 0.107 / p90 0.207 m/s (frames 600-900, ICM on; target was 0.1; ICM off median 0.089), mean 0.012, rare snaps to 0.6-0.7. 11.4 ms/frame headless (60 fps budget 16.7). Spin sweep (values above ~0.75 rad/s are capped at the rim to 3 m/s in the first frame; ICM on / off hits, ratio): none 0.31 / 3.9 (12.7x); 0.1 2.6 / 4.3; 0.4 3.5 / 5.8; 0.7 1.3 / 5.1; 1.5 1.0 / 9.8 (chosen, most hits); 3 0.7 / 7.2 (rest speed worse). Finer grids or a gap >= 0.04 made ICM off blow up before the fix (not re-measured). Next: Bridson 2002 CCD + velocity repulsion.
- [ ] **Shareable demo state**: keep demo settings in query params (fractal zoom/center, sorting algorithm, seeds). Opening the link restores the view. - [ ] **Shareable demo state**: keep demo settings in query params (fractal zoom/center, sorting algorithm, seeds). Opening the link restores the view.
- [ ] **New algorithm demos** (pattern: route + `RouterConstants` + `AlgorithmsService` entry + i18n + information panel): - [ ] **New algorithm demos** (pattern: route + `RouterConstants` + `AlgorithmsService` entry + i18n + information panel):
- [x] Boids / flocking (GPU path like cloth) - [x] Boids / flocking (GPU path like cloth)
@@ -12,11 +12,13 @@ export interface ClothIcmScene {
gravityY: number; gravityY: number;
sphere: Sphere | null; sphere: Sphere | null;
groundY: number | null; groundY: number | null;
/** Initial angular velocity around the vertical axis through the cloth centre in rad/s (0 = none). The rim starts above MAX_SPEED and is capped to 3 m/s in the first frame. */
spinY: number;
} }
const SCENES: Record<ClothIcmSceneId, ClothIcmScene> = { const SCENES: Record<ClothIcmSceneId, ClothIcmScene> = {
tangled: {gravityY: 0, sphere: null, groundY: null}, tangled: {gravityY: 0, sphere: null, groundY: null, spinY: 0},
drop: {gravityY: -9.81, sphere: {x: 0, y: -0.3, z: 0, r: 0.7}, groundY: -1} drop: {gravityY: -9.81, sphere: {x: 0, y: -0.3, z: 0, r: 0.7}, groundY: -1, spinY: 1.5}
}; };
const DROP_HEIGHT = 0.9; const DROP_HEIGHT = 0.9;
@@ -125,6 +127,19 @@ function ridgePoint(profile: BumpProfile, u: number): [number, number] {
return [Math.sign(u) * projected, bumpHeight(projected, amplitude, radius)]; return [Math.sign(u) * projected, bumpHeight(projected, amplitude, radius)];
} }
/** Vec4 layout [x, y, z, 0]: rigid rotation of the start positions around the y axis through the origin (the cloth centre). */
export function initialVelocities(positions: Float32Array, spinY: number): Float32Array {
const velocities = new Float32Array(positions.length);
if (spinY === 0) {
return velocities;
}
for (let base = 0; base < positions.length; base += 4) {
velocities[base] = spinY * positions[base + 2];
velocities[base + 2] = -spinY * positions[base];
}
return velocities;
}
function dropPositions(topology: ClothTopology, spacing: number, invMass: number): Float32Array { function dropPositions(topology: ClothTopology, spacing: number, invMass: number): Float32Array {
const {gridWidth, gridHeight} = topology; const {gridWidth, gridHeight} = topology;
const positions = new Float32Array(topology.vertexCount * 4); const positions = new Float32Array(topology.vertexCount * 4);
@@ -4,26 +4,43 @@ import {ClothIcmSolver} from './cloth-icm-solver';
const DT = 1 / 60; const DT = 1 / 60;
const TANGLED_SPACING = 0.1; const TANGLED_SPACING = 0.1;
function config(gridSize: number, thickness = 0.01, proximityRedetectEvery = 0, spacing = TANGLED_SPACING): ClothIcmConfig { function config(
return {gridWidth: gridSize, gridHeight: gridSize, spacing, particleInvMass: 1 / (spacing * spacing), thickness, proximityRedetectEvery}; gridSize: number, thickness = 0.01, proximityRedetectEvery = 0, spacing = TANGLED_SPACING, velocitySmoothing = 0, clampProximityVelocity = false
): ClothIcmConfig {
return {
gridWidth: gridSize, gridHeight: gridSize, spacing, particleInvMass: 1 / (spacing * spacing), thickness, proximityRedetectEvery,
velocitySmoothing, clampProximityVelocity
};
} }
/** Mean hits per frame over frames 100..240 and the share of those frames with 0 hits, drop scene with the page settings. */ function dropSolver(proximityRedetectEvery = SCENE_SETTINGS.drop.proximityRedetectEvery): ClothIcmSolver {
function dropHitsAfterLanding(icmEnabled: boolean, proximityRedetectEvery: number): {average: number; zeroShare: number} { const {gridSize, spacing, thickness, velocitySmoothing, clampProximityVelocity} = SCENE_SETTINGS.drop;
const {gridSize, spacing, thickness} = SCENE_SETTINGS.drop; return new ClothIcmSolver(config(gridSize, thickness, proximityRedetectEvery, spacing, velocitySmoothing, clampProximityVelocity), 'drop');
const solver = new ClothIcmSolver(config(gridSize, thickness, proximityRedetectEvery, spacing), 'drop'); }
/**
* Mean hits per frame over frames 100..240, the share of those frames with 0 hits and the mean of the fastest vertex
* over frames 180..240, drop scene with the page settings.
*/
function dropAfterLanding(icmEnabled: boolean): {average: number; zeroShare: number; restMaxSpeed: number} {
const solver = dropSolver();
const first = 100; const first = 100;
const last = 240; const last = 240;
const restFirst = 180;
let sum = 0; let sum = 0;
let zeroFrames = 0; let zeroFrames = 0;
let speedSum = 0;
for (let frame = 1; frame <= last; frame++) { for (let frame = 1; frame <= last; frame++) {
solver.step({dt: DT, icmEnabled, icmStep: 0.25}); solver.step({dt: DT, icmEnabled, icmStep: 0.25});
if (frame > restFirst) {
speedSum += solver.maxSpeed();
}
if (frame > first) { if (frame > first) {
sum += solver.stats.intersections; sum += solver.stats.intersections;
zeroFrames += solver.stats.intersections === 0 ? 1 : 0; zeroFrames += solver.stats.intersections === 0 ? 1 : 0;
} }
} }
return {average: sum / (last - first), zeroShare: zeroFrames / (last - first)}; return {average: sum / (last - first), zeroShare: zeroFrames / (last - first), restMaxSpeed: speedSum / (last - restFirst)};
} }
function run( function run(
@@ -61,8 +78,7 @@ describe('ClothIcmSolver', () => {
}); });
it('keeps the dropped cloth finite and above the ground', () => { it('keeps the dropped cloth finite and above the ground', () => {
const {gridSize, spacing, thickness, proximityRedetectEvery} = SCENE_SETTINGS.drop; const solver = dropSolver();
const solver = new ClothIcmSolver(config(gridSize, thickness, proximityRedetectEvery, spacing), 'drop');
runFrames(solver, 300, 0.25, DT); runFrames(solver, 300, 0.25, DT);
const groundY = solver.scene.groundY ?? -Infinity; const groundY = solver.scene.groundY ?? -Infinity;
for (let vertex = 0; vertex < solver.topology.vertexCount; vertex++) { for (let vertex = 0; vertex < solver.topology.vertexCount; vertex++) {
@@ -74,27 +90,58 @@ describe('ClothIcmSolver', () => {
}); });
describe('drop scene after landing', () => { describe('drop scene after landing', () => {
const {proximityRedetectEvery} = SCENE_SETTINGS.drop; const icmOn = dropAfterLanding(true);
const icmOn = dropHitsAfterLanding(true, proximityRedetectEvery);
it('keeps the dropped page grid almost free of intersections', () => { it('keeps the dropped page grid almost free of intersections', () => {
expect(icmOn.average).toBeLessThanOrEqual(5); expect(icmOn.average).toBeLessThanOrEqual(5);
expect(icmOn.zeroShare).toBeGreaterThanOrEqual(0.5); expect(icmOn.zeroShare).toBeGreaterThanOrEqual(0.3);
}); });
it('shows the ICM effect', () => { it('shows the ICM effect', () => {
expect(dropHitsAfterLanding(false, proximityRedetectEvery).average).toBeGreaterThanOrEqual(3 * icmOn.average); expect(dropAfterLanding(false).average).toBeGreaterThanOrEqual(3 * icmOn.average);
}); });
it('re-detects proximity inside the frame', () => { it('lets the dropped cloth come to rest', () => {
expect(proximityRedetectEvery).toBeGreaterThan(0); expect(icmOn.restMaxSpeed).toBeLessThan(0.4);
expect(dropHitsAfterLanding(true, 0).average).toBeGreaterThanOrEqual(3 * icmOn.average);
}); });
}); });
it('keeps the BVH culled flags of the frame-start detection', () => { it('keeps the free fall unchanged by the damping', () => {
const {gridSize, spacing, thickness, proximityRedetectEvery} = SCENE_SETTINGS.drop; const {gridSize, spacing, thickness, proximityRedetectEvery} = SCENE_SETTINGS.drop;
const solver = new ClothIcmSolver(config(gridSize, thickness, proximityRedetectEvery, spacing), 'drop'); // The spin stretches the edges, which the smoothing answers; a pure free fall isolates the damping math.
const scene = new ClothIcmSolver(config(2), 'drop').scene;
const spin = scene.spinY;
scene.spinY = 0;
try {
const damped = dropSolver();
const plain = new ClothIcmSolver(config(gridSize, thickness, proximityRedetectEvery, spacing), 'drop');
runFrames(damped, 15, 0.25, DT);
runFrames(plain, 15, 0.25, DT);
let largestDifference = 0;
for (let index = 0; index < damped.positions.length; index++) {
largestDifference = Math.max(largestDifference, Math.abs(damped.positions[index] - plain.positions[index]));
}
expect(largestDifference).toBeLessThan(1e-4);
} finally {
scene.spinY = spin;
}
});
it('spins the dropped cloth around the vertical axis and leaves the tangled scene at rest', () => {
const solver = dropSolver();
const start = solver.positions.slice();
runFrames(solver, 5, 0.25, DT);
let turned = 0;
for (let vertex = 0; vertex < solver.topology.vertexCount; vertex++) {
const base = vertex * 4;
turned += start[base + 2] * (solver.positions[base] - start[base]) - start[base] * (solver.positions[base + 2] - start[base + 2]);
}
expect(turned).toBeGreaterThan(0);
expect(new ClothIcmSolver(config(30), 'tangled').scene.spinY).toBe(0);
});
it('keeps the BVH culled flags of the frame-start detection', () => {
const solver = dropSolver();
let framesWithMismatch = 0; let framesWithMismatch = 0;
for (let frame = 0; frame < 60; frame++) { for (let frame = 0; frame < 60; frame++) {
solver.step({dt: DT, icmEnabled: true, icmStep: 0.25}); solver.step({dt: DT, icmEnabled: true, icmStep: 0.25});
@@ -2,7 +2,7 @@ import {ClothPhysicsParams, solveConstraints, updateVelocities} from '../cloth/s
import { import {
CONTACT_FRICTION, ClothIcmConfig, ClothIcmSceneId, ClothIcmStats, DISTANCE_COMPLIANCE, emptyClothIcmStats, ICM_G0, ICM_SUBSTEPS, MAX_DT, MAX_SPEED, SIM_DT, VELOCITY_DAMPING CONTACT_FRICTION, ClothIcmConfig, ClothIcmSceneId, ClothIcmStats, DISTANCE_COMPLIANCE, emptyClothIcmStats, ICM_G0, ICM_SUBSTEPS, MAX_DT, MAX_SPEED, SIM_DT, VELOCITY_DAMPING
} from './cloth-icm.model'; } from './cloth-icm.model';
import {applyContactFriction, ClothIcmScene, initialPositions, projectObstacles, sceneOf} from './cloth-icm-scenes'; import {applyContactFriction, ClothIcmScene, initialPositions, initialVelocities, projectObstacles, sceneOf} from './cloth-icm-scenes';
import {ClothBvh} from './collision/cloth-bvh'; import {ClothBvh} from './collision/cloth-bvh';
import {buildClothTopology, buildDistanceConstraints, ClothTopology} from './collision/cloth-topology'; import {buildClothTopology, buildDistanceConstraints, ClothTopology} from './collision/cloth-topology';
import {detectCollisions} from './collision/collision-detection'; import {detectCollisions} from './collision/collision-detection';
@@ -41,6 +41,13 @@ export class ClothIcmSolver {
private readonly icmVertices: Int32Array; private readonly icmVertices: Int32Array;
private icmVertexCount = 0; private icmVertexCount = 0;
private readonly proximitySnapshot: Float32Array; private readonly proximitySnapshot: Float32Array;
/** CSR list of the distance-constraint neighbours of each vertex. */
private readonly neighborOffsets: Int32Array;
private readonly neighbors: Int32Array;
private readonly smoothedVelocities: Float32Array;
private readonly positionsBeforeProximity: Float32Array;
/** Sum of the proximity position changes of the frame per vertex. */
private readonly proximityPush: Float32Array;
private accumulator = 0; private accumulator = 0;
private readonly proximityCandidates: number[] = []; private readonly proximityCandidates: number[] = [];
private proximityConstraints: Float32Array = new Float32Array(0); private proximityConstraints: Float32Array = new Float32Array(0);
@@ -56,12 +63,17 @@ export class ClothIcmSolver {
const vertexCount = this.topology.vertexCount; const vertexCount = this.topology.vertexCount;
this.positions = initialPositions(sceneId, this.topology, config.spacing, config.particleInvMass); this.positions = initialPositions(sceneId, this.topology, config.spacing, config.particleInvMass);
this.prevPositions = this.positions.slice(); this.prevPositions = this.positions.slice();
this.velocities = new Float32Array(vertexCount * 4); this.velocities = initialVelocities(this.positions, this.scene.spinY);
this.distanceConstraints = buildDistanceConstraints(this.topology, config.spacing); this.distanceConstraints = buildDistanceConstraints(this.topology, config.spacing);
this.icmDelta = new Float32Array(vertexCount * 3); this.icmDelta = new Float32Array(vertexCount * 3);
this.icmCount = new Float32Array(vertexCount); this.icmCount = new Float32Array(vertexCount);
this.icmVertices = new Int32Array(vertexCount); this.icmVertices = new Int32Array(vertexCount);
this.proximitySnapshot = new Float32Array(vertexCount * 3); this.proximitySnapshot = new Float32Array(vertexCount * 3);
this.smoothedVelocities = new Float32Array(vertexCount * 4);
this.positionsBeforeProximity = new Float32Array(vertexCount * 4);
this.proximityPush = new Float32Array(vertexCount * 3);
this.neighborOffsets = new Int32Array(vertexCount + 1);
this.neighbors = this.buildNeighbors(vertexCount);
this.physics = { this.physics = {
dt: 0, gravityY: this.scene.gravityY, compliance: DISTANCE_COMPLIANCE, numVertices: vertexCount, dt: 0, gravityY: this.scene.gravityY, compliance: DISTANCE_COMPLIANCE, numVertices: vertexCount,
windX: 0, windY: 0, windZ: 0, time: 0, elongation: 1 windX: 0, windY: 0, windZ: 0, time: 0, elongation: 1
@@ -87,6 +99,7 @@ export class ClothIcmSolver {
const collisionStart = performance.now(); const collisionStart = performance.now();
const counters = this.detect(this.bvh, this.intersections); const counters = this.detect(this.bvh, this.intersections);
this.contours = traceContours(this.intersections); this.contours = traceContours(this.intersections);
this.proximityPush.fill(0);
this.prepareIcm(params); this.prepareIcm(params);
this.buildProximity(); this.buildProximity();
const detectMs = performance.now() - collisionStart; const detectMs = performance.now() - collisionStart;
@@ -95,6 +108,8 @@ export class ClothIcmSolver {
const collisionMs = detectMs + redetectMs; const collisionMs = detectMs + redetectMs;
applyContactFriction(this.positions, this.prevPositions, this.scene, this.config.thickness, CONTACT_FRICTION); applyContactFriction(this.positions, this.prevPositions, this.scene, this.config.thickness, CONTACT_FRICTION);
updateVelocities(this.positions, this.prevPositions, this.velocities, this.physics); updateVelocities(this.positions, this.prevPositions, this.velocities, this.physics);
this.clampProximityVelocity();
this.smoothVelocities();
this.limitSpeed(); this.limitSpeed();
this.stats = { this.stats = {
@@ -128,6 +143,91 @@ export class ClothIcmSolver {
} }
} }
private buildNeighbors(vertexCount: number): Int32Array {
const {distanceConstraints, neighborOffsets} = this;
const constraintCount = distanceConstraints.length / 4;
for (let index = 0; index < constraintCount; index++) {
neighborOffsets[distanceConstraints[index * 4] + 1]++;
neighborOffsets[distanceConstraints[index * 4 + 1] + 1]++;
}
for (let vertex = 0; vertex < vertexCount; vertex++) {
neighborOffsets[vertex + 1] += neighborOffsets[vertex];
}
const result = new Int32Array(neighborOffsets[vertexCount]);
const nextSlot = neighborOffsets.slice(0, vertexCount);
for (let index = 0; index < constraintCount; index++) {
const a = distanceConstraints[index * 4];
const b = distanceConstraints[index * 4 + 1];
result[nextSlot[a]++] = b;
result[nextSlot[b]++] = a;
}
return result;
}
/** Removes the velocity part along this frame's proximity push: the push separates the layers, it must not throw the vertex. */
private clampProximityVelocity(): void {
if (!this.config.clampProximityVelocity) {
return;
}
const {velocities, proximityPush} = this;
for (let vertex = 0; vertex < this.physics.numVertices; vertex++) {
const pushX = proximityPush[vertex * 3];
const pushY = proximityPush[vertex * 3 + 1];
const pushZ = proximityPush[vertex * 3 + 2];
const lengthSquared = pushX * pushX + pushY * pushY + pushZ * pushZ;
if (lengthSquared < 1e-14) {
continue;
}
const base = vertex * 4;
const along = (velocities[base] * pushX + velocities[base + 1] * pushY + velocities[base + 2] * pushZ) / lengthSquared;
velocities[base] -= along * pushX;
velocities[base + 1] -= along * pushY;
velocities[base + 2] -= along * pushZ;
}
}
/** One XSPH-style pass: each free vertex moves its velocity toward the mean of its constraint neighbours. A uniform velocity (free fall) stays. */
private smoothVelocities(): void {
const smoothing = this.config.velocitySmoothing;
if (smoothing === 0) {
return;
}
const {velocities, smoothedVelocities, neighborOffsets, neighbors, positions} = this;
smoothedVelocities.set(velocities);
for (let vertex = 0; vertex < this.physics.numVertices; vertex++) {
const base = vertex * 4;
const first = neighborOffsets[vertex];
const count = neighborOffsets[vertex + 1] - first;
if (positions[base + 3] <= 0 || count === 0) {
continue;
}
let sumX = 0;
let sumY = 0;
let sumZ = 0;
for (let index = first; index < first + count; index++) {
const other = neighbors[index] * 4;
sumX += smoothedVelocities[other];
sumY += smoothedVelocities[other + 1];
sumZ += smoothedVelocities[other + 2];
}
velocities[base] += smoothing * (sumX / count - smoothedVelocities[base]);
velocities[base + 1] += smoothing * (sumY / count - smoothedVelocities[base + 1]);
velocities[base + 2] += smoothing * (sumZ / count - smoothedVelocities[base + 2]);
}
}
/** Fastest free vertex in m/s, for the specs. */
maxSpeed(): number {
let fastest = 0;
for (let vertex = 0; vertex < this.physics.numVertices; vertex++) {
const base = vertex * 4;
if (this.positions[base + 3] > 0) {
fastest = Math.max(fastest, Math.hypot(this.velocities[base], this.velocities[base + 1], this.velocities[base + 2]));
}
}
return fastest;
}
private limitSpeed(): void { private limitSpeed(): void {
const {velocities} = this; const {velocities} = this;
for (let vertex = 0; vertex < this.physics.numVertices; vertex++) { for (let vertex = 0; vertex < this.physics.numVertices; vertex++) {
@@ -201,6 +301,10 @@ export class ClothIcmSolver {
*/ */
private solveProximityBesideIcm(): void { private solveProximityBesideIcm(): void {
const {positions, icmVertices, proximitySnapshot, icmDelta} = this; const {positions, icmVertices, proximitySnapshot, icmDelta} = this;
const clamp = this.config.clampProximityVelocity;
if (clamp) {
this.positionsBeforeProximity.set(positions);
}
for (let index = 0; index < this.icmVertexCount; index++) { for (let index = 0; index < this.icmVertexCount; index++) {
const vertex = icmVertices[index]; const vertex = icmVertices[index];
proximitySnapshot[index * 3] = positions[vertex * 4]; proximitySnapshot[index * 3] = positions[vertex * 4];
@@ -220,5 +324,17 @@ export class ClothIcmSolver {
positions[vertex * 4 + 2] = proximitySnapshot[index * 3 + 2]; positions[vertex * 4 + 2] = proximitySnapshot[index * 3 + 2];
} }
} }
if (clamp) {
this.addProximityPush();
}
}
private addProximityPush(): void {
const {positions, positionsBeforeProximity, proximityPush} = this;
for (let vertex = 0; vertex < this.physics.numVertices; vertex++) {
for (let axis = 0; axis < 3; axis++) {
proximityPush[vertex * 3 + axis] += positions[vertex * 4 + axis] - positionsBeforeProximity[vertex * 4 + axis];
}
}
} }
} }
@@ -191,14 +191,16 @@ export class ClothIcmComponent implements OnDestroy {
} }
private clothConfig(): ClothIcmConfig { private clothConfig(): ClothIcmConfig {
const {gridSize, spacing, thickness, proximityRedetectEvery} = SCENE_SETTINGS[this.sceneId()]; const {gridSize, spacing, thickness, proximityRedetectEvery, velocitySmoothing, clampProximityVelocity} = SCENE_SETTINGS[this.sceneId()];
return { return {
gridWidth: gridSize, gridWidth: gridSize,
gridHeight: gridSize, gridHeight: gridSize,
spacing, spacing,
particleInvMass: 1 / (spacing * spacing), particleInvMass: 1 / (spacing * spacing),
thickness, thickness,
proximityRedetectEvery proximityRedetectEvery,
velocitySmoothing,
clampProximityVelocity
}; };
} }
} }
@@ -7,6 +7,10 @@ export interface ClothIcmConfig {
thickness: number; thickness: number;
/** Re-detect proximity pairs every this many substeps, 0 = only at frame start. */ /** Re-detect proximity pairs every this many substeps, 0 = only at frame start. */
proximityRedetectEvery: number; proximityRedetectEvery: number;
/** Share of the difference to the neighbour average removed from each velocity per frame, 0 = off. */
velocitySmoothing: number;
/** Drop the velocity along the proximity push of the frame: the push keeps the layers apart but gives no momentum. */
clampProximityVelocity: boolean;
} }
export interface ClothIcmStats { export interface ClothIcmStats {
@@ -42,13 +46,16 @@ export interface SceneSettings {
spacing: number; spacing: number;
thickness: number; thickness: number;
proximityRedetectEvery: number; proximityRedetectEvery: number;
velocitySmoothing: number;
clampProximityVelocity: boolean;
} }
/** /**
* Drop: a 35x35 cloth of the same size (wider spacing), gap 3.5x the tangled gap and one mid-frame re-detection: * Drop: a 40x40 cloth (5.7 m, wider spacing), gap twice the tangled gap and one mid-frame re-detection, starts with a spin (scene `spinY`):
* ICM on ends with ~0 hits after landing, ICM off stays bounded. A larger gap or a finer grid makes ICM off blow up. * ICM on ends with ~1 hit per frame after landing, ICM off ~10 and bounded. A larger gap or a finer grid makes ICM off blow up.
* Drop: velocity smoothing and the proximity velocity clamp stop the fold chatter on the ground; the tangled scene needs the ICM momentum and keeps both off.
*/ */
export const SCENE_SETTINGS: Record<ClothIcmSceneId, SceneSettings> = { export const SCENE_SETTINGS: Record<ClothIcmSceneId, SceneSettings> = {
tangled: {gridSize: 50, spacing: 0.1, thickness: 0.01, proximityRedetectEvery: 0}, tangled: {gridSize: 50, spacing: 0.1, thickness: 0.01, proximityRedetectEvery: 0, velocitySmoothing: 0, clampProximityVelocity: false},
drop: {gridSize: 35, spacing: 0.145, thickness: 0.035, proximityRedetectEvery: 5} drop: {gridSize: 40, spacing: 0.145, thickness: 0.02, proximityRedetectEvery: 5, velocitySmoothing: 1, clampProximityVelocity: true}
}; };
+2 -2
View File
@@ -741,12 +741,12 @@
"DISCLAIMER_3": "Kontaktreibung (Kugel und Boden) und ein Geschwindigkeitslimit halten den Stoff ruhig. Ohne sie rutscht der Stoff zu einem Haufen zusammen, den die Erkennung nicht mehr sortieren kann." "DISCLAIMER_3": "Kontaktreibung (Kugel und Boden) und ein Geschwindigkeitslimit halten den Stoff ruhig. Ohne sie rutscht der Stoff zu einem Haufen zusammen, den die Erkennung nicht mehr sortieren kann."
}, },
"HOW_IT_WORKS": { "HOW_IT_WORKS": {
"INTRO": "Der Stoff ist ein Gitter aus Partikeln (CPU, XPBD, 10 Solver-Iterationen pro Frame): 50 × 50 = 2.500 im verheddigten Start, 35 × 35 = 1.225 beim fallenden Stoff. Pro Frame: Positionen vorhersagen, BVH anpassen, alle Schnitte finden, Konturen verfolgen, ICM-Verschiebung berechnen, dann die Solver-Iterationen laufen lassen und die Verschiebung in kleinen Teilen anwenden. Beim fallenden Stoff ist der Abstand zwischen den Lagen 3,5-mal größer (0,035 statt 0,01) und die Partikel haben mehr Abstand (0,145 statt 0,1), damit der Stoff gleich groß bleibt. Die nahen Paare werden mitten in den Iterationen noch einmal gesucht (nach 5 von 10), weil sich der fallende Stoff in einem Frame stark bewegt.", "INTRO": "Der Stoff ist ein Gitter aus Partikeln (CPU, XPBD, 10 Solver-Iterationen pro Frame): 50 × 50 = 2.500 im verheddigten Start, 40 × 40 = 1.600 beim fallenden Stoff. Pro Frame: Positionen vorhersagen, BVH anpassen, alle Schnitte finden, Konturen verfolgen, ICM-Verschiebung berechnen, dann die Solver-Iterationen laufen lassen und die Verschiebung in kleinen Teilen anwenden. Beim fallenden Stoff ist der Abstand zwischen den Lagen doppelt so groß (0,02 statt 0,01) und die Partikel haben mehr Abstand (0,145 statt 0,1), damit der fallende Stoff 5,7 m breit ist. Er startet mit einer kleinen Drehung um die senkrechte Achse, damit sich mehr Falten kreuzen. Die nahen Paare werden mitten in den Iterationen noch einmal gesucht (nach 5 von 10), weil sich der fallende Stoff in einem Frame stark bewegt. Nach dem Solver wird jede Geschwindigkeit durch den Mittelwert ihrer Nachbarn ersetzt und der Impuls des Näherungs-Schubs verworfen, damit Falten, die auf dem Boden liegen, nicht mehr zittern.",
"ITEM_1": "BVH: ein Blatt pro Gitterquadrat (2 Dreiecke). Jeder Knoten speichert ein 18-DOP und einen Normalenkegel. Der Baum behält seine Form und wird jeden Frame von unten nach oben angepasst. Knotenpaare mit überlappenden Volumen werden bis zu Blattpaaren abgelaufen.", "ITEM_1": "BVH: ein Blatt pro Gitterquadrat (2 Dreiecke). Jeder Knoten speichert ein 18-DOP und einen Normalenkegel. Der Baum behält seine Form und wird jeden Frame von unten nach oben angepasst. Knotenpaare mit überlappenden Volumen werden bis zu Blattpaaren abgelaufen.",
"ITEM_2": "Narrow Phase: Die 3 Kanten jedes Dreiecks werden gegen das andere Dreieck getestet (Möller-Trumbore). Zwei Treffer eines Dreieckspaars bilden ein Segment der Schnittkontur. Dreiecke mit gemeinsamem Eckpunkt werden übersprungen.", "ITEM_2": "Narrow Phase: Die 3 Kanten jedes Dreiecks werden gegen das andere Dreieck getestet (Möller-Trumbore). Zwei Treffer eines Dreieckspaars bilden ein Segment der Schnittkontur. Dreiecke mit gemeinsamem Eckpunkt werden übersprungen.",
"ITEM_3": "Konturen: Die Segmente werden zu geschlossenen Schleifen oder zu offenen Pfaden verbunden, die am Stoffrand enden. Die roten Linien zeigen sie.", "ITEM_3": "Konturen: Die Segmente werden zu geschlossenen Schleifen oder zu offenen Pfaden verbunden, die am Stoffrand enden. Die roten Linien zeigen sie.",
"ITEM_4": "ICM: Pro Kontur bekommt jedes Segmentende einen Gradienten (die Richtung, in der sich seine Kante relativ zum Dreieck bewegen muss, damit die Kontur kürzer wird). Die Gradienten werden mit Vorzeichen für die beiden Stoffseiten summiert und zu H = h0 · D / sqrt(|D|² + g0²) skaliert, mit h0 = ICM-Schritt · Abstand und g0 = 4. Die Kantenseite bewegt sich um +H, die Dreiecksseite um −H. Die Verschiebung wird nach inverser Masse und Abstand zum Trefferpunkt auf die Partikel verteilt und pro Partikel gemittelt.", "ITEM_4": "ICM: Pro Kontur bekommt jedes Segmentende einen Gradienten (die Richtung, in der sich seine Kante relativ zum Dreieck bewegen muss, damit die Kontur kürzer wird). Die Gradienten werden mit Vorzeichen für die beiden Stoffseiten summiert und zu H = h0 · D / sqrt(|D|² + g0²) skaliert, mit h0 = ICM-Schritt · Abstand und g0 = 4. Die Kantenseite bewegt sich um +H, die Dreiecksseite um −H. Die Verschiebung wird nach inverser Masse und Abstand zum Trefferpunkt auf die Partikel verteilt und pro Partikel gemittelt.",
"ITEM_5": "Verheddigter Start: keine Schwerkraft, die obere Reihe ist fixiert, die untere Hälfte ist zurückgefaltet und ein Wulst quer darüber drückt durch die vordere Hälfte. Fallender Stoff: Der Stoff fällt auf eine kleine Kugel auf dem Boden. Beide Szenen nutzen denselben Solver. Schalte ICM aus, um den Unterschied zu sehen: beim fallenden Stoff bleiben dann die Schnitte (etwa hundert statt fast keine)." "ITEM_5": "Verheddigter Start: keine Schwerkraft, die obere Reihe ist fixiert, die untere Hälfte ist zurückgefaltet und ein Wulst quer darüber drückt durch die vordere Hälfte. Fallender Stoff: Der Stoff fällt auf eine kleine Kugel auf dem Boden. Beide Szenen nutzen denselben Solver. Schalte ICM aus, um den Unterschied zu sehen: beim fallenden Stoff bleiben dann die Schnitte (etwa zehn pro Frame statt etwa eine)."
} }
}, },
"BOIDS": { "BOIDS": {
+2 -2
View File
@@ -741,12 +741,12 @@
"DISCLAIMER_3": "Contact friction (sphere and ground) and a speed limit keep the cloth calm. Without them the cloth slides into a pile that the detection can no longer sort out." "DISCLAIMER_3": "Contact friction (sphere and ground) and a speed limit keep the cloth calm. Without them the cloth slides into a pile that the detection can no longer sort out."
}, },
"HOW_IT_WORKS": { "HOW_IT_WORKS": {
"INTRO": "The cloth is a grid of particles (CPU, XPBD, 10 solver iterations per frame): 50 × 50 = 2,500 in the tangled start, 35 × 35 = 1,225 in the dropped cloth. Per frame: predict positions, refit the BVH, find all intersections, trace the contours, compute the ICM displacement, then run the solver iterations and apply the displacement in small parts. In the dropped cloth the gap between the layers is 3.5 times larger (0.035 instead of 0.01) and the particles are spaced wider (0.145 instead of 0.1) so the cloth keeps its size. The close pairs are found again in the middle of the iterations (after 5 of 10), because the falling cloth moves a lot within one frame.", "INTRO": "The cloth is a grid of particles (CPU, XPBD, 10 solver iterations per frame): 50 × 50 = 2,500 in the tangled start, 40 × 40 = 1,600 in the dropped cloth. Per frame: predict positions, refit the BVH, find all intersections, trace the contours, compute the ICM displacement, then run the solver iterations and apply the displacement in small parts. In the dropped cloth the gap between the layers is twice as large (0.02 instead of 0.01) and the particles are spaced wider (0.145 instead of 0.1), so the dropped cloth is 5.7 m wide. It starts with a small spin around the vertical axis, so that more folds cross each other. The close pairs are found again in the middle of the iterations (after 5 of 10), because the falling cloth moves a lot within one frame. After the solver, each velocity is replaced by the mean of its neighbours and the momentum of the proximity push is dropped, so folds that rest on the ground stop chattering.",
"ITEM_1": "BVH: one leaf per grid quad (2 triangles). Every node stores an 18-DOP and a normal cone. The tree keeps its shape and is refit bottom-up every frame. Node pairs with overlapping volumes are walked down to leaf pairs.", "ITEM_1": "BVH: one leaf per grid quad (2 triangles). Every node stores an 18-DOP and a normal cone. The tree keeps its shape and is refit bottom-up every frame. Node pairs with overlapping volumes are walked down to leaf pairs.",
"ITEM_2": "Narrow phase: the 3 edges of each triangle are tested against the other triangle (Möller-Trumbore). Two hits of one triangle pair form one segment of the intersection contour. Triangles that share a vertex are skipped.", "ITEM_2": "Narrow phase: the 3 edges of each triangle are tested against the other triangle (Möller-Trumbore). Two hits of one triangle pair form one segment of the intersection contour. Triangles that share a vertex are skipped.",
"ITEM_3": "Contours: the segments are joined into closed loops or into open paths that end at the cloth border. The red lines show them.", "ITEM_3": "Contours: the segments are joined into closed loops or into open paths that end at the cloth border. The red lines show them.",
"ITEM_4": "ICM: per contour, every segment end gets a gradient (the direction in which its edge must move relative to the triangle to shorten the contour). The gradients are summed with a sign for the two sides of the cloth and scaled to H = h0 · D / sqrt(|D|² + g0²), with h0 = ICM step · spacing and g0 = 4. The edge side moves by +H, the triangle side by −H. The displacement is spread over the particles by inverse mass and by distance to the hit point, and averaged per particle.", "ITEM_4": "ICM: per contour, every segment end gets a gradient (the direction in which its edge must move relative to the triangle to shorten the contour). The gradients are summed with a sign for the two sides of the cloth and scaled to H = h0 · D / sqrt(|D|² + g0²), with h0 = ICM step · spacing and g0 = 4. The edge side moves by +H, the triangle side by −H. The displacement is spread over the particles by inverse mass and by distance to the hit point, and averaged per particle.",
"ITEM_5": "Tangled start: no gravity, the top row is pinned, the lower half is folded back and a ridge across it pokes through the front half. Dropped cloth: the cloth falls on a small sphere on the ground. Both scenes share the same solver. Switch ICM off to see the difference: in the dropped cloth the intersections then stay (about a hundred instead of almost none)." "ITEM_5": "Tangled start: no gravity, the top row is pinned, the lower half is folded back and a ridge across it pokes through the front half. Dropped cloth: the cloth falls on a small sphere on the ground. Both scenes share the same solver. Switch ICM off to see the difference: in the dropped cloth the intersections then stay (about ten per frame instead of about one)."
} }
}, },
"BOIDS": { "BOIDS": {