fix(cloth-icm): keep drop cloth free of intersections
Build, Test & Push Frontend / quality-check (push) Successful in 2m43s
Build, Test & Push Frontend / docker (push) Successful in 1m5s

The drop scene used the tangled-start settings, so layers stayed
intersecting. It now has its own grid (35x35, gap 0.035) and
re-checks proximity after 5 of 10 substeps.
After landing, ICM on drops from ~260 to ~1 hit per frame.
This commit is contained in:
lobo committed 2026-10-30 15:41:12 +01:00
1 parent ba82df7694
commit 9c55298ab7
7 files changed
+127 -31

No files matched your search

+1 -1
View File
@@ -19,7 +19,7 @@ Open ideas for the playground frontend. Tick a box when done.
## Features
- [ ] **Cloth ICM drop scene**: with ICM on, ~260 intersections stay (ICM off: ~1000) and the cloth never settles. Causes: discrete detection (cloth moves 5-7x thickness per frame), gravity and ground fold new layers faster than ICM resolves, open contours end at vertex-sharing pairs the narrow phase skips. Ideas: continuous collision detection, detection per substep, handle vertex-sharing pairs.
- [ ] **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.
- [ ] **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):
- [x] Boids / flocking (GPU path like cloth)
@@ -1,15 +1,35 @@
import {ClothIcmConfig, ClothIcmSceneId} from './cloth-icm.model';
import {ClothIcmConfig, ClothIcmSceneId, SCENE_SETTINGS} from './cloth-icm.model';
import {ClothIcmSolver} from './cloth-icm-solver';
const DT = 1 / 60;
const SPACING = 0.1;
const TANGLED_SPACING = 0.1;
function config(gridSize: number): ClothIcmConfig {
return {gridWidth: gridSize, gridHeight: gridSize, spacing: SPACING, particleInvMass: 1 / (SPACING * SPACING), thickness: 0.01};
function config(gridSize: number, thickness = 0.01, proximityRedetectEvery = 0, spacing = TANGLED_SPACING): ClothIcmConfig {
return {gridWidth: gridSize, gridHeight: gridSize, spacing, particleInvMass: 1 / (spacing * spacing), thickness, proximityRedetectEvery};
}
function run(sceneId: ClothIcmSceneId, gridSize: number, frames: number, icmEnabled: boolean): {solver: ClothIcmSolver; firstContours: number} {
const solver = new ClothIcmSolver(config(gridSize), sceneId);
/** Mean hits per frame over frames 100..240 and the share of those frames with 0 hits, drop scene with the page settings. */
function dropHitsAfterLanding(icmEnabled: boolean, proximityRedetectEvery: number): {average: number; zeroShare: number} {
const {gridSize, spacing, thickness} = SCENE_SETTINGS.drop;
const solver = new ClothIcmSolver(config(gridSize, thickness, proximityRedetectEvery, spacing), 'drop');
const first = 100;
const last = 240;
let sum = 0;
let zeroFrames = 0;
for (let frame = 1; frame <= last; frame++) {
solver.step({dt: DT, icmEnabled, icmStep: 0.25});
if (frame > first) {
sum += solver.stats.intersections;
zeroFrames += solver.stats.intersections === 0 ? 1 : 0;
}
}
return {average: sum / (last - first), zeroShare: zeroFrames / (last - first)};
}
function run(
sceneId: ClothIcmSceneId, gridSize: number, frames: number, icmEnabled: boolean, thickness?: number, proximityRedetectEvery?: number
): {solver: ClothIcmSolver; firstContours: number} {
const solver = new ClothIcmSolver(config(gridSize, thickness, proximityRedetectEvery), sceneId);
let firstContours = 0;
for (let frame = 0; frame < frames; frame++) {
solver.step({dt: DT, icmEnabled, icmStep: 0.25});
@@ -41,7 +61,9 @@ describe('ClothIcmSolver', () => {
});
it('keeps the dropped cloth finite and above the ground', () => {
const {solver} = run('drop', 20, 300, true);
const {gridSize, spacing, thickness, proximityRedetectEvery} = SCENE_SETTINGS.drop;
const solver = new ClothIcmSolver(config(gridSize, thickness, proximityRedetectEvery, spacing), 'drop');
runFrames(solver, 300, 0.25, DT);
const groundY = solver.scene.groundY ?? -Infinity;
for (let vertex = 0; vertex < solver.topology.vertexCount; vertex++) {
const y = solver.positions[vertex * 4 + 1];
@@ -51,6 +73,39 @@ describe('ClothIcmSolver', () => {
expect(solver.positions.every(Number.isFinite)).toBe(true);
});
describe('drop scene after landing', () => {
const {proximityRedetectEvery} = SCENE_SETTINGS.drop;
const icmOn = dropHitsAfterLanding(true, proximityRedetectEvery);
it('keeps the dropped page grid almost free of intersections', () => {
expect(icmOn.average).toBeLessThanOrEqual(5);
expect(icmOn.zeroShare).toBeGreaterThanOrEqual(0.5);
});
it('shows the ICM effect', () => {
expect(dropHitsAfterLanding(false, proximityRedetectEvery).average).toBeGreaterThanOrEqual(3 * icmOn.average);
});
it('re-detects proximity inside the frame', () => {
expect(proximityRedetectEvery).toBeGreaterThan(0);
expect(dropHitsAfterLanding(true, 0).average).toBeGreaterThanOrEqual(3 * icmOn.average);
});
});
it('keeps the BVH culled flags of the frame-start detection', () => {
const {gridSize, spacing, thickness, proximityRedetectEvery} = SCENE_SETTINGS.drop;
const solver = new ClothIcmSolver(config(gridSize, thickness, proximityRedetectEvery, spacing), 'drop');
let framesWithMismatch = 0;
for (let frame = 0; frame < 60; frame++) {
solver.step({dt: DT, icmEnabled: true, icmStep: 0.25});
const flaggedNodes = solver.bvh.culled.reduce((sum, flag) => sum + flag, 0);
if (flaggedNodes !== solver.stats.culledNodes) {
framesWithMismatch++;
}
}
expect(framesWithMismatch).toBe(0);
});
it('reports collision stats', () => {
const {solver} = run('tangled', 30, 1, true);
expect(solver.stats.intersections).toBeGreaterThan(0);
@@ -23,6 +23,7 @@ export interface ClothIcmFrameParams {
/** CPU cloth with self-collision handling. Pure TypeScript, no Babylon: one `step()` per frame. */
export class ClothIcmSolver {
readonly topology: ClothTopology;
/** Frame-start detection; the renderer reads its culled flags and bounds. */
readonly bvh: ClothBvh;
readonly scene: ClothIcmScene;
/** Vec4 layout [x, y, z, invMass]. */
@@ -43,11 +44,14 @@ export class ClothIcmSolver {
private accumulator = 0;
private readonly proximityCandidates: number[] = [];
private proximityConstraints: Float32Array = new Float32Array(0);
private readonly midFrameIntersections = new IntersectionSet();
private readonly midFrameBvh: ClothBvh;
private readonly physics: ClothPhysicsParams;
constructor(private readonly config: ClothIcmConfig, sceneId: ClothIcmSceneId) {
this.topology = buildClothTopology(config.gridWidth, config.gridHeight);
this.bvh = new ClothBvh(this.topology);
this.midFrameBvh = new ClothBvh(this.topology);
this.scene = sceneOf(sceneId);
const vertexCount = this.topology.vertexCount;
this.positions = initialPositions(sceneId, this.topology, config.spacing, config.particleInvMass);
@@ -81,13 +85,14 @@ export class ClothIcmSolver {
this.predictPositions();
const collisionStart = performance.now();
const counters = this.detect();
const counters = this.detect(this.bvh, this.intersections);
this.contours = traceContours(this.intersections);
this.prepareIcm(params);
this.proximityConstraints = buildProximityConstraints(this.positions, this.topology, this.proximityCandidates, this.config.thickness);
const collisionMs = performance.now() - collisionStart;
this.buildProximity();
const detectMs = performance.now() - collisionStart;
this.solve();
const redetectMs = this.solve();
const collisionMs = detectMs + redetectMs;
applyContactFriction(this.positions, this.prevPositions, this.scene, this.config.thickness, CONTACT_FRICTION);
updateVelocities(this.positions, this.prevPositions, this.velocities, this.physics);
this.limitSpeed();
@@ -137,9 +142,22 @@ export class ClothIcmSolver {
}
}
private detect(): {testedPairs: number; culledNodes: number} {
this.bvh.refit(this.positions);
return detectCollisions(this.bvh, this.positions, this.topology, this.config.thickness, this.intersections, this.proximityCandidates);
private detect(bvh: ClothBvh, intersections: IntersectionSet): {testedPairs: number; culledNodes: number} {
bvh.refit(this.positions);
return detectCollisions(bvh, this.positions, this.topology, this.config.thickness, intersections, this.proximityCandidates);
}
private buildProximity(): void {
this.proximityConstraints = buildProximityConstraints(this.positions, this.topology, this.proximityCandidates, this.config.thickness);
}
/** Only the proximity constraints follow the current positions; the frame-start hits and the ICM displacement stay. */
private redetectProximity(): number {
const start = performance.now();
this.midFrameBvh.coneCullingEnabled = this.bvh.coneCullingEnabled;
this.detect(this.midFrameBvh, this.midFrameIntersections);
this.buildProximity();
return performance.now() - start;
}
private prepareIcm(params: ClothIcmFrameParams): void {
@@ -160,15 +178,21 @@ export class ClothIcmSolver {
}
}
/** The ICM displacement is spread over the iterations so that the distance constraints can react to it. */
private solve(): void {
/** The ICM displacement is spread over the iterations so that the distance constraints can react to it. Returns the re-detection time. */
private solve(): number {
const icmScale = 1 / ICM_SUBSTEPS;
const every = this.config.proximityRedetectEvery;
let redetectMs = 0;
for (let iteration = 0; iteration < ICM_SUBSTEPS; iteration++) {
if (every > 0 && iteration > 0 && iteration % every === 0) {
redetectMs += this.redetectProximity();
}
solveConstraints(this.positions, this.distanceConstraints, this.physics);
this.solveProximityBesideIcm();
applyAveragedDisplacement(this.positions, this.icmDelta, this.icmCount, icmScale);
projectObstacles(this.positions, this.scene, this.config.thickness);
}
return redetectMs;
}
/**
@@ -12,13 +12,10 @@ import {FpsCounter} from '../../../shared/fps-counter';
import {Information} from '../information/information';
import {AlgorithmInformation} from '../information/information.models';
import {SOURCE_PATHS} from '../information/source-paths';
import {ClothIcmConfig, ClothIcmSceneId, ClothIcmStats, emptyClothIcmStats} from './cloth-icm.model';
import {ClothIcmConfig, ClothIcmSceneId, ClothIcmStats, emptyClothIcmStats, SCENE_SETTINGS} from './cloth-icm.model';
import {ClothIcmRenderer} from './cloth-icm-renderer';
import {ClothIcmSolver} from './cloth-icm-solver';
const GRID_SIZE = 50;
const SPACING = 0.1;
const THICKNESS = 0.01;
const DEFAULT_ICM_STEP = 0.25;
const MAX_BVH_DEPTH = 10;
const STATS_EVERY_FRAMES = 10;
@@ -60,7 +57,7 @@ export class ClothIcmComponent implements OnDestroy {
private readonly stats = signal<ClothIcmStats>(emptyClothIcmStats());
readonly metrics = computed<Metric[]>(() => [
{label: 'METRICS.FPS', value: this.fpsCounter.fps()},
{label: 'METRICS.PARTICLES', value: GRID_SIZE * GRID_SIZE},
{label: 'METRICS.PARTICLES', value: SCENE_SETTINGS[this.sceneId()].gridSize ** 2},
{label: 'METRICS.INTERSECTIONS', value: this.stats().intersections},
{label: 'METRICS.CONTOURS', value: this.stats().contours},
{label: 'METRICS.TESTED_PAIRS', value: this.stats().testedPairs},
@@ -194,12 +191,14 @@ export class ClothIcmComponent implements OnDestroy {
}
private clothConfig(): ClothIcmConfig {
const {gridSize, spacing, thickness, proximityRedetectEvery} = SCENE_SETTINGS[this.sceneId()];
return {
gridWidth: GRID_SIZE,
gridHeight: GRID_SIZE,
spacing: SPACING,
particleInvMass: 1 / (SPACING * SPACING),
thickness: THICKNESS
gridWidth: gridSize,
gridHeight: gridSize,
spacing,
particleInvMass: 1 / (spacing * spacing),
thickness,
proximityRedetectEvery
};
}
}
@@ -5,6 +5,8 @@ export interface ClothIcmConfig {
particleInvMass: number;
/** Minimum distance kept by the proximity constraints. */
thickness: number;
/** Re-detect proximity pairs every this many substeps, 0 = only at frame start. */
proximityRedetectEvery: number;
}
export interface ClothIcmStats {
@@ -34,3 +36,19 @@ export const CONTACT_FRICTION = 0.8;
export function emptyClothIcmStats(): ClothIcmStats {
return {intersections: 0, contours: 0, testedPairs: 0, culledNodes: 0, collisionMs: 0};
}
export interface SceneSettings {
gridSize: number;
spacing: number;
thickness: number;
proximityRedetectEvery: number;
}
/**
* Drop: a 35x35 cloth of the same size (wider spacing), gap 3.5x the tangled gap and one mid-frame re-detection:
* ICM on ends with ~0 hits after landing, ICM off stays bounded. A larger gap or a finer grid makes ICM off blow up.
*/
export const SCENE_SETTINGS: Record<ClothIcmSceneId, SceneSettings> = {
tangled: {gridSize: 50, spacing: 0.1, thickness: 0.01, proximityRedetectEvery: 0},
drop: {gridSize: 35, spacing: 0.145, thickness: 0.035, proximityRedetectEvery: 5}
};
+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."
},
"HOW_IT_WORKS": {
"INTRO": "Der Stoff ist ein Gitter aus 50 × 50 = 2.500 Partikeln (CPU, XPBD, 10 Solver-Iterationen pro Frame). 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.",
"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.",
"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_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_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."
"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)."
}
},
"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."
},
"HOW_IT_WORKS": {
"INTRO": "The cloth is a grid of 50 × 50 = 2,500 particles (CPU, XPBD, 10 solver iterations per frame). 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.",
"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.",
"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_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_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."
"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)."
}
},
"BOIDS": {