@yali-engine/physics 0.0.0-stage → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cloth.js ADDED
@@ -0,0 +1,1578 @@
1
+ // Kanduka's GPU cloth runtime (ADR-0101), `@yali-engine/physics/gpu`: the `physics.Cloth` component,
2
+ // the system that turns cloth entities and colliders into GPU records each frame, and the
3
+ // compute work the render plugin records before its frame graph (`render.prepasses`): one
4
+ // dispatch per fixed step for every cloth (a workgroup each, shaders/kanduka/cloth.wesl), then
5
+ // one that writes the interpolated vertices into the renderer's vertex pool. WebGPU core only;
6
+ // elsewhere the cloths draw at rest (a CPU fallback is a later step).
7
+ import { registerTransforms, YaliError } from '@yali-engine/core';
8
+ import { clothTopology, FABRICS, NO_VERTEX, packCloth, splitTorn } from "./cloth-asset.js";
9
+ import { registerPhysics, ShapeKind } from "./components.js";
10
+ import { kandukaCloth } from "./gpu-shaders.gen.js";
11
+ /** Preset fabric ids: 1-based, in `FABRICS` order. */
12
+ const PRESETS = Object.keys(FABRICS);
13
+ /**
14
+ * `physics.Cloth` values (ADR-0101): `asset` from `ClothSystem.addAsset`. Put it on an entity with
15
+ * a `Transform` and a `render.Mesh` (its material and colour are the cloth's; the system sets the
16
+ * mesh). Pinned vertices follow the entity's pose.
17
+ *
18
+ * @example
19
+ * const flagAsset = clothSys.addAsset(clothGrid({ width: 3, height: 2, pin: 'left' }));
20
+ * world.insert(flag, Cloth, cloth(flagAsset, { fabric: 'canvas' }));
21
+ */
22
+ export function cloth(asset, o = {}) {
23
+ const f = o.fabric ?? 'cotton';
24
+ const fabric = typeof f === 'number' ? f : PRESETS.indexOf(f) + 1;
25
+ if (!(fabric > 0))
26
+ throw Object.assign(new YaliError('PHYSICS_INVALID', `No fabric ${JSON.stringify(f)}.`, `Use one of ${PRESETS.join(', ')}, or an id from addFabric.`), { path: 'fabric' });
27
+ if (!(asset > 0))
28
+ throw Object.assign(new YaliError('PHYSICS_INVALID', `Cloth asset ${asset}.`, 'Pass the id ClothSystem.addAsset returned.'), { path: 'asset' });
29
+ const wind = o.wind ?? 1;
30
+ if (!(wind >= 0 && Number.isFinite(wind)))
31
+ throw Object.assign(new YaliError('PHYSICS_INVALID', `Cloth wind ${wind}.`, 'Give a share of the wind ≥ 0 (1: all of it).'), { path: 'wind' });
32
+ const sc = o.selfCollision ?? false;
33
+ if (!(typeof sc === 'boolean' || (sc > 0 && Number.isFinite(sc)))) {
34
+ throw Object.assign(new YaliError('PHYSICS_INVALID', `Cloth selfCollision ${sc}.`, 'Give true, false, or a distance in metres above 0.'), { path: 'selfCollision' });
35
+ }
36
+ const tear = o.tear ?? 0;
37
+ if (!(tear >= 0 && Number.isFinite(tear)))
38
+ throw Object.assign(new YaliError('PHYSICS_INVALID', `Cloth tear ${tear}.`, 'Give the strain at which it tears (0.3: 30% stretch), or 0 for never.'), { path: 'tear' });
39
+ const tt = o.tether ?? false;
40
+ if (!(typeof tt === 'boolean' || (tt >= 0 && Number.isFinite(tt))))
41
+ throw Object.assign(new YaliError('PHYSICS_INVALID', `Cloth tether ${tt}.`, 'Give true, false, or a slack ≥ 0 (0.02: 2% longer than the distance over the mesh).'), { path: 'tether' });
42
+ const backstop = o.backstop ?? 0.01;
43
+ if (!(backstop >= 0 && Number.isFinite(backstop)))
44
+ throw Object.assign(new YaliError('PHYSICS_INVALID', `Cloth backstop ${backstop}.`, 'Give a distance in metres ≥ 0.'), { path: 'backstop' });
45
+ return { asset, fabric, wind, selfCollision: sc === true ? -1 : sc === false ? 0 : sc, tear, tether: tt === true ? 0.02 : tt === false ? 0 : Math.max(tt, 1e-6), body: o.body ?? 0, backstop, handle: 0 };
46
+ }
47
+ // WebGPU flags as numbers: the globals don't exist in Node, which imports this module in tests.
48
+ const STORAGE = 0x80;
49
+ const COPY_DST = 0x8;
50
+ const COPY_SRC = 0x4;
51
+ const UNIFORM = 0x40;
52
+ const MAP_READ = 0x1;
53
+ const QUERY_RESOLVE = 0x200;
54
+ const COMPUTE = 0x4;
55
+ /** Bytes per parameter slot (a dynamic uniform offset), and the slot the vertex write reads. */
56
+ const SLOT = 256;
57
+ const SLOTS = 256;
58
+ const WRITE_SLOT = SLOTS - 1;
59
+ /** Most fixed steps one frame runs (catch-up from the clock is capped; `step(n)` may queue more, up to the slots). */
60
+ const MAX_CLOCK_STEPS = 8;
61
+ const CLOTH_BYTES = 160;
62
+ /** Bytes of one step's parameters (the shader's `Params`). */
63
+ const PARAM_BYTES = 96;
64
+ const COLLIDER_BYTES = 80;
65
+ /** A free vertex gets a backstop when it starts within this distance (m) of the body's skin (ADR-0108). */
66
+ const BACKSTOP_RANGE = 0.06;
67
+ /**
68
+ * The cloth solver (the `cloth` resource): assets, fabrics, the wind, and the GPU work. Every
69
+ * cloth is in one solver group sharing a frame (world axes, metres from the first cloth's
70
+ * origin, f64), so cloths spread over kilometres lose precision; one group per region is later.
71
+ */
72
+ export class ClothSystem {
73
+ Cloth;
74
+ /** The tag the system puts on a cloth's skinned body (ADR-0108), so only bodies are read each frame. */
75
+ ClothBody;
76
+ world;
77
+ /** The scene's wind (live). */
78
+ wind;
79
+ gravity;
80
+ /** Substeps per fixed step (live: 8 is softer and cheaper, 32 stiffer). */
81
+ substeps;
82
+ /** XPBD's bending passes per substep on the GPU (live, ADR-0128). */
83
+ bendIterations;
84
+ /** Substeps per fixed step on the CPU cloth (live). */
85
+ cpuSubsteps;
86
+ /** The most speed a vertex may reach, m/s (live). */
87
+ maxSpeed;
88
+ /** The GPU solver (live: ADR-0101's XPBD or ADR-0105's VBD). */
89
+ solver;
90
+ /**
91
+ * XPBD's wide step (ADR-0110, live, default on): each phase dispatched over the whole GPU for
92
+ * every cloth at once, instead of a cloth's whole step in one workgroup (one multiprocessor).
93
+ */
94
+ wide = true;
95
+ /** VBD's substeps per fixed step and iterations per substep (live). */
96
+ vbdSubsteps;
97
+ vbdIterations;
98
+ /** VBD's hard strain limit on each edge (live; 0: none). */
99
+ strainLimit;
100
+ /** Stops the clock's steps (`step(n)` still runs). */
101
+ paused = false;
102
+ /**
103
+ * Vertex splitting (ADR-0108): every this many frames a tearable cloth's tear flags are read back
104
+ * and its torn edges split into clean cuts (0: only when `flushTears()` is called, for goldens).
105
+ */
106
+ splitEvery = 20;
107
+ #splitWarned = false;
108
+ #render;
109
+ #app;
110
+ #assets = [];
111
+ #fabrics = PRESETS.map((k) => FABRICS[k]);
112
+ #instances = [];
113
+ #words = new Uint32Array(1 << 14);
114
+ #wordCount = 0;
115
+ /** Words the self-colliding cloths' hash tables need. */
116
+ #hashWords = 0;
117
+ /** Words the tearable cloths' triangle lists need. */
118
+ #indexWords = 0;
119
+ #vertices = 0;
120
+ #origin = new Float64Array(3);
121
+ #frame = 0;
122
+ #seenSteps = -1;
123
+ #manual = 0;
124
+ #simTime = 0;
125
+ // Colliders this frame: entity, shape, dims, friction, pose now (f64, group frame).
126
+ #colliderEntities = [];
127
+ #colliderData = new Float32Array(64 * 20);
128
+ #colliderCount = 0;
129
+ #colliderSim = new Map();
130
+ #skipped = 0;
131
+ #warned = false;
132
+ #gpu = null;
133
+ #cpu = null;
134
+ #cpuMs = Number.NaN;
135
+ #trash = [];
136
+ #gpuMs = Number.NaN;
137
+ #query;
138
+ #writeQuery;
139
+ #colliderQuery;
140
+ #bodyQuery;
141
+ /** This frame's bodies: mesh, palette row + 1, pose (t xyz in f64, r xyzw, s). */
142
+ #bodies = new Map();
143
+ /** Bodies a cloth names that are not tagged yet: tagged after this frame's extract. */
144
+ #untagged = new Set();
145
+ /** Anchor records after the colliders this frame. */
146
+ #anchorCount = 0;
147
+ /** Threads per cloth on this device (0 before the GPU state exists). */
148
+ workgroup = 0;
149
+ #workgroupOption;
150
+ /** Called with each GPU time measured (ms), for the profiler. */
151
+ onGpuTime = null;
152
+ constructor(app, render, o = {}) {
153
+ this.#app = app;
154
+ this.world = app.world;
155
+ this.#render = render;
156
+ this.substeps = Math.max(1, Math.round(o.substeps ?? 16));
157
+ this.bendIterations = Math.max(1, Math.round(o.bendIterations ?? 1));
158
+ this.cpuSubsteps = Math.max(1, Math.round(o.cpuSubsteps ?? 8));
159
+ this.#workgroupOption = o.workgroupSize ?? 1024;
160
+ this.maxSpeed = o.maxSpeed ?? 60;
161
+ this.solver = o.solver ?? 'xpbd';
162
+ this.vbdSubsteps = Math.max(1, Math.round(o.vbd?.substeps ?? 2));
163
+ this.vbdIterations = Math.max(1, Math.round(o.vbd?.iterations ?? 6));
164
+ this.strainLimit = o.vbd?.strainLimit ?? 0.1;
165
+ this.gravity = [...(o.gravity ?? [0, -9.81, 0])];
166
+ this.wind = { velocity: [0, 0, 0], turbulence: 0.3, gust: 0.3, gustFrequency: 0.2, airDensity: 1.2, ...o.wind };
167
+ this.Cloth = this.world.registerComponent({ name: 'physics.Cloth', fields: { asset: 'u32', fabric: 'u32', wind: 'f32', selfCollision: 'f32', tear: 'f32', tether: 'f32', body: 'entity', backstop: 'f32', handle: 'u32' } });
168
+ this.ClothBody = this.world.registerComponent({ name: 'physics.ClothBody', fields: {} });
169
+ const { GlobalTransform } = registerTransforms(this.world);
170
+ const { Collider } = registerPhysics(this.world);
171
+ const Mesh = render.Mesh;
172
+ this.#query = this.world.query({
173
+ with: Mesh ? [this.Cloth, GlobalTransform, Mesh] : [this.Cloth, GlobalTransform],
174
+ columns: [
175
+ [this.Cloth, 'asset'],
176
+ [this.Cloth, 'fabric'],
177
+ [this.Cloth, 'handle'],
178
+ [GlobalTransform, 't'],
179
+ [GlobalTransform, 'r'],
180
+ [GlobalTransform, 's'],
181
+ [this.Cloth, 'wind'],
182
+ [this.Cloth, 'selfCollision'],
183
+ [this.Cloth, 'tear'],
184
+ [this.Cloth, 'tether'],
185
+ [this.Cloth, 'body'],
186
+ [this.Cloth, 'backstop'],
187
+ ],
188
+ });
189
+ this.#bodyQuery = Mesh ? this.world.query({ with: [this.ClothBody, Mesh, GlobalTransform], columns: [[Mesh, 'mesh'], [Mesh, 'skin'], [GlobalTransform, 't'], [GlobalTransform, 'r'], [GlobalTransform, 's']] }) : null;
190
+ this.#writeQuery = Mesh ? this.world.query({ with: [this.Cloth, Mesh], columns: [[this.Cloth, 'handle', 'write'], [Mesh, 'mesh', 'write']] }) : null;
191
+ this.#colliderQuery = this.world.query({
192
+ with: [Collider, GlobalTransform],
193
+ columns: [
194
+ [Collider, 'shape'],
195
+ [Collider, 'dims'],
196
+ [Collider, 'offsetT'],
197
+ [Collider, 'offsetR'],
198
+ [Collider, 'friction'],
199
+ [Collider, 'sensor'],
200
+ [GlobalTransform, 't'],
201
+ [GlobalTransform, 'r'],
202
+ ],
203
+ });
204
+ }
205
+ /** Where cloths simulate: the GPU (WebGPU core), the CPU (the physics variant's CPU cloth, ADR-0102), or nowhere (they draw at rest). */
206
+ get mode() {
207
+ return this.supported ? 'gpu' : this.world.core.exports.yali_cloth_step ? 'cpu' : 'none';
208
+ }
209
+ /** Whether cloths simulate on the GPU (WebGPU core). */
210
+ get supported() {
211
+ return this.#render.backend === 'webgpu' && !!this.#render.gpu;
212
+ }
213
+ /** Registers a cloth mesh (the topology is built and coloured now); returns its id for `cloth()`. */
214
+ addAsset(mesh) {
215
+ this.#assets.push(clothTopology(mesh));
216
+ return this.#assets.length;
217
+ }
218
+ /** Registers a fabric; returns its id for `cloth({ fabric })`. */
219
+ addFabric(f) {
220
+ for (const [k, v] of Object.entries(f)) {
221
+ if (!(typeof v === 'number' && v >= 0 && Number.isFinite(v)))
222
+ throw Object.assign(new YaliError('PHYSICS_INVALID', `Fabric ${k} is ${v}.`, 'Give a finite number ≥ 0.'), { path: k });
223
+ }
224
+ this.#fabrics.push(f);
225
+ return this.#fabrics.length;
226
+ }
227
+ /** Runs `n` more fixed steps at the next frame, whatever the clock does (deterministic goldens). */
228
+ step(n) {
229
+ this.#manual = Math.min(SLOTS - 1 - MAX_CLOCK_STEPS, this.#manual + Math.max(0, Math.floor(n)));
230
+ }
231
+ /**
232
+ * Counts: cloths, vertices, constraints, the most colours a cloth has, colliders, colliders
233
+ * skipped (shapes the cloth does not collide with yet), simulated seconds, the last frame's GPU
234
+ * ms (NaN without timestamps) or CPU ms, and where it runs (`gpu`, `cpu` (ADR-0102), or `none`:
235
+ * no WebGPU core and no physics variant).
236
+ */
237
+ stats() {
238
+ let cloths = 0;
239
+ let vertices = 0;
240
+ let constraints = 0;
241
+ let colours = 0;
242
+ for (const c of this.#instances) {
243
+ if (!c.live)
244
+ continue;
245
+ cloths++;
246
+ vertices += c.count;
247
+ constraints += c.constraints;
248
+ colours = Math.max(colours, c.packed.ncolours);
249
+ }
250
+ return { cloths, vertices, constraints, colours, colliders: this.#colliderCount, skippedColliders: this.#skipped, time: this.#simTime, gpuMs: this.#gpuMs, cpuMs: this.#cpuMs, mode: this.mode, supported: this.supported };
251
+ }
252
+ /** Extract (before `render.extract`): new cloths get their mesh, every cloth its pose, every collider its record. */
253
+ extract() {
254
+ this.#frame++;
255
+ this.#readBodies();
256
+ const q = this.#query;
257
+ let created = null;
258
+ const n = q.run();
259
+ for (let k = 0; k < n; k++) {
260
+ const c = q.chunk(k);
261
+ const asset = c.u32(0);
262
+ const fabric = c.u32(1);
263
+ const handle = c.u32(2);
264
+ const t = c.f64(3);
265
+ const r = c.f32(4);
266
+ const s = c.f32(5);
267
+ const wind = c.f32(6);
268
+ const selfCol = c.f32(7);
269
+ const tear = c.f32(8);
270
+ const tether = c.f32(9);
271
+ const bodyW = c.view(10);
272
+ const backstop = c.f32(11);
273
+ for (let i = 0; i < c.length; i++) {
274
+ const e = c.entity(i);
275
+ let inst = handle[i] > 0 ? this.#instances[handle[i] - 1] : undefined;
276
+ if (!inst || inst.entity !== e) {
277
+ const body = bodyW[i * 2] + bodyW[i * 2 + 1] * 2 ** 32;
278
+ // A cloth worn on a body waits for the body's tag (one frame): its anchors need the body's skinned pose.
279
+ if (body && !this.#bodies.has(body)) {
280
+ this.#untagged.add(body);
281
+ continue;
282
+ }
283
+ inst = this.#create(e, asset[i], fabric[i], selfCol[i], tear[i], tether[i], body, backstop[i], t, r, s, i);
284
+ (created ??= []).push({ entity: e, handle: this.#instances.length, mesh: inst.mesh });
285
+ }
286
+ const now = inst.now;
287
+ for (let a = 0; a < 3; a++)
288
+ now[a] = t[i * 3 + a] - this.#origin[a];
289
+ now[3] = Math.abs(s[i]) || 1;
290
+ for (let a = 0; a < 4; a++)
291
+ now[4 + a] = r[i * 4 + a];
292
+ inst.wind = wind[i];
293
+ inst.seen = this.#frame;
294
+ }
295
+ }
296
+ if (created && this.#writeQuery) {
297
+ const w = this.#writeQuery;
298
+ const m = w.run();
299
+ for (let k = 0; k < m; k++) {
300
+ const c = w.chunk(k);
301
+ const handle = c.u32(0);
302
+ const mesh = c.u32(1);
303
+ for (let i = 0; i < c.length; i++) {
304
+ const e = c.entity(i);
305
+ const hit = created.find((x) => x.entity === e);
306
+ if (hit) {
307
+ handle[i] = hit.handle;
308
+ mesh[i] = hit.mesh;
309
+ }
310
+ }
311
+ }
312
+ }
313
+ for (const c of this.#instances)
314
+ if (c.live && c.seen !== this.#frame)
315
+ c.live = false;
316
+ for (const b of this.#untagged)
317
+ if (this.world.isAlive(b))
318
+ this.world.insert(b, this.ClothBody);
319
+ this.#untagged.clear();
320
+ if (this.#instances.length > 0) {
321
+ this.#extractColliders();
322
+ this.#extractAnchors();
323
+ if (this.#render.backend !== 'webgpu')
324
+ this.#runCpu();
325
+ }
326
+ }
327
+ #create(entity, assetId, fabricId, selfCollision, tear, tether, body, backstop, t, r, s, i) {
328
+ const topo = this.#assets[assetId - 1];
329
+ if (!topo)
330
+ throw new YaliError('CLOTH_ASSET', `physics.Cloth.asset ${assetId} is not a registered cloth asset.`, 'Pass the id ClothSystem.addAsset returned.');
331
+ const fabric = this.#fabrics[(fabricId || 2) - 1];
332
+ if (!fabric)
333
+ throw new YaliError('CLOTH_FABRIC', `physics.Cloth.fabric ${fabricId} is not a fabric.`, `Use a preset (${PRESETS.join(', ')}) or an id from addFabric.`);
334
+ if (this.#instances.length === 0)
335
+ for (let a = 0; a < 3; a++)
336
+ this.#origin[a] = t[i * 3 + a];
337
+ const scale = Math.abs(s[i]) || 1;
338
+ // Where its particles start: the rest pose placed by the entity (the solver group's frame).
339
+ const q = [r[i * 4], r[i * 4 + 1], r[i * 4 + 2], r[i * 4 + 3]];
340
+ const o = [t[i * 3] - this.#origin[0], t[i * 3 + 1] - this.#origin[1], t[i * 3 + 2] - this.#origin[2]];
341
+ const start = new Float64Array(topo.vertices * 3);
342
+ for (let v = 0; v < topo.vertices; v++) {
343
+ const ps = topo.mesh.positions;
344
+ const w = rotate(q, [ps[v * 3] * scale, ps[v * 3 + 1] * scale, ps[v * 3 + 2] * scale]);
345
+ start.set([o[0] + w[0], o[1] + w[1], o[2] + w[2]], v * 3);
346
+ }
347
+ const anchors = body ? this.#anchorsFor(body, topo.pinned, start, backstop) : null;
348
+ const packed = packCloth(topo, fabric, scale, tether, anchors?.map ?? null);
349
+ const base = this.#wordCount;
350
+ if (base + packed.words.length > this.#words.length) {
351
+ const grown = new Uint32Array(Math.max(this.#words.length * 2, base + packed.words.length));
352
+ grown.set(this.#words.subarray(0, base));
353
+ this.#words = grown;
354
+ }
355
+ this.#words.set(packed.words, base);
356
+ this.#wordCount += packed.words.length;
357
+ // The render mesh: the rest pose, double-sided, with a culling radius that covers a swing.
358
+ const nv = topo.vertices;
359
+ // A tearable cloth reserves spare vertices for the splits its tears make (ADR-0108).
360
+ const cap = tear > 0 ? nv + Math.max(16, Math.ceil(nv * 0.5)) : nv;
361
+ const p = topo.mesh.positions;
362
+ const verts = new Float32Array(cap * 8);
363
+ const nrm = new Float64Array(nv * 3);
364
+ const idx = topo.mesh.indices;
365
+ for (let f = 0; f < topo.triangles; f++) {
366
+ const [a, b, c] = [idx[f * 3], idx[f * 3 + 1], idx[f * 3 + 2]];
367
+ const u = [p[b * 3] - p[a * 3], p[b * 3 + 1] - p[a * 3 + 1], p[b * 3 + 2] - p[a * 3 + 2]];
368
+ const v = [p[c * 3] - p[a * 3], p[c * 3 + 1] - p[a * 3 + 1], p[c * 3 + 2] - p[a * 3 + 2]];
369
+ const cr = [u[1] * v[2] - u[2] * v[1], u[2] * v[0] - u[0] * v[2], u[0] * v[1] - u[1] * v[0]];
370
+ for (const x of [a, b, c])
371
+ for (let k = 0; k < 3; k++)
372
+ nrm[x * 3 + k] += cr[k];
373
+ }
374
+ let r0 = 0;
375
+ for (let v = 0; v < nv; v++) {
376
+ const l = Math.hypot(nrm[v * 3], nrm[v * 3 + 1], nrm[v * 3 + 2]) || 1;
377
+ verts.set([p[v * 3], p[v * 3 + 1], p[v * 3 + 2], nrm[v * 3] / l, nrm[v * 3 + 1] / l, nrm[v * 3 + 2] / l, topo.mesh.uvs?.[v * 2] ?? 0, topo.mesh.uvs?.[v * 2 + 1] ?? 0], v * 8);
378
+ r0 = Math.max(r0, Math.hypot(p[v * 3], p[v * 3 + 1], p[v * 3 + 2]));
379
+ }
380
+ const mesh = this.#render.meshes.add({ vertices: verts, indices: idx, state: { doubleSided: true }, radius: r0 * 3 + 4 / scale });
381
+ // Particles start at the rest pose placed by the entity.
382
+ const initial = new Float32Array(cap * 4);
383
+ for (let v = 0; v < nv; v++)
384
+ initial.set([start[v * 3], start[v * 3 + 1], start[v * 3 + 2], packed.invMass[v]], v * 4);
385
+ const now = new Float64Array([o[0], o[1], o[2], scale, q[0], q[1], q[2], q[3]]);
386
+ // Self-collision: a dense hash of twice the vertices, its counts and entries in `hash`.
387
+ let edges = 0;
388
+ let edgeSum = 0;
389
+ for (let k = 0; k < topo.kinds.length; k++)
390
+ if (topo.kinds[k] === 0)
391
+ (edges++, (edgeSum += topo.rest[k * 4]));
392
+ const selfDist = selfCollision > 0 ? selfCollision : selfCollision < 0 ? (0.6 * edgeSum * scale) / Math.max(1, edges) : 0;
393
+ const table = selfDist > 0 ? nextPow2(cap * 2) : 0;
394
+ const hashAt = this.#hashWords;
395
+ if (table)
396
+ this.#hashWords += table + 1 + cap;
397
+ const indexAt = this.#indexWords;
398
+ if (tear > 0)
399
+ this.#indexWords += topo.triangles * 3;
400
+ const inst = { entity, packed, rest: verts, fabric, selfDist, table, hashAt, tear, triangles: topo.triangles, indexAt, first: this.#vertices, count: nv, base, mesh, constraints: topo.kinds.length, initial, now, sim: now.slice(), wind: 1, live: true, seen: this.#frame, body, anchors, topo, scale, tether, cap, copies: [], masses: [], tornTotal: 0, reported: new Uint8Array(topo.kinds.length), tornRead: 'idle' };
401
+ this.#vertices += cap;
402
+ this.#instances.push(inst);
403
+ return inst;
404
+ }
405
+ /** This frame's tagged bodies (ADR-0108): mesh, palette row + 1, pose. */
406
+ #readBodies() {
407
+ this.#bodies.clear();
408
+ const q = this.#bodyQuery;
409
+ if (!q)
410
+ return;
411
+ const n = q.run();
412
+ for (let k = 0; k < n; k++) {
413
+ const c = q.chunk(k);
414
+ const mesh = c.u32(0);
415
+ const skin = c.u32(1);
416
+ const t = c.f64(2);
417
+ const r = c.f32(3);
418
+ const s = c.f32(4);
419
+ for (let i = 0; i < c.length; i++) {
420
+ this.#bodies.set(c.entity(i), { mesh: mesh[i], skin: skin[i], pose: Float64Array.of(t[i * 3], t[i * 3 + 1], t[i * 3 + 2], r[i * 4], r[i * 4 + 1], r[i * 4 + 2], r[i * 4 + 3], Math.abs(s[i]) || 1) });
421
+ }
422
+ }
423
+ }
424
+ /** Body vertex `b`'s blended skinning matrix (3 × 4, row-major) under this frame's palette, or null. */
425
+ #skinMatrix(skin, row, b, out) {
426
+ const pal = this.#render.scene?.paletteSource?.();
427
+ if (!pal || row <= 0)
428
+ return false;
429
+ out.fill(0);
430
+ let wsum = 0;
431
+ for (let j = 0; j < 4; j++) {
432
+ const w = skin[b * 8 + 4 + j] / 65535;
433
+ if (w === 0)
434
+ continue;
435
+ wsum += w;
436
+ const at = pal.at + (row - 1 + skin[b * 8 + j]) * 12;
437
+ for (let k = 0; k < 12; k++)
438
+ out[k] += w * pal.f32[at + k];
439
+ }
440
+ if (wsum <= 0)
441
+ out.set([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0]);
442
+ return true;
443
+ }
444
+ /**
445
+ * A cloth's anchors on its body (ADR-0108), from where its vertices start: each pinned vertex binds
446
+ * to its nearest body vertex (its spot carried into that vertex's bind space, so it follows that
447
+ * vertex's skinning); each free vertex lying on the skin (`BACKSTOP_RANGE`) gets a backstop there.
448
+ */
449
+ #anchorsFor(body, pinned, start, radius) {
450
+ const b = this.#bodies.get(body);
451
+ const g = this.#render.meshes.geometry(b.mesh);
452
+ const m = new Float64Array(12);
453
+ if (!g?.skin || !this.#skinMatrix(g.skin, b.skin, 0, m)) {
454
+ throw new YaliError('CLOTH_BODY', `The cloth's body ${body} has no skinned mesh with a palette this frame.`, 'Give physics.Cloth.body the entity of a skinned render.Mesh (spawnAnimatedGltf(...).skinned[0]) with animationPlugin() installed.');
455
+ }
456
+ const nb = g.vertices.length / 8;
457
+ // The body's vertices where they are now, in the solver group's frame.
458
+ const at = new Float64Array(nb * 3);
459
+ const nrm = new Float32Array(nb * 3);
460
+ for (let v = 0; v < nb; v++)
461
+ this.#bodyPoint(g, b, v, m, at, v * 3, nrm, v * 3);
462
+ const n = pinned.length;
463
+ const map = new Uint32Array(n).fill(NO_VERTEX);
464
+ const vertex = [];
465
+ const bodyVertex = [];
466
+ const bind = [];
467
+ for (let v = 0; v < n; v++) {
468
+ let best = -1;
469
+ let bestD = Infinity;
470
+ for (let u = 0; u < nb; u++) {
471
+ const d = (at[u * 3] - start[v * 3]) ** 2 + (at[u * 3 + 1] - start[v * 3 + 1]) ** 2 + (at[u * 3 + 2] - start[v * 3 + 2]) ** 2;
472
+ if (d < bestD)
473
+ (bestD = d), (best = u);
474
+ }
475
+ // A backstop holds cloth lying on the skin: within BACKSTOP_RANGE of it, on its outside. Further
476
+ // off, an infinite plane at the nearest vertex (a hip's, a leg's) pushed the hanging cape sideways.
477
+ const off = best < 0 ? 0 : (start[v * 3] - at[best * 3]) * nrm[best * 3] + (start[v * 3 + 1] - at[best * 3 + 1]) * nrm[best * 3 + 1] + (start[v * 3 + 2] - at[best * 3 + 2]) * nrm[best * 3 + 2];
478
+ if (best < 0 || (!pinned[v] && (bestD > BACKSTOP_RANGE * BACKSTOP_RANGE || off < -0.005)))
479
+ continue;
480
+ map[v] = vertex.length;
481
+ vertex.push(v);
482
+ bodyVertex.push(best);
483
+ if (!pinned[v]) {
484
+ bind.push(NaN, NaN, NaN);
485
+ continue;
486
+ }
487
+ // The pin's spot in the body's mesh space, then back through its body vertex's skinning.
488
+ this.#skinMatrix(g.skin, b.skin, best, m);
489
+ const pose = b.pose;
490
+ const inv = [-pose[3], -pose[4], -pose[5], pose[6]];
491
+ const local = rotate(inv, [start[v * 3] + this.#origin[0] - pose[0], start[v * 3 + 1] + this.#origin[1] - pose[1], start[v * 3 + 2] + this.#origin[2] - pose[2]]).map((x) => x / pose[7]);
492
+ bind.push(...unskin(m, local));
493
+ }
494
+ if (vertex.length === 0)
495
+ return null;
496
+ const k = vertex.length;
497
+ const anchors = { map, vertex: Uint32Array.from(vertex), bodyVertex: Uint32Array.from(bodyVertex), bind: Float32Array.from(bind), radius, sim: new Float32Array(k * 8), now: new Float32Array(k * 8), base: 0 };
498
+ this.#poseAnchors(anchors, b, g);
499
+ anchors.sim.set(anchors.now);
500
+ return anchors;
501
+ }
502
+ /**
503
+ * Body vertex `v` (or, with `spot`, that bind-space spot skinned like it) in the solver group's
504
+ * frame into `out[o..]`; with `normal`, its skinned normal into `normal[o..]`. `m` is scratch.
505
+ */
506
+ #bodyPoint(g, b, v, m, out, o, normal, no = 0, spot) {
507
+ this.#skinMatrix(g.skin, b.skin, v, m);
508
+ const x = spot ?? [g.vertices[v * 8], g.vertices[v * 8 + 1], g.vertices[v * 8 + 2]];
509
+ const sk = [0, 1, 2].map((r) => m[r * 4] * x[0] + m[r * 4 + 1] * x[1] + m[r * 4 + 2] * x[2] + m[r * 4 + 3]);
510
+ const pose = b.pose;
511
+ const q = [pose[3], pose[4], pose[5], pose[6]];
512
+ const w = rotate(q, [sk[0] * pose[7], sk[1] * pose[7], sk[2] * pose[7]]);
513
+ for (let a = 0; a < 3; a++)
514
+ out[o + a] = pose[a] + w[a] - this.#origin[a];
515
+ if (normal) {
516
+ const nv = [g.vertices[v * 8 + 3], g.vertices[v * 8 + 4], g.vertices[v * 8 + 5]];
517
+ const nw = rotate(q, [0, 1, 2].map((r) => m[r * 4] * nv[0] + m[r * 4 + 1] * nv[1] + m[r * 4 + 2] * nv[2]));
518
+ const l = Math.hypot(nw[0], nw[1], nw[2]) || 1;
519
+ for (let a = 0; a < 3; a++)
520
+ normal[no + a] = nw[a] / l;
521
+ }
522
+ }
523
+ /** Each anchor where its body puts it now (ADR-0108). */
524
+ #poseAnchors(a, b, g) {
525
+ const m = new Float64Array(12);
526
+ const now = a.now;
527
+ for (let k = 0; k < a.vertex.length; k++) {
528
+ const pin = !Number.isNaN(a.bind[k * 3]);
529
+ // The point (a pin's bind-space spot skinned like its body vertex) and the body's normal there.
530
+ this.#bodyPoint(g, b, a.bodyVertex[k], m, now, k * 8, now, k * 8 + 4, pin ? a.bind.subarray(k * 3, k * 3 + 3) : undefined);
531
+ now[k * 8 + 3] = a.radius;
532
+ now[k * 8 + 7] = 0;
533
+ }
534
+ }
535
+ /** Every cloth's anchors this frame, as records after the colliders (ADR-0108). */
536
+ #extractAnchors() {
537
+ let count = 0;
538
+ for (const c of this.#instances) {
539
+ const a = c.anchors;
540
+ if (!a || !c.live)
541
+ continue;
542
+ const b = this.#bodies.get(c.body);
543
+ const g = b ? this.#render.meshes.geometry(b.mesh) : null;
544
+ if (b && g?.skin)
545
+ this.#poseAnchors(a, b, g);
546
+ a.base = count;
547
+ const need = (this.#colliderCount + count + a.vertex.length) * 20;
548
+ if (need > this.#colliderData.length) {
549
+ const grown = new Float32Array(nextPow2(need));
550
+ grown.set(this.#colliderData);
551
+ this.#colliderData = grown;
552
+ }
553
+ const d = this.#colliderData;
554
+ for (let k = 0; k < a.vertex.length; k++) {
555
+ const o = (this.#colliderCount + count + k) * 20;
556
+ d.set(a.sim.subarray(k * 8, k * 8 + 8), o);
557
+ d.set(a.now.subarray(k * 8, k * 8 + 8), o + 8);
558
+ d.fill(0, o + 16, o + 20);
559
+ }
560
+ count += a.vertex.length;
561
+ }
562
+ this.#anchorCount = count;
563
+ }
564
+ #extractColliders() {
565
+ const q = this.#colliderQuery;
566
+ const n = q.run();
567
+ let count = 0;
568
+ let skipped = 0;
569
+ this.#colliderEntities.length = 0;
570
+ for (let k = 0; k < n; k++) {
571
+ const c = q.chunk(k);
572
+ const shape = c.u8(0);
573
+ const dims = c.f32(1);
574
+ const offT = c.f32(2);
575
+ const offR = c.f32(3);
576
+ const friction = c.f32(4);
577
+ const sensor = c.u8(5);
578
+ const t = c.f64(6);
579
+ const r = c.f32(7);
580
+ for (let i = 0; i < c.length; i++) {
581
+ if (sensor[i])
582
+ continue;
583
+ if (shape[i] > ShapeKind.Box) {
584
+ skipped++;
585
+ continue;
586
+ }
587
+ if ((count + 1) * 20 > this.#colliderData.length) {
588
+ const grown = new Float32Array(this.#colliderData.length * 2);
589
+ grown.set(this.#colliderData);
590
+ this.#colliderData = grown;
591
+ }
592
+ const qr = [r[i * 4], r[i * 4 + 1], r[i * 4 + 2], r[i * 4 + 3]];
593
+ const off = rotate(qr, [offT[i * 3], offT[i * 3 + 1], offT[i * 3 + 2]]);
594
+ const qq = quatMul(qr, [offR[i * 4], offR[i * 4 + 1], offR[i * 4 + 2], offR[i * 4 + 3]]);
595
+ const e = c.entity(i);
596
+ let sim = this.#colliderSim.get(e);
597
+ const p = [t[i * 3] + off[0] - this.#origin[0], t[i * 3 + 1] + off[1] - this.#origin[1], t[i * 3 + 2] + off[2] - this.#origin[2]];
598
+ if (!sim) {
599
+ sim = new Float64Array([p[0], p[1], p[2], qq[0], qq[1], qq[2], qq[3]]);
600
+ this.#colliderSim.set(e, sim);
601
+ }
602
+ const d = this.#colliderData;
603
+ const o = count * 20;
604
+ d.set([sim[0], sim[1], sim[2], shape[i], sim[3], sim[4], sim[5], sim[6], p[0], p[1], p[2], friction[i], qq[0], qq[1], qq[2], qq[3], dims[i * 3], dims[i * 3 + 1], dims[i * 3 + 2], 0], o);
605
+ this.#colliderEntities.push(e);
606
+ count++;
607
+ }
608
+ }
609
+ this.#colliderCount = count;
610
+ if (skipped && !this.#warned) {
611
+ this.#warned = true;
612
+ console.warn(`[yali] cloth: ${skipped} hull, mesh or heightfield colliders are ignored by the cloth (spheres, capsules and boxes only, ADR-0101). Give the cloth simpler colliders to hit.`);
613
+ }
614
+ this.#skipped = skipped;
615
+ if (this.#colliderSim.size > count * 2 + 64) {
616
+ const keep = new Set(this.#colliderEntities);
617
+ for (const e of this.#colliderSim.keys())
618
+ if (!keep.has(e))
619
+ this.#colliderSim.delete(e);
620
+ }
621
+ }
622
+ /** The fixed steps this frame runs: the clock's (unless paused, at most 8) plus any queued with `step(n)`; and the render alpha. */
623
+ #takeSteps() {
624
+ const clock = this.#app.time.fixedSteps;
625
+ if (this.#seenSteps < 0)
626
+ this.#seenSteps = clock;
627
+ let steps = this.paused ? 0 : Math.min(MAX_CLOCK_STEPS, clock - this.#seenSteps);
628
+ this.#seenSteps = clock;
629
+ const manual = this.#manual;
630
+ steps += manual;
631
+ this.#manual = 0;
632
+ return { steps, alpha: this.paused || manual > 0 ? 1 : this.#app.time.alpha };
633
+ }
634
+ /** Every cloth's record: its pose when it last stepped and now, its fabric; `pool` is where its output vertices start. */
635
+ #records(pool) {
636
+ const rec = new ArrayBuffer(this.#instances.length * CLOTH_BYTES);
637
+ const u = new Uint32Array(rec);
638
+ const f = new Float32Array(rec);
639
+ for (let k = 0; k < this.#instances.length; k++) {
640
+ const c = this.#instances[k];
641
+ const o = k * (CLOTH_BYTES / 4);
642
+ const pk = c.packed;
643
+ u.set([c.first, c.count, c.base, pool(c), pk.tris, pk.adj, pk.colours, pk.ncolours, pk.cons, c.live ? 1 : 0, c.hashAt, c.table], o);
644
+ // Pose when last stepped (origin, scale, rotation), then now.
645
+ f.set(c.sim, o + 12);
646
+ f.set(c.now, o + 20);
647
+ f.set([c.fabric.damping, c.fabric.friction, c.fabric.thickness, c.fabric.drag, c.fabric.skinDrag, c.wind, c.selfDist, c.tear], o + 28);
648
+ u[o + 36] = c.indexAt;
649
+ u[o + 37] = pk.vbd;
650
+ u[o + 39] = c.anchors?.base ?? 0;
651
+ f[o + 38] = this.strainLimit;
652
+ }
653
+ return rec;
654
+ }
655
+ /** The parameter slots (`SLOT` bytes each): one per step, and the write's (`WRITE_SLOT`). */
656
+ #paramSlots(steps, alpha, substeps, iterations = this.vbdIterations) {
657
+ const dt = this.#app.time.fixedDelta;
658
+ const slots = new ArrayBuffer(SLOTS * SLOT);
659
+ const pf = new Float32Array(slots);
660
+ const pu = new Uint32Array(slots);
661
+ const w = this.wind;
662
+ const sub = Math.max(1, Math.round(substeps));
663
+ for (let k = 0; k < steps; k++) {
664
+ const o = k * (SLOT / 4);
665
+ pf.set([...this.gravity, dt / sub, ...w.velocity, this.#simTime + k * dt, w.turbulence, w.gust, w.gustFrequency, w.airDensity], o);
666
+ pu[o + 12] = sub;
667
+ pu[o + 13] = this.#colliderCount;
668
+ pf.set([k / steps, (k + 1) / steps, 1, steps * dt, this.maxSpeed], o + 14);
669
+ // VBD's iterations (the GPU picks its pipeline; the CPU steps with VBD when this is above 0, ADR-0126).
670
+ pu[o + 19] = iterations;
671
+ pu[o + 20] = this.bendIterations;
672
+ }
673
+ pf[WRITE_SLOT * (SLOT / 4) + 16] = alpha;
674
+ return slots;
675
+ }
676
+ /** After `steps` steps: the poses they ended at are where the next frame's start. */
677
+ #stepped(steps) {
678
+ if (steps === 0)
679
+ return;
680
+ this.#simTime += steps * this.#app.time.fixedDelta;
681
+ for (const c of this.#instances) {
682
+ c.sim.set(c.now);
683
+ c.anchors?.sim.set(c.anchors.now);
684
+ }
685
+ const d = this.#colliderData;
686
+ for (let k = 0; k < this.#colliderCount; k++) {
687
+ const sim = this.#colliderSim.get(this.#colliderEntities[k]);
688
+ if (sim)
689
+ sim.set([d[k * 20 + 8], d[k * 20 + 9], d[k * 20 + 10], d[k * 20 + 12], d[k * 20 + 13], d[k * 20 + 14], d[k * 20 + 15]]);
690
+ }
691
+ }
692
+ /** The frame's GPU work (a `render.prepasses` hook): every pending fixed step, then the vertex write. */
693
+ encode(encoder, pool, indexPool) {
694
+ for (const b of this.#trash)
695
+ b.destroy();
696
+ this.#trash.length = 0;
697
+ if (this.#instances.length === 0 || !this.supported)
698
+ return;
699
+ const { steps, alpha } = this.#takeSteps();
700
+ const live = this.#instances.length;
701
+ const g = this.#ensure(this.#render.gpu.device, encoder);
702
+ if (g.pool !== pool) {
703
+ g.pool = pool;
704
+ g.writeGroup = null;
705
+ }
706
+ this.#bind(g);
707
+ // Vertices a split made start where their source vertex is (ADR-0108), through a scratch buffer:
708
+ // WebGPU copies never go from a buffer to itself.
709
+ let pairs = 0;
710
+ for (const c of this.#instances)
711
+ pairs += c.copies.length / 2;
712
+ if (pairs > 0) {
713
+ const bytes = pairs * 16 * g.particles.length;
714
+ if (!g.splitTmp || g.splitTmp.size < bytes) {
715
+ g.splitTmp?.destroy();
716
+ g.splitTmp = g.device.createBuffer({ label: 'yali:cloth:split', size: nextPow2(bytes), usage: COPY_SRC | COPY_DST });
717
+ }
718
+ const tmp = g.splitTmp;
719
+ for (const dir of [0, 1]) {
720
+ let slot = 0;
721
+ for (const c of this.#instances) {
722
+ for (let k = 0; k < c.copies.length; k += 2) {
723
+ for (const b of g.particles) {
724
+ if (dir === 0)
725
+ encoder.copyBufferToBuffer(b, (c.first + c.copies[k]) * 16, tmp, slot * 16, 16);
726
+ else
727
+ encoder.copyBufferToBuffer(tmp, slot * 16, b, (c.first + c.copies[k + 1]) * 16, 16);
728
+ slot++;
729
+ }
730
+ }
731
+ }
732
+ }
733
+ for (const c of this.#instances)
734
+ c.copies.length = 0;
735
+ }
736
+ // A split's vertices lost the mass of the triangles they no longer share (ADR-0115): each new
737
+ // inverse mass into `pos.w`, after the copies above (which brought the source's).
738
+ let masses = 0;
739
+ for (const c of this.#instances)
740
+ masses += c.masses.length / 2;
741
+ if (masses > 0) {
742
+ if (!g.massTmp || g.massTmp.size < masses * 4) {
743
+ g.massTmp?.destroy();
744
+ g.massTmp = g.device.createBuffer({ label: 'yali:cloth:masses', size: nextPow2(masses * 4), usage: COPY_SRC | COPY_DST });
745
+ }
746
+ const vals = new Uint32Array(masses);
747
+ let k = 0;
748
+ for (const c of this.#instances)
749
+ for (let i = 1; i < c.masses.length; i += 2)
750
+ vals[k++] = c.masses[i];
751
+ g.device.queue.writeBuffer(g.massTmp, 0, vals);
752
+ k = 0;
753
+ for (const c of this.#instances) {
754
+ for (let i = 0; i < c.masses.length; i += 2)
755
+ encoder.copyBufferToBuffer(g.massTmp, 4 * k++, g.particles[0], (c.first + c.masses[i]) * 16 + 12, 4);
756
+ c.masses.length = 0;
757
+ }
758
+ }
759
+ const dev = g.device;
760
+ dev.queue.writeBuffer(g.cloths, 0, this.#records((c) => this.#render.meshes.entries[c.mesh].baseVertex));
761
+ const records = this.#colliderCount + this.#anchorCount;
762
+ if (records > 0)
763
+ dev.queue.writeBuffer(g.colliders, 0, this.#colliderData.buffer, 0, records * COLLIDER_BYTES);
764
+ const vbd = this.solver === 'vbd';
765
+ const slots = this.#paramSlots(steps, alpha, vbd ? this.vbdSubsteps : this.substeps);
766
+ if (steps > 0)
767
+ dev.queue.writeBuffer(g.params, 0, slots, 0, steps * SLOT);
768
+ dev.queue.writeBuffer(g.params, WRITE_SLOT * SLOT, slots, WRITE_SLOT * SLOT, SLOT);
769
+ const timer = g.timer?.state === 'idle' ? g.timer : null;
770
+ const self = this.#hashWords > 0;
771
+ // Each step starts from a copy of the positions in `last` (render interpolation): a buffer
772
+ // copy between passes, so the step binds the scratch `delta` instead (ADR-0110). The
773
+ // timestamps span from the first pass's start to the write pass's end.
774
+ const used = this.#vertices * 16;
775
+ const subs = vbd ? this.vbdSubsteps : this.substeps;
776
+ const wide = this.wide && steps > 0 ? this.#wideLayout(g, slots, steps, subs) : null;
777
+ const n = this.#instances.length;
778
+ const w = g.wide;
779
+ for (let k = 0; k < steps; k++) {
780
+ encoder.copyBufferToBuffer(g.particles[0], 0, g.particles[3], 0, used);
781
+ const sp = encoder.beginComputePass(timer && k === 0 ? { label: 'physics.cloth', timestampWrites: { querySet: timer.query, beginningOfPassWriteIndex: 0 } } : { label: 'physics.cloth' });
782
+ const go = (p, x) => {
783
+ if (x <= 0)
784
+ return;
785
+ sp.setPipeline(p);
786
+ sp.dispatchWorkgroups(Math.ceil(x / 256), n);
787
+ };
788
+ if (wide && vbd) {
789
+ // Wide VBD (ADR-0118): each phase over the whole GPU, a dispatch per vertex colour.
790
+ const iters = Math.max(1, this.vbdIterations);
791
+ const edges = Math.max(wide.constraints, wide.vertices);
792
+ for (let sub = 0; sub < subs; sub++) {
793
+ sp.setBindGroup(0, g.wideGroup, [(k * subs + sub) * SLOT]);
794
+ go(w.air, wide.vertices);
795
+ if (sub === 0)
796
+ go(w.vbd.warm, edges);
797
+ go(w.vbd.predict, wide.vertices);
798
+ for (let it = 0; it < iters; it++) {
799
+ wide.vcolours.forEach((size, c) => go(w.vbd.colour[c], size));
800
+ go(w.vbd.dual, edges);
801
+ }
802
+ go(w.collide, wide.vertices);
803
+ }
804
+ }
805
+ else if (wide) {
806
+ for (let sub = 0; sub < this.substeps; sub++) {
807
+ sp.setBindGroup(0, g.wideGroup, [(k * this.substeps + sub) * SLOT]);
808
+ go(w.air, wide.vertices);
809
+ go(w.predict, wide.vertices);
810
+ wide.colours.forEach((size, c) => go(w.stretch[c], size));
811
+ // Bending's passes (ADR-0128): the first starts the multipliers at zero.
812
+ for (let it = 0; it < Math.max(1, this.bendIterations) && wide.bends > 0; it++) {
813
+ go(it === 0 ? w.bend : w.bendMore, wide.bends);
814
+ go(w.bendApply, wide.vertices);
815
+ }
816
+ if (wide.tethers)
817
+ go(w.tether, wide.vertices);
818
+ go(w.collide, wide.vertices);
819
+ }
820
+ }
821
+ else {
822
+ sp.setPipeline(vbd ? g.vbd : g.step);
823
+ sp.setBindGroup(0, g.stepGroup, [k * SLOT]);
824
+ sp.dispatchWorkgroups(live);
825
+ }
826
+ if (self && wide) {
827
+ // Wide self-collision (ADR-0118): its phases over the whole GPU, the scan a workgroup a cloth.
828
+ const sc = w.self;
829
+ sp.setBindGroup(0, g.selfGroup, [k * SLOT]);
830
+ go(sc.clear, wide.table + 1);
831
+ go(sc.count, wide.vertices);
832
+ sp.setPipeline(sc.scan);
833
+ sp.dispatchWorkgroups(1, n);
834
+ go(sc.fill, wide.vertices);
835
+ go(sc.sort, wide.table);
836
+ go(sc.push, wide.vertices);
837
+ go(sc.apply, wide.vertices);
838
+ }
839
+ else if (self) {
840
+ sp.setPipeline(g.self);
841
+ sp.setBindGroup(0, g.selfGroup, [k * SLOT]);
842
+ sp.dispatchWorkgroups(live);
843
+ }
844
+ sp.end();
845
+ }
846
+ const pass = encoder.beginComputePass(timer ? { label: 'physics.cloth', timestampWrites: steps > 0 ? { querySet: timer.query, endOfPassWriteIndex: 1 } : { querySet: timer.query, beginningOfPassWriteIndex: 0, endOfPassWriteIndex: 1 } } : { label: 'physics.cloth' });
847
+ pass.setPipeline(g.write);
848
+ pass.setBindGroup(0, g.writeGroup, [WRITE_SLOT * SLOT]);
849
+ pass.dispatchWorkgroups(live);
850
+ pass.end();
851
+ if (this.splitEvery > 0)
852
+ this.#readTears(encoder, g);
853
+ for (const c of this.#instances) {
854
+ if (c.live && c.tear > 0)
855
+ encoder.copyBufferToBuffer(g.triOut, c.indexAt * 4, indexPool, this.#render.meshes.entries[c.mesh].lods[0].firstIndex * 4, c.triangles * 12);
856
+ }
857
+ if (timer) {
858
+ encoder.resolveQuerySet(timer.query, 0, 2, timer.resolve, 0);
859
+ encoder.copyBufferToBuffer(timer.resolve, 0, timer.read, 0, 16);
860
+ timer.state = 'copied';
861
+ }
862
+ else if (g.timer?.state === 'copied')
863
+ this.#readTimer(g.timer);
864
+ this.#stepped(steps);
865
+ }
866
+ /**
867
+ * The CPU cloth (ADR-0102), where the page has no WebGPU core: the same steps in the core's
868
+ * physics variant (`yali_cloth_step`, then `yali_cloth_write`), on buffers in linear memory,
869
+ * and the vertices handed to the renderer through `MeshStore.updateVertices`. In Extract.
870
+ */
871
+ #runCpu() {
872
+ const core = this.world.core;
873
+ const x = core.exports;
874
+ if (!x.yali_cloth_step || !x.yali_cloth_write) {
875
+ if (!this.#warned) {
876
+ this.#warned = true;
877
+ console.warn(`[yali] cloth: this page renders on ${this.#render.backend} and its core has no CPU cloth, so cloths stay at their rest pose. Load the physics variant (import @yali-engine/physics, or features: ['physics'] in yali.config.ts).`);
878
+ }
879
+ return;
880
+ }
881
+ const t0 = performance.now();
882
+ const { steps, alpha } = this.#takeSteps();
883
+ const m = this.#ensureCpu();
884
+ const heap = core.heap;
885
+ heap.refresh();
886
+ const rec = new Uint32Array(this.#records((c) => c.first));
887
+ heap.u32.set(rec, m.cloths >>> 2);
888
+ // The colliders, then the skinned anchors' records (ADR-0108; pins and backstops on the CPU too, ADR-0126).
889
+ const records = this.#colliderCount + this.#anchorCount;
890
+ heap.f32.set(this.#colliderData.subarray(0, records * 20), m.colliders >>> 2);
891
+ const vbd = this.solver === 'vbd';
892
+ const slots = new Uint32Array(this.#paramSlots(steps, alpha, vbd ? this.vbdSubsteps : this.cpuSubsteps, vbd ? this.vbdIterations : 0));
893
+ const n = this.#instances.length;
894
+ const w = this.world.handle;
895
+ for (let k = 0; k <= steps; k++) {
896
+ const slot = k < steps ? k : WRITE_SLOT;
897
+ heap.u32.set(slots.subarray(slot * (SLOT / 4), slot * (SLOT / 4) + 20), m.params >>> 2);
898
+ const status = k < steps
899
+ ? x.yali_cloth_step(w, m.params, m.cloths, n, m.st, this.#wordCount, m.particles, m.cap, m.colliders, records, m.torn, this.#wordCount)
900
+ : x.yali_cloth_write(w, m.params, m.cloths, n, m.st, this.#wordCount, m.particles, m.cap, m.out, m.cap * 8);
901
+ this.world.check(status);
902
+ }
903
+ heap.refresh();
904
+ // Tears split into clean cuts here too (ADR-0117), every `splitEvery` frames: the flags are in
905
+ // linear memory, read with no readback.
906
+ if (steps > 0 && this.splitEvery > 0 && this.#frame % this.splitEvery === 0)
907
+ this.#cpuTears(m);
908
+ for (const c of this.#instances) {
909
+ // The mesh's whole block: a tearable cloth's holds its spare slots too (ADR-0108).
910
+ if (c.live)
911
+ this.#render.meshes.updateVertices(c.mesh, heap.f32.subarray((m.out >>> 2) + c.first * 8, (m.out >>> 2) + (c.first + c.cap) * 8));
912
+ }
913
+ this.#stepped(steps);
914
+ this.#cpuMs = performance.now() - t0;
915
+ }
916
+ /**
917
+ * The CPU cloth's tears (ADR-0117): each tearable cloth's compact flags read from linear memory and
918
+ * split (`#split`, the GPU path's), then the split applied here: the new vertices' particles copied
919
+ * from their sources, the changed inverse masses, the new uvs in the output, and the render mesh's
920
+ * triangles rewritten (`MeshStore.updateIndices`).
921
+ */
922
+ #cpuTears(m) {
923
+ const heap = this.world.core.heap;
924
+ for (const c of this.#instances) {
925
+ if (!c.live || c.tear <= 0)
926
+ continue;
927
+ const [at, n] = this.#tornRange(c);
928
+ const before = c.count;
929
+ this.#split(c, heap.u32.slice((m.torn >>> 2) + at, (m.torn >>> 2) + at + n));
930
+ if (c.count === before)
931
+ continue;
932
+ heap.refresh();
933
+ const f = heap.f32;
934
+ const u = heap.u32;
935
+ const p = m.particles >>> 2;
936
+ for (let k = 0; k < c.copies.length; k += 2) {
937
+ const src = (c.first + c.copies[k]) * 4;
938
+ const dst = (c.first + c.copies[k + 1]) * 4;
939
+ for (let a = 0; a < 4; a++)
940
+ f.copyWithin(p + a * m.cap * 4 + dst, p + a * m.cap * 4 + src, p + a * m.cap * 4 + src + 4);
941
+ }
942
+ for (let k = 0; k < c.masses.length; k += 2)
943
+ u[p + (c.first + c.masses[k]) * 4 + 3] = c.masses[k + 1];
944
+ c.copies.length = 0;
945
+ c.masses.length = 0;
946
+ f.set(c.rest, (m.out >>> 2) + c.first * 8);
947
+ this.#render.meshes.updateIndices(c.mesh, c.topo.mesh.indices);
948
+ }
949
+ }
950
+ /** The CPU cloth's linear-memory buffers, grown (particles copied) as cloths arrive. */
951
+ #ensureCpu() {
952
+ const core = this.world.core;
953
+ const x = core.exports;
954
+ let m = this.#cpu;
955
+ const heap = core.heap;
956
+ const alloc = (bytes) => {
957
+ const p = x.yali_alloc(Math.max(16, bytes));
958
+ if (!p)
959
+ throw new YaliError('CLOTH_MEMORY', `Could not allocate ${bytes} bytes of linear memory for the CPU cloth.`, 'Fewer or smaller cloths on this device.');
960
+ heap.refresh();
961
+ return p;
962
+ };
963
+ if (!m) {
964
+ m = this.#cpu = { cap: 0, particles: 0, out: 0, st: 0, stCap: 0, stWords: -1, torn: 0, cloths: 0, clothCap: 0, colliders: 0, colliderCap: 0, params: alloc(PARAM_BYTES), uploaded: 0 };
965
+ }
966
+ if (this.#vertices > m.cap) {
967
+ const cap = nextPow2(Math.max(1024, this.#vertices));
968
+ const particles = alloc(cap * 96);
969
+ const out = alloc(cap * 32);
970
+ if (m.cap > 0) {
971
+ // Each of the four arrays to its new stride; the output (uvs and all) as it is.
972
+ for (let a = 0; a < 4; a++)
973
+ heap.u8.copyWithin(particles + a * cap * 16, m.particles + a * m.cap * 16, m.particles + (a + 1) * m.cap * 16);
974
+ heap.u8.copyWithin(out, m.out, m.out + m.cap * 32);
975
+ x.yali_dealloc(m.particles, m.cap * 96);
976
+ x.yali_dealloc(m.out, m.cap * 32);
977
+ }
978
+ Object.assign(m, { cap, particles, out });
979
+ }
980
+ if (this.#wordCount > m.stCap) {
981
+ const old = m.stCap;
982
+ const cap = nextPow2(this.#wordCount);
983
+ const st = alloc(cap * 4);
984
+ // The torn words keep what tore (a split appends a packing; the old ones stay), zero beyond.
985
+ const torn = alloc(cap * 4);
986
+ heap.u32.fill(0, torn >>> 2, (torn >>> 2) + cap);
987
+ if (old) {
988
+ heap.u32.copyWithin(torn >>> 2, m.torn >>> 2, (m.torn >>> 2) + old);
989
+ x.yali_dealloc(m.st, old * 4);
990
+ x.yali_dealloc(m.torn, old * 4);
991
+ }
992
+ Object.assign(m, { st, torn, stCap: cap, stWords: -1 });
993
+ }
994
+ if (m.stWords !== this.#wordCount) {
995
+ heap.u32.set(this.#words.subarray(0, this.#wordCount), m.st >>> 2);
996
+ m.stWords = this.#wordCount;
997
+ }
998
+ if (this.#instances.length * CLOTH_BYTES > m.clothCap) {
999
+ if (m.clothCap)
1000
+ x.yali_dealloc(m.cloths, m.clothCap);
1001
+ m.clothCap = nextPow2(this.#instances.length * CLOTH_BYTES);
1002
+ m.cloths = alloc(m.clothCap);
1003
+ }
1004
+ if (Math.max(1, this.#colliderCount + this.#anchorCount) * COLLIDER_BYTES > m.colliderCap) {
1005
+ if (m.colliderCap)
1006
+ x.yali_dealloc(m.colliders, m.colliderCap);
1007
+ m.colliderCap = nextPow2(Math.max(1, this.#colliderCount + this.#anchorCount) * COLLIDER_BYTES);
1008
+ m.colliders = alloc(m.colliderCap);
1009
+ }
1010
+ // New cloths: at rest and still, their output holding the rest pose's uvs.
1011
+ for (; m.uploaded < this.#instances.length; m.uploaded++) {
1012
+ const c = this.#instances[m.uploaded];
1013
+ const f = heap.f32;
1014
+ const p = m.particles >>> 2;
1015
+ f.set(c.initial, p + c.first * 4);
1016
+ f.set(c.initial, p + m.cap * 4 + c.first * 4);
1017
+ f.fill(0, p + m.cap * 8 + c.first * 4, p + m.cap * 8 + (c.first + c.count) * 4);
1018
+ f.set(c.initial, p + m.cap * 12 + c.first * 4);
1019
+ f.set(c.rest, (m.out >>> 2) + c.first * 8);
1020
+ c.sim.set(c.now);
1021
+ }
1022
+ return m;
1023
+ }
1024
+ #readTimer(t) {
1025
+ t.state = 'mapping';
1026
+ t.read.mapAsync(MAP_READ).then(() => {
1027
+ const v = new BigUint64Array(t.read.getMappedRange());
1028
+ const ms = Number(v[1] - v[0]) / 1e6;
1029
+ t.read.unmap();
1030
+ t.state = 'idle';
1031
+ if (ms >= 0 && ms < 1e4) {
1032
+ this.#gpuMs = ms;
1033
+ this.onGpuTime?.(ms);
1034
+ }
1035
+ }, () => (t.state = 'idle'));
1036
+ }
1037
+ /** The GPU state for `device`: built on first use and after a device loss (cloths restart from where they were created). */
1038
+ #ensure(device, encoder) {
1039
+ let g = this.#gpu;
1040
+ if (!g || g.device !== device) {
1041
+ g = this.#build(device);
1042
+ this.#gpu = g;
1043
+ }
1044
+ // Capacity: particles grow by copy (they hold the simulation); the rest is rewritten.
1045
+ if (this.#vertices > g.capVertices) {
1046
+ const cap = nextPow2(Math.max(1024, this.#vertices));
1047
+ const used = g.capVertices > 0 ? this.#instances[g.uploaded - 1] : null;
1048
+ const usedBytes = used ? (used.first + used.count) * 16 : 0;
1049
+ const next = [0, 1, 2, 3].map((k) => device.createBuffer({ label: `yali:cloth:${['pos', 'prev', 'vel', 'last'][k]}`, size: cap * 16, usage: STORAGE | COPY_DST | COPY_SRC }));
1050
+ if (usedBytes > 0)
1051
+ for (let k = 0; k < 4; k++)
1052
+ encoder.copyBufferToBuffer(g.particles[k], 0, next[k], 0, usedBytes);
1053
+ this.#trash.push(...g.particles, g.delta);
1054
+ g.particles = next;
1055
+ g.delta = device.createBuffer({ label: 'yali:cloth:delta', size: cap * 16, usage: STORAGE });
1056
+ g.capVertices = cap;
1057
+ g.stepGroup = g.writeGroup = g.selfGroup = null;
1058
+ }
1059
+ if (this.#wordCount > g.capWords) {
1060
+ this.#trash.push(g.st, g.torn);
1061
+ const old = g.torn;
1062
+ const oldWords = g.capWords;
1063
+ g.capWords = nextPow2(this.#wordCount);
1064
+ g.st = device.createBuffer({ label: 'yali:cloth:static', size: g.capWords * 4, usage: STORAGE | COPY_DST });
1065
+ // The tears so far survive the growth.
1066
+ g.torn = device.createBuffer({ label: 'yali:cloth:torn', size: g.capWords * 4, usage: STORAGE | COPY_DST | COPY_SRC });
1067
+ encoder.copyBufferToBuffer(old, 0, g.torn, 0, oldWords * 4);
1068
+ g.stWords = 0;
1069
+ g.stepGroup = g.writeGroup = g.selfGroup = null;
1070
+ }
1071
+ if (g.stWords !== this.#wordCount) {
1072
+ device.queue.writeBuffer(g.st, 0, this.#words.buffer, 0, this.#wordCount * 4);
1073
+ g.stWords = this.#wordCount;
1074
+ }
1075
+ const need = this.#instances.length * CLOTH_BYTES;
1076
+ if (need > g.cloths.size) {
1077
+ this.#trash.push(g.cloths);
1078
+ g.cloths = device.createBuffer({ label: 'yali:cloth:cloths', size: nextPow2(need), usage: STORAGE | COPY_DST });
1079
+ g.stepGroup = g.writeGroup = g.selfGroup = null;
1080
+ }
1081
+ if (this.#indexWords * 4 > g.triOut.size) {
1082
+ this.#trash.push(g.triOut);
1083
+ g.triOut = device.createBuffer({ label: 'yali:cloth:triangles', size: nextPow2(this.#indexWords * 4), usage: STORAGE | COPY_SRC });
1084
+ g.writeGroup = null;
1085
+ }
1086
+ if (this.#hashWords * 4 > g.hash.size) {
1087
+ this.#trash.push(g.hash);
1088
+ g.hash = device.createBuffer({ label: 'yali:cloth:hash', size: nextPow2(this.#hashWords * 4), usage: STORAGE });
1089
+ g.selfGroup = null;
1090
+ }
1091
+ const needC = Math.max(1, this.#colliderCount + this.#anchorCount) * COLLIDER_BYTES;
1092
+ if (needC > g.colliders.size) {
1093
+ this.#trash.push(g.colliders);
1094
+ g.colliders = device.createBuffer({ label: 'yali:cloth:colliders', size: nextPow2(needC), usage: STORAGE | COPY_DST });
1095
+ g.stepGroup = null;
1096
+ }
1097
+ // New cloths' particles: at rest, still.
1098
+ for (; g.uploaded < this.#instances.length; g.uploaded++) {
1099
+ const c = this.#instances[g.uploaded];
1100
+ const at = c.first * 16;
1101
+ device.queue.writeBuffer(g.particles[0], at, c.initial);
1102
+ device.queue.writeBuffer(g.particles[1], at, c.initial);
1103
+ device.queue.writeBuffer(g.particles[2], at, new Float32Array(c.count * 4));
1104
+ device.queue.writeBuffer(g.particles[3], at, c.initial);
1105
+ c.sim.set(c.now);
1106
+ }
1107
+ return g;
1108
+ }
1109
+ #build(device) {
1110
+ const module = device.createShaderModule({ label: 'yali:cloth', code: kandukaCloth.wgsl });
1111
+ const buf = (type, binding, dynamic = false) => ({ binding, visibility: COMPUTE, buffer: { type, hasDynamicOffset: dynamic } });
1112
+ const head = [buf('uniform', 0, true), buf('read-only-storage', 1), buf('read-only-storage', 2), buf('storage', 3)];
1113
+ const stepLayout = device.createBindGroupLayout({ label: 'yali:cloth:step', entries: [...head, buf('storage', 4), buf('storage', 5), buf('storage', 10), buf('read-only-storage', 7), buf('storage', 11)] });
1114
+ const writeLayout = device.createBindGroupLayout({ label: 'yali:cloth:write', entries: [...head, buf('storage', 6), buf('storage', 8), buf('storage', 11), buf('storage', 12)] });
1115
+ const selfLayout = device.createBindGroupLayout({ label: 'yali:cloth:self', entries: [...head, buf('storage', 5), buf('storage', 9), buf('storage', 10)] });
1116
+ // The widest power of two the device runs in one workgroup, up to the option.
1117
+ const most = Math.min(this.#workgroupOption, device.limits.maxComputeInvocationsPerWorkgroup, device.limits.maxComputeWorkgroupSizeX);
1118
+ this.workgroup = 2 ** Math.floor(Math.log2(Math.max(32, most)));
1119
+ const pipe = (layout, entryPoint) => device.createComputePipeline({ label: `yali:cloth:${entryPoint}`, layout: device.createPipelineLayout({ bindGroupLayouts: [layout] }), compute: { module, entryPoint, constants: { WG: this.workgroup } } });
1120
+ let timer = null;
1121
+ if (this.#render.gpu?.features.has('timestamp-query')) {
1122
+ timer = {
1123
+ query: device.createQuerySet({ type: 'timestamp', count: 2 }),
1124
+ resolve: device.createBuffer({ label: 'yali:cloth:ts', size: 16, usage: QUERY_RESOLVE | COPY_SRC }),
1125
+ read: device.createBuffer({ label: 'yali:cloth:ts-read', size: 16, usage: MAP_READ | COPY_DST }),
1126
+ state: 'idle',
1127
+ };
1128
+ }
1129
+ for (const c of this.#instances)
1130
+ c.sim.set(c.now);
1131
+ const widePipe = (entryPoint) => device.createComputePipeline({ label: `yali:cloth:${entryPoint}`, layout: device.createPipelineLayout({ bindGroupLayouts: [stepLayout] }), compute: { module, entryPoint, constants: { WG: this.workgroup } } });
1132
+ return {
1133
+ device,
1134
+ module,
1135
+ wide: {
1136
+ air: widePipe('w_air'),
1137
+ predict: widePipe('w_predict'),
1138
+ bend: widePipe('w_bend'),
1139
+ bendMore: device.createComputePipeline({ label: 'yali:cloth:w_bend:more', layout: device.createPipelineLayout({ bindGroupLayouts: [stepLayout] }), compute: { module, entryPoint: 'w_bend', constants: { WG: this.workgroup, BFIRST: 0 } } }),
1140
+ bendApply: widePipe('w_bend_apply'),
1141
+ tether: widePipe('w_tether'),
1142
+ collide: widePipe('w_collide'),
1143
+ stretch: [],
1144
+ vbd: { warm: widePipe('vw_warm'), predict: widePipe('vw_predict'), dual: widePipe('vw_dual'), colour: [] },
1145
+ self: { clear: pipe(selfLayout, 'sw_clear'), count: pipe(selfLayout, 'sw_count'), scan: pipe(selfLayout, 'sw_scan'), fill: pipe(selfLayout, 'sw_fill'), sort: pipe(selfLayout, 'sw_sort'), push: pipe(selfLayout, 'sw_push'), apply: pipe(selfLayout, 'sw_apply') },
1146
+ },
1147
+ wideParams: device.createBuffer({ label: 'yali:cloth:wide-params', size: 16 * SLOT, usage: UNIFORM | COPY_DST }),
1148
+ wideGroup: null,
1149
+ particles: [],
1150
+ capVertices: 0,
1151
+ st: device.createBuffer({ label: 'yali:cloth:static', size: 16, usage: STORAGE | COPY_DST }),
1152
+ capWords: 4,
1153
+ stWords: 0,
1154
+ cloths: device.createBuffer({ label: 'yali:cloth:cloths', size: CLOTH_BYTES, usage: STORAGE | COPY_DST }),
1155
+ colliders: device.createBuffer({ label: 'yali:cloth:colliders', size: COLLIDER_BYTES, usage: STORAGE | COPY_DST }),
1156
+ params: device.createBuffer({ label: 'yali:cloth:params', size: SLOTS * SLOT, usage: UNIFORM | COPY_DST }),
1157
+ stepLayout,
1158
+ writeLayout,
1159
+ step: pipe(stepLayout, 'step'),
1160
+ vbd: pipe(stepLayout, 'vbd'),
1161
+ write: pipe(writeLayout, 'write'),
1162
+ selfLayout,
1163
+ self: pipe(selfLayout, 'selfcollide'),
1164
+ selfGroup: null,
1165
+ hash: device.createBuffer({ label: 'yali:cloth:hash', size: 16, usage: STORAGE }),
1166
+ delta: device.createBuffer({ label: 'yali:cloth:delta', size: 16, usage: STORAGE }),
1167
+ torn: device.createBuffer({ label: 'yali:cloth:torn', size: 16, usage: STORAGE | COPY_DST | COPY_SRC }),
1168
+ triOut: device.createBuffer({ label: 'yali:cloth:triangles', size: 16, usage: STORAGE | COPY_SRC }),
1169
+ stepGroup: null,
1170
+ writeGroup: null,
1171
+ pool: null,
1172
+ uploaded: 0,
1173
+ timer,
1174
+ tornReads: new Map(),
1175
+ splitTmp: null,
1176
+ massTmp: null,
1177
+ };
1178
+ }
1179
+ #bind(g) {
1180
+ const [pos, prev, vel, last] = g.particles;
1181
+ const b = (binding, buffer, size) => ({ binding, resource: size ? { buffer, size } : { buffer } });
1182
+ const head = [b(0, g.params, PARAM_BYTES), b(1, g.cloths), b(2, g.st), b(3, pos)];
1183
+ if (!g.stepGroup)
1184
+ g.wideGroup = null;
1185
+ g.stepGroup ??= g.device.createBindGroup({ label: 'yali:cloth:step', layout: g.stepLayout, entries: [...head, b(4, prev), b(5, vel), b(10, g.delta), b(7, g.colliders), b(11, g.torn)] });
1186
+ g.writeGroup ??= g.device.createBindGroup({ label: 'yali:cloth:write', layout: g.writeLayout, entries: [...head, b(6, last), b(8, g.pool), b(11, g.torn), b(12, g.triOut)] });
1187
+ g.selfGroup ??= g.device.createBindGroup({ label: 'yali:cloth:self', layout: g.selfLayout, entries: [...head, b(5, vel), b(9, g.hash), b(10, g.delta)] });
1188
+ }
1189
+ /**
1190
+ * Reads every cloth's particles back from the GPU (a copy, a frame or more late): per cloth
1191
+ * its entity and positions in world space relative to the cloth's own entity origin (xyz per
1192
+ * vertex), for checks and tools. Null before the GPU has any.
1193
+ */
1194
+ /**
1195
+ * The wide step's sizes this frame (ADR-0110): the most vertices, stretch constraints per colour
1196
+ * and bending stencils any live cloth has; its parameter slots (step k's, with the substep index
1197
+ * in `iterations`) uploaded, and its pipelines for each colour made.
1198
+ */
1199
+ #wideLayout(g, slots, steps, substeps) {
1200
+ let vertices = 0;
1201
+ let bends = 0;
1202
+ let tethers = false;
1203
+ let constraints = 0;
1204
+ let table = 0;
1205
+ const colours = [];
1206
+ // VBD's vertex colours (ADR-0118): their sizes from the VBD header (offset, count).
1207
+ const vcolours = [];
1208
+ for (const c of this.#instances) {
1209
+ if (!c.live)
1210
+ continue;
1211
+ const w = c.packed.words;
1212
+ const at = c.packed.colours;
1213
+ const ns = w[c.packed.vbd + 13];
1214
+ vertices = Math.max(vertices, c.count);
1215
+ for (let k = 0; k < ns; k++)
1216
+ colours[k] = Math.max(colours[k] ?? 0, w[at + k + 1] - w[at + k]);
1217
+ if (ns < c.packed.ncolours)
1218
+ bends = Math.max(bends, w[at + ns + 1] - w[at + ns]);
1219
+ tethers ||= w[c.packed.vbd + 5] !== 0;
1220
+ constraints = Math.max(constraints, w[at + c.packed.ncolours]);
1221
+ table = Math.max(table, c.table);
1222
+ const vc = w[c.packed.vbd + 1];
1223
+ const ncv = w[c.packed.vbd + 2];
1224
+ for (let k = 0; k < ncv; k++)
1225
+ vcolours[k] = Math.max(vcolours[k] ?? 0, w[vc + k + 1] - w[vc + k]);
1226
+ }
1227
+ for (let k = g.wide.stretch.length; k < colours.length; k++)
1228
+ g.wide.stretch.push(this.#widePipe(g, 'w_stretch', k));
1229
+ if (this.solver === 'vbd')
1230
+ for (let k = g.wide.vbd.colour.length; k < vcolours.length; k++)
1231
+ g.wide.vbd.colour.push(this.#widePipe(g, 'vw_colour', k));
1232
+ const n = steps * substeps;
1233
+ if (g.wideParams.size < n * SLOT) {
1234
+ g.wideParams.destroy();
1235
+ g.wideParams = g.device.createBuffer({ label: 'yali:cloth:wide-params', size: nextPow2(n * SLOT), usage: UNIFORM | COPY_DST });
1236
+ g.wideGroup = null;
1237
+ }
1238
+ const out = new Uint32Array(n * (SLOT / 4));
1239
+ const src = new Uint32Array(slots);
1240
+ for (let k = 0; k < steps; k++) {
1241
+ for (let sub = 0; sub < substeps; sub++) {
1242
+ const o = (k * substeps + sub) * (SLOT / 4);
1243
+ out.set(src.subarray(k * (SLOT / 4), k * (SLOT / 4) + PARAM_BYTES / 4), o);
1244
+ out[o + 19] = sub;
1245
+ }
1246
+ }
1247
+ g.device.queue.writeBuffer(g.wideParams, 0, out);
1248
+ if (!g.wideGroup) {
1249
+ const [pos, prev, vel] = g.particles;
1250
+ const b = (binding, buffer, size) => ({ binding, resource: size ? { buffer, size } : { buffer } });
1251
+ g.wideGroup = g.device.createBindGroup({ label: 'yali:cloth:wide', layout: g.stepLayout, entries: [b(0, g.wideParams, PARAM_BYTES), b(1, g.cloths), b(2, g.st), b(3, pos), b(4, prev), b(5, vel), b(10, g.delta), b(7, g.colliders), b(11, g.torn)] });
1252
+ }
1253
+ return { vertices, colours, bends, tethers, constraints, vcolours, table };
1254
+ }
1255
+ /** A wide-step pipeline (ADR-0110) on the step's layout; `colour` for `w_stretch`. */
1256
+ #widePipe(g, entryPoint, colour = 0) {
1257
+ return g.device.createComputePipeline({
1258
+ label: `yali:cloth:${entryPoint}${entryPoint === 'w_stretch' || entryPoint === 'vw_colour' ? colour : ''}`,
1259
+ layout: g.device.createPipelineLayout({ bindGroupLayouts: [g.stepLayout] }),
1260
+ compute: { module: g.module, entryPoint, constants: { WG: this.workgroup, ...(entryPoint === 'w_stretch' ? { WCOLOUR: colour } : entryPoint === 'vw_colour' ? { VCOLOUR: colour } : {}) } },
1261
+ });
1262
+ }
1263
+ /** Where a cloth's compact tear flags are in the `torn` buffer (words), and how many. */
1264
+ #tornRange(c) {
1265
+ return [c.base + c.packed.words[c.packed.vbd + 12], c.constraints];
1266
+ }
1267
+ /** The tearable cloths' tear flags, copied every `splitEvery` frames and mapped a frame later (ADR-0108). */
1268
+ #readTears(encoder, g) {
1269
+ for (const c of this.#instances) {
1270
+ if (!c.live || c.tear <= 0)
1271
+ continue;
1272
+ let buf = g.tornReads.get(c);
1273
+ if (c.tornRead === 'copied' && buf) {
1274
+ c.tornRead = 'mapping';
1275
+ const base = c.base;
1276
+ const b = buf;
1277
+ b.mapAsync(MAP_READ).then(() => {
1278
+ const flags = new Uint32Array(b.getMappedRange().slice(0));
1279
+ b.unmap();
1280
+ c.tornRead = 'idle';
1281
+ if (c.base === base)
1282
+ this.#split(c, flags);
1283
+ }, () => (c.tornRead = 'idle'));
1284
+ continue;
1285
+ }
1286
+ if (c.tornRead !== 'idle' || this.#frame % this.splitEvery !== 0)
1287
+ continue;
1288
+ const [at, n] = this.#tornRange(c);
1289
+ if (!buf || buf.size < n * 4) {
1290
+ buf?.destroy();
1291
+ buf = g.device.createBuffer({ label: 'yali:cloth:torn-read', size: nextPow2(n * 4), usage: MAP_READ | COPY_DST });
1292
+ g.tornReads.set(c, buf);
1293
+ }
1294
+ encoder.copyBufferToBuffer(g.torn, at * 4, buf, 0, n * 4);
1295
+ c.tornRead = 'copied';
1296
+ }
1297
+ }
1298
+ /**
1299
+ * Splits every tearable cloth's torn edges now (ADR-0108), waiting for the readback: deterministic
1300
+ * runs (goldens) call it with `splitEvery = 0` between steps.
1301
+ */
1302
+ async flushTears() {
1303
+ if (this.#cpu) {
1304
+ this.#cpuTears(this.#cpu);
1305
+ return;
1306
+ }
1307
+ const g = this.#gpu;
1308
+ if (!g)
1309
+ return;
1310
+ for (const c of this.#instances) {
1311
+ if (!c.live || c.tear <= 0 || c.tornRead !== 'idle')
1312
+ continue;
1313
+ const [at, n] = this.#tornRange(c);
1314
+ const buf = g.device.createBuffer({ label: 'yali:cloth:torn-flush', size: n * 4, usage: MAP_READ | COPY_DST });
1315
+ const enc = g.device.createCommandEncoder();
1316
+ enc.copyBufferToBuffer(g.torn, at * 4, buf, 0, n * 4);
1317
+ g.device.queue.submit([enc.finish()]);
1318
+ await buf.mapAsync(MAP_READ);
1319
+ const flags = new Uint32Array(buf.getMappedRange().slice(0));
1320
+ buf.unmap();
1321
+ buf.destroy();
1322
+ this.#split(c, flags);
1323
+ }
1324
+ }
1325
+ /**
1326
+ * Splits cloth `c` along the stretch constraints `flags` marks torn (ADR-0108): new vertices from
1327
+ * its spare slots, a new packing appended to the static words (the old one is left), and the
1328
+ * render mesh's vertices (their uvs) uploaded. The split vertices keep their source's inverse
1329
+ * mass (a small excess of mass at cut edges).
1330
+ */
1331
+ #split(c, flags) {
1332
+ const pk = c.packed;
1333
+ const w = pk.words;
1334
+ const pairs = [];
1335
+ // The edges torn since the last report, each reported once per packing (ADR-0115).
1336
+ const fresh = [];
1337
+ for (let j = 0; j < Math.min(flags.length, c.constraints); j++) {
1338
+ const o = pk.cons + j * 12;
1339
+ if (!flags[j] || w[o + 2] !== NO_VERTEX)
1340
+ continue;
1341
+ pairs.push([w[o], w[o + 1]]);
1342
+ if (!c.reported[j]) {
1343
+ c.reported[j] = 1;
1344
+ fresh.push(w[o], w[o + 1]);
1345
+ }
1346
+ }
1347
+ this.#splitEdges(c, pairs);
1348
+ if (fresh.length > 0) {
1349
+ c.tornTotal += fresh.length / 2;
1350
+ const e = { entity: c.entity, edges: Uint32Array.from(fresh), total: c.tornTotal };
1351
+ for (const fn of this.#tornListeners)
1352
+ fn(e);
1353
+ }
1354
+ }
1355
+ #tornListeners = new Set();
1356
+ /**
1357
+ * Calls `fn` when a cloth tears (ADR-0115), a frame or a few after the tear (when its flags are read
1358
+ * back). Returns the unsubscribe.
1359
+ *
1360
+ * @example
1361
+ * const off = cloth.onTorn((e) => audio.play(rip, { at: e.entity }));
1362
+ */
1363
+ onTorn(fn) {
1364
+ this.#tornListeners.add(fn);
1365
+ return () => void this.#tornListeners.delete(fn);
1366
+ }
1367
+ #splitEdges(c, pairs) {
1368
+ const res = pairs.length ? splitTorn(c.topo.mesh, pairs) : null;
1369
+ if (!res)
1370
+ return;
1371
+ const n = res.mesh.positions.length / 3;
1372
+ if (n > c.cap) {
1373
+ if (!this.#splitWarned) {
1374
+ this.#splitWarned = true;
1375
+ console.warn(`[yali] cloth: a torn cloth used its ${c.cap - c.topo.vertices} spare vertices; further tears open as gaps, not clean cuts (ADR-0108).`);
1376
+ }
1377
+ return;
1378
+ }
1379
+ const old = c.count;
1380
+ const topo = clothTopology(res.mesh);
1381
+ let map = null;
1382
+ if (c.anchors) {
1383
+ map = new Uint32Array(n);
1384
+ map.set(c.anchors.map.subarray(0, old));
1385
+ res.source.forEach((v, k) => (map[old + k] = c.anchors.map[v]));
1386
+ c.anchors.map = map;
1387
+ }
1388
+ const packed = packCloth(topo, c.fabric, c.scale, c.tether, map);
1389
+ const base = this.#wordCount;
1390
+ if (base + packed.words.length > this.#words.length) {
1391
+ const grown = new Uint32Array(Math.max(this.#words.length * 2, base + packed.words.length));
1392
+ grown.set(this.#words.subarray(0, base));
1393
+ this.#words = grown;
1394
+ }
1395
+ this.#words.set(packed.words, base);
1396
+ this.#wordCount += packed.words.length;
1397
+ const oldMass = c.packed.invMass;
1398
+ Object.assign(c, { packed, topo, base, count: n, constraints: topo.kinds.length, reported: new Uint8Array(topo.kinds.length) });
1399
+ res.source.forEach((v, k) => {
1400
+ c.copies.push(v, old + k);
1401
+ c.rest.copyWithin((old + k) * 8, v * 8, v * 8 + 8);
1402
+ c.initial.copyWithin((old + k) * 4, v * 4, v * 4 + 4);
1403
+ });
1404
+ // Each vertex's mass is its share of its triangles' area (ADR-0115): a split vertex and its
1405
+ // source keep only their own sides', so the cut does not add mass (they kept the source's before).
1406
+ for (let v = 0; v < n; v++) {
1407
+ const m = packed.invMass[v];
1408
+ if (v < old && m === oldMass[v])
1409
+ continue;
1410
+ F32[0] = m;
1411
+ c.masses.push(v, U32[0]);
1412
+ c.initial[v * 4 + 3] = m;
1413
+ }
1414
+ this.#render.meshes.updateVertices(c.mesh, c.rest);
1415
+ }
1416
+ async readPositions() {
1417
+ const m = this.#cpu;
1418
+ if (m && m.uploaded > 0) {
1419
+ const heap = this.world.core.heap;
1420
+ heap.refresh();
1421
+ return this.#perCloth(heap.f32.slice(m.particles >>> 2, (m.particles >>> 2) + m.cap * 4), m.uploaded);
1422
+ }
1423
+ const g = this.#gpu;
1424
+ if (!g || g.particles.length === 0 || g.uploaded === 0)
1425
+ return null;
1426
+ const used = this.#instances[g.uploaded - 1];
1427
+ const bytes = (used.first + used.count) * 16;
1428
+ const read = g.device.createBuffer({ label: 'yali:cloth:readback', size: bytes, usage: MAP_READ | COPY_DST });
1429
+ const enc = g.device.createCommandEncoder();
1430
+ enc.copyBufferToBuffer(g.particles[0], 0, read, 0, bytes);
1431
+ g.device.queue.submit([enc.finish()]);
1432
+ await read.mapAsync(MAP_READ);
1433
+ const all = new Float32Array(read.getMappedRange().slice(0));
1434
+ read.unmap();
1435
+ read.destroy();
1436
+ return this.#perCloth(all, g.uploaded);
1437
+ }
1438
+ /** Each of the first `n` cloths' positions (xyz) from a copy of the `pos` array, relative to its entity. */
1439
+ #perCloth(all, n) {
1440
+ return this.#instances.slice(0, n).map((c) => {
1441
+ const positions = new Float32Array(c.count * 3);
1442
+ for (let v = 0; v < c.count; v++)
1443
+ for (let k = 0; k < 3; k++)
1444
+ positions[v * 3 + k] = all[(c.first + v) * 4 + k] - c.now[k];
1445
+ return { entity: c.entity, positions };
1446
+ });
1447
+ }
1448
+ /** Releases the GPU buffers. */
1449
+ destroy() {
1450
+ const g = this.#gpu;
1451
+ if (!g)
1452
+ return;
1453
+ for (const b of [...g.particles, g.st, g.cloths, g.colliders, g.params, g.hash, g.delta, g.torn, g.triOut, ...this.#trash])
1454
+ b.destroy();
1455
+ g.timer?.query.destroy();
1456
+ for (const b of g.tornReads.values())
1457
+ b.destroy();
1458
+ g.splitTmp?.destroy();
1459
+ g.massTmp?.destroy();
1460
+ g.wideParams.destroy();
1461
+ this.#gpu = null;
1462
+ this.#trash.length = 0;
1463
+ }
1464
+ /** GPU buffers held (the dispose leak check). */
1465
+ get liveBuffers() {
1466
+ return this.#gpu ? this.#gpu.particles.length + 4 : 0;
1467
+ }
1468
+ }
1469
+ /**
1470
+ * Kanduka's GPU cloth as a plugin (ADR-0101): provides the `cloth` resource (`ClothSystem`) and
1471
+ * its `physics.Cloth` component, steps every cloth on the GPU each fixed step and draws it through
1472
+ * its entity's `render.Mesh`. Needs `@yali-engine/render` (WebGPU core simulates; WebGL2 and compatibility
1473
+ * mode draw cloths at rest). Colliders are the scene's `physics.Collider` spheres, capsules and
1474
+ * boxes; the cloth does not push them back yet.
1475
+ *
1476
+ * @example
1477
+ * const app = App.create({ core, plugins: [transformsPlugin(), renderPlugin({ canvas }), clothPlugin({ wind: { velocity: [6, 0, 0] } }), game] });
1478
+ * // in game's build:
1479
+ * const clothSys = b.app.resource<ClothSystem>('cloth');
1480
+ * const flag = clothSys.addAsset(clothGrid({ width: 3, height: 2, pin: 'left' }));
1481
+ * const e = world.spawn([...bundle, render.Mesh!, clothSys.Cloth]);
1482
+ * world.insert(e, clothSys.Cloth, cloth(flag, { fabric: 'canvas' }));
1483
+ */
1484
+ export function clothPlugin(o = {}) {
1485
+ return {
1486
+ name: '@yali-engine/physics/gpu',
1487
+ version: '0.4.0',
1488
+ dependencies: [
1489
+ { name: '@yali-engine/render', range: '^0.4.0' },
1490
+ { name: '@yali-engine/transforms', range: '^0.4.0', optional: true },
1491
+ ],
1492
+ build(b) {
1493
+ const render = b.app.resource('render');
1494
+ const sys = new ClothSystem(b.app, render, o);
1495
+ b.provide('cloth', sys);
1496
+ b.system({ name: 'cloth.extract', stage: 'Extract', before: ['render.extract'], run: () => sys.extract() });
1497
+ render.prepasses.push((encoder, pool, indexPool) => sys.encode(encoder, pool, indexPool));
1498
+ const scope = b.app.profiler.gpuScope('physics.cloth');
1499
+ sys.onGpuTime = (ms) => b.app.profiler.gpu(scope, ms);
1500
+ b.counter('cloth.vertices', () => sys.stats().vertices);
1501
+ b.tool({
1502
+ name: 'physics.cloth',
1503
+ description: 'Cloth counts (cloths, vertices, constraints, colours, colliders, GPU ms) and the wind; sets the wind, substeps or pause when given.',
1504
+ input: {
1505
+ type: 'object',
1506
+ properties: {
1507
+ wind: { type: 'array', description: 'Mean wind velocity [x, y, z], m/s.' },
1508
+ turbulence: { type: 'number', description: 'Turbulence, a share of the wind speed (0–1).' },
1509
+ substeps: { type: 'number', description: 'Solver substeps per fixed step.' },
1510
+ paused: { type: 'boolean', description: 'Stop or resume the clock-driven steps.' },
1511
+ solver: { type: 'string', description: 'The GPU solver: xpbd or vbd (ADR-0105).' },
1512
+ iterations: { type: 'number', description: 'VBD iterations per substep.' },
1513
+ },
1514
+ },
1515
+ run: (args) => {
1516
+ const w = args.wind;
1517
+ if (w) {
1518
+ if (!(Array.isArray(w) && w.length === 3 && w.every((x) => Number.isFinite(x))))
1519
+ throw Object.assign(new YaliError('PHYSICS_INVALID', 'wind must be [x, y, z] in m/s.', 'Pass three finite numbers.'), { path: 'wind' });
1520
+ sys.wind.velocity = [w[0], w[1], w[2]];
1521
+ }
1522
+ if (typeof args.turbulence === 'number')
1523
+ sys.wind.turbulence = args.turbulence;
1524
+ if (typeof args.substeps === 'number')
1525
+ sys.substeps = Math.max(1, Math.round(args.substeps));
1526
+ if (typeof args.paused === 'boolean')
1527
+ sys.paused = args.paused;
1528
+ if (args.solver !== undefined) {
1529
+ if (args.solver !== 'xpbd' && args.solver !== 'vbd')
1530
+ throw Object.assign(new YaliError('PHYSICS_INVALID', `solver ${JSON.stringify(args.solver)}.`, 'Use "xpbd" or "vbd".'), { path: 'solver' });
1531
+ sys.solver = args.solver;
1532
+ }
1533
+ if (typeof args.iterations === 'number')
1534
+ sys.vbdIterations = Math.max(1, Math.round(args.iterations));
1535
+ return { ...sys.stats(), wind: { ...sys.wind }, substeps: sys.solver === 'vbd' ? sys.vbdSubsteps : sys.substeps, solver: sys.solver, iterations: sys.vbdIterations, paused: sys.paused };
1536
+ },
1537
+ });
1538
+ b.onDispose(() => sys.destroy());
1539
+ },
1540
+ };
1541
+ }
1542
+ /** One float's bits (the split's inverse masses go to the GPU as words). */
1543
+ const F32 = new Float32Array(1);
1544
+ const U32 = new Uint32Array(F32.buffer);
1545
+ function nextPow2(n) {
1546
+ return 2 ** Math.ceil(Math.log2(Math.max(1, n)));
1547
+ }
1548
+ /** `x` through the inverse of the 3 × 4 affine `m` (row-major): the spot `m` maps to `x` (ADR-0108). */
1549
+ function unskin(m, x) {
1550
+ const [a, b, c, d, e, f, g, h, i] = [m[0], m[1], m[2], m[4], m[5], m[6], m[8], m[9], m[10]];
1551
+ const y = [x[0] - m[3], x[1] - m[7], x[2] - m[11]];
1552
+ const det = a * (e * i - f * h) - b * (d * i - f * g) + c * (d * h - e * g);
1553
+ if (Math.abs(det) < 1e-12)
1554
+ return y;
1555
+ return [
1556
+ ((e * i - f * h) * y[0] - (b * i - c * h) * y[1] + (b * f - c * e) * y[2]) / det,
1557
+ (-(d * i - f * g) * y[0] + (a * i - c * g) * y[1] - (a * f - c * d) * y[2]) / det,
1558
+ ((d * h - e * g) * y[0] - (a * h - b * g) * y[1] + (a * e - b * d) * y[2]) / det,
1559
+ ];
1560
+ }
1561
+ /** `q` (x, y, z, w) applied to `v`. */
1562
+ function rotate(q, v) {
1563
+ const [x, y, z, w] = q;
1564
+ const tx = 2 * (y * v[2] - z * v[1]);
1565
+ const ty = 2 * (z * v[0] - x * v[2]);
1566
+ const tz = 2 * (x * v[1] - y * v[0]);
1567
+ return [v[0] + w * tx + (y * tz - z * ty), v[1] + w * ty + (z * tx - x * tz), v[2] + w * tz + (x * ty - y * tx)];
1568
+ }
1569
+ /** a · b (x, y, z, w). */
1570
+ function quatMul(a, b) {
1571
+ return [
1572
+ a[3] * b[0] + a[0] * b[3] + a[1] * b[2] - a[2] * b[1],
1573
+ a[3] * b[1] - a[0] * b[2] + a[1] * b[3] + a[2] * b[0],
1574
+ a[3] * b[2] + a[0] * b[1] - a[1] * b[0] + a[2] * b[3],
1575
+ a[3] * b[3] - a[0] * b[0] - a[1] * b[1] - a[2] * b[2],
1576
+ ];
1577
+ }
1578
+ //# sourceMappingURL=cloth.js.map