@vgai/engine 0.2.0 → 0.4.0-canary.20260715.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.
Files changed (123) hide show
  1. package/README.md +3 -1
  2. package/package.json +24 -4
  3. package/schemas/engine-api.json +124 -0
  4. package/schemas/engine-api.md +53 -0
  5. package/schemas/engine-capabilities.json +124 -0
  6. package/schemas/inputmap.schema.json +314 -0
  7. package/schemas/mat.schema.json +286 -0
  8. package/schemas/prefab.schema.json +10148 -0
  9. package/schemas/scn2d.schema.json +475 -0
  10. package/schemas/vgai-game.schema.json +383 -0
  11. package/schemas/vscn.schema.json +11007 -0
  12. package/src/adapter/{world-kind.ts → adapter-surface.ts} +6 -6
  13. package/src/adapter/authoring.ts +77 -0
  14. package/src/adapter/first-party-systems.ts +23 -34
  15. package/src/adapter/game-adapter.ts +8 -8
  16. package/src/adapter/host-context.ts +2 -4
  17. package/src/adapter/index.ts +4 -4
  18. package/src/adapter/system-adapter.ts +88 -22
  19. package/src/adapter/vgai-scene-game-adapter.ts +244 -194
  20. package/src/animation/anim-graph-types.ts +12 -43
  21. package/src/animation/animation-clock.ts +479 -0
  22. package/src/animation/camera-ownership.ts +467 -0
  23. package/src/animation/cinematic-cues.ts +451 -0
  24. package/src/animation/clip-map.ts +41 -0
  25. package/src/animation/gsap-registration.ts +184 -0
  26. package/src/animation/theatre-clock-binding.ts +111 -0
  27. package/src/animation/theatre-director.ts +347 -0
  28. package/src/animation/theatre-object-binding.ts +661 -0
  29. package/src/animation/xstate-animation-binding.ts +436 -0
  30. package/src/animation/xstate-animation-meta.ts +319 -0
  31. package/src/audio/index.ts +39 -7
  32. package/src/audio/tone-clock-binding.ts +98 -0
  33. package/src/audio/tone-context.ts +129 -0
  34. package/src/audio/tone-offline-render.ts +167 -0
  35. package/src/audio/wav-encode.ts +119 -0
  36. package/src/character/cloth-sim.ts +533 -0
  37. package/src/character/spring-chain.ts +307 -0
  38. package/src/core/game-loop.ts +57 -2
  39. package/src/core/seeded-random.ts +161 -0
  40. package/src/core/system-runner.ts +20 -3
  41. package/src/core/types.ts +50 -0
  42. package/src/data/data-asset.ts +167 -0
  43. package/src/data/data-check-core.ts +242 -0
  44. package/src/data/data-ref.ts +145 -0
  45. package/src/data/vite-plugin-data.ts +290 -0
  46. package/src/dev/performance-profiler.ts +213 -0
  47. package/src/dev/webgl-gpu-timer.ts +53 -0
  48. package/src/ecs/component-manager.ts +45 -12
  49. package/src/ecs/game-component.ts +95 -11
  50. package/src/humanoid/bake.operation.ts +326 -0
  51. package/src/humanoid/body.ts +663 -0
  52. package/src/humanoid/clips.ts +149 -0
  53. package/src/humanoid/compose.ts +209 -0
  54. package/src/humanoid/generate.ts +189 -0
  55. package/src/humanoid/index.ts +36 -0
  56. package/src/humanoid/schema.ts +108 -0
  57. package/src/humanoid/skeleton.ts +345 -0
  58. package/src/index.ts +48 -0
  59. package/src/input/input-manager.ts +1886 -33
  60. package/src/input/input-types.ts +158 -3
  61. package/src/input/prompt-labels.ts +122 -0
  62. package/src/input/rebind-controller.ts +105 -0
  63. package/src/input/schema.ts +206 -52
  64. package/src/manifest/index.ts +5 -5
  65. package/src/manifest/load.ts +125 -72
  66. package/src/manifest/schema.ts +362 -255
  67. package/src/react/game-state.tsx +135 -32
  68. package/src/react/root-adapter.tsx +49 -0
  69. package/src/react/unmanaged-root-detector.ts +66 -0
  70. package/src/react/use-data.ts +124 -0
  71. package/src/react/use-selection.tsx +135 -0
  72. package/src/runtime/create-runtime.ts +112 -273
  73. package/src/runtime/debug-bridge.ts +483 -0
  74. package/src/runtime/debug-registry.ts +856 -0
  75. package/src/runtime/game.ts +342 -93
  76. package/src/runtime/gameplay-rng-trap.ts +134 -0
  77. package/src/runtime/input-router.ts +7 -7
  78. package/src/runtime/mount-game.ts +40 -38
  79. package/src/runtime/mount-manifest.ts +169 -37
  80. package/src/runtime/render-audio-control.ts +168 -0
  81. package/src/runtime/render-control.ts +522 -0
  82. package/src/runtime/render-seed.ts +79 -0
  83. package/src/runtime/state-bridge.ts +24 -10
  84. package/src/runtime/types.ts +110 -33
  85. package/src/scene/asset-loaders.ts +10 -36
  86. package/src/scene/asset-paths.ts +0 -2
  87. package/src/scene/asset-ref-check.ts +248 -0
  88. package/src/scene/asset-registry.ts +22 -0
  89. package/src/scene/component-registry.ts +14 -3
  90. package/src/scene/defaults.ts +1 -0
  91. package/src/scene/light-camera-factory.ts +11 -3
  92. package/src/scene/parse.ts +133 -0
  93. package/src/scene/scene-apply.ts +55 -4
  94. package/src/scene/scene-loader.ts +91 -123
  95. package/src/scene/scene-types.ts +0 -1
  96. package/src/scene/schema/animation.ts +30 -79
  97. package/src/scene/schema/entity.ts +20 -0
  98. package/src/scene/schema/index.ts +2 -46
  99. package/src/scene/schema/light.ts +16 -1
  100. package/src/scene/schema/material.ts +96 -91
  101. package/src/scene/schema/scene-file.ts +1 -7
  102. package/src/scene/user-data.ts +22 -10
  103. package/src/setup/setup-renderer.ts +10 -3
  104. package/src/tools/define-tool.ts +191 -0
  105. package/src/world2d/authoring-2d.ts +17 -1
  106. package/src/world2d/collision-2d.ts +1 -1
  107. package/src/world2d/pixi-game-adapter.ts +19 -17
  108. package/src/world2d/scene2d-loader.ts +1 -0
  109. package/src/world2d/types.ts +8 -2
  110. package/src/animation/anim-graph.ts +0 -406
  111. package/src/animation/anim-system.ts +0 -28
  112. package/src/animation/property-track.ts +0 -178
  113. package/src/animation/schema.ts +0 -204
  114. package/src/audio/ambient.ts +0 -300
  115. package/src/audio/impacts.ts +0 -212
  116. package/src/audio/movement.ts +0 -140
  117. package/src/audio/musical.ts +0 -200
  118. package/src/audio/ui-sounds.ts +0 -171
  119. package/src/audio/vehicle.ts +0 -235
  120. package/src/audio/weapons.ts +0 -152
  121. package/src/runtime/scene-ui-bridge.ts +0 -86
  122. package/src/runtime/scene-ui-data.ts +0 -119
  123. package/src/scene/schema/ui.ts +0 -602
@@ -0,0 +1,533 @@
1
+ /**
2
+ * Character primitive — Jolt soft-body cloth grids (capes, skirts, banners)
3
+ * on top of the raw `jolt-physics` WASM bindings.
4
+ *
5
+ * THIN COMPOSITION HELPER, not a wrapper (CLAUDE.md "use libraries
6
+ * directly"): every export assembles the library's own objects
7
+ * (`Jolt.SoftBodySharedSettings`, `Jolt.Body`, `Jolt.JoltInterface`) —
8
+ * nothing from the library is re-exported. This file never imports the WASM
9
+ * module itself — the caller does the async `JoltInit()` and passes the
10
+ * resolved module in, mirroring `setup-physics.ts`'s `setupPhysics(rapier:
11
+ * typeof RAPIER, ...)` pattern for Rapier. `import type Jolt from
12
+ * 'jolt-physics/wasm-compat'` below is TYPE-ONLY (erased at compile time —
13
+ * no runtime WASM import here).
14
+ *
15
+ * Sharp edges this module exists partly to contain (see the mission brief /
16
+ * `cloth-hair-bench/jolt-cloth.ts`'s header for the full discovery):
17
+ * - `Body.GetMotionProperties()` needs the `Jolt.castObject` downcast to
18
+ * `Jolt.SoftBodyMotionProperties` to reach `GetVertex`.
19
+ * - `SoftBodySharedSettings.CreateConstraints` takes a POINTER-typed
20
+ * 1-element `ArraySoftBodySharedSettingsVertexAttributes` (push_back,
21
+ * not resize+at — `.at()` on these embind vectors returns a copy).
22
+ * - Kinematic pins must set BOTH `SoftBodyVertex.mPosition` and
23
+ * `.mVelocity` (finite-differenced) every tick, or the cloth's own
24
+ * constraint solver fights a teleporting anchor.
25
+ * - NO per-frame embind allocations (reviewer finding on Phase 1's
26
+ * spike, which leaked a `new Jolt.Vec3(...)`/`new Jolt.RVec3(...)` per
27
+ * pinned vertex per frame): `tick()` below preallocates ONE `Jolt.Vec3`
28
+ * for position and ONE for velocity and reuses them via `.Set(...)` —
29
+ * embind property setters (`vertex.mPosition = tmpVec3`) copy the value
30
+ * in, so reassigning the SAME wrapper object across many vertices in one
31
+ * frame is safe. `KinematicCollider.moveTo` does the same with one
32
+ * `Jolt.RVec3` + one `Jolt.Quat` (identity, orientation is not simulated
33
+ * here) allocated once at construction.
34
+ * - CONSTRUCTION-TIME embind temporaries need `Jolt.destroy(...)` too, once
35
+ * Jolt has copied their data out (reviewer finding on Phase 2 — these
36
+ * were leaking WASM heap per build/dispose cycle even with `tick()`
37
+ * itself allocation-free). Two different lifetimes are at play:
38
+ * - Per-vertex/per-face grid temporaries (`SoftBodySharedSettingsVertex`
39
+ * + its `Float3`, `SoftBodySharedSettingsFace`, the single
40
+ * `vertexAttr`) are copied BY VALUE into `sharedSettings`'s own
41
+ * storage the instant `push_back`/the property setter runs — freed
42
+ * right there, matching the reuse-then-`Jolt.destroy()`-once pattern
43
+ * in Jolt's own `soft-body-creator.js` sample. `bodyCreation`
44
+ * (`SoftBodyCreationSettings`) is the same story: `CreateSoftBody`
45
+ * copies what it needs, so it's freed right after — same contract as
46
+ * `JoltSettings` → `JoltInterface` below.
47
+ * - `sharedSettings` itself (`SoftBodySharedSettings`) is different: it
48
+ * is REFCOUNTED (`AddRef`/`Release`/`GetRefCount` in the vendored
49
+ * typings, like `Shape`) and the body reads it every step
50
+ * (`SoftBodyMotionProperties.GetSettings()`) for face/constraint
51
+ * topology. Refcounted RefTargets are freed BY JOLT ITSELF when the
52
+ * last reference drops (Release of the final ref IS the delete) —
53
+ * so it gets NO explicit `Jolt.destroy(...)` at all: destroying
54
+ * `bodyCreation` releases the ctor's ref, and `dispose()`'s
55
+ * `DestroyBody` releases the body's (last) ref, which deletes it.
56
+ * An explicit destroy on top would be a use-after-free (empirically
57
+ * confirmed with a `GetRefCount()` probe against real Jolt WASM).
58
+ * Same rule for the collider's `Shape`: `destroy(creation)` releases
59
+ * the creation-settings ref, `DestroyBody` releases the body's —
60
+ * never destroy the shape directly.
61
+ * - The preallocated tick-loop temporaries described above (`tmpPos`/
62
+ * `tmpVel` in `buildClothGrid`, `tmpPos`/`identityQuat` in
63
+ * `buildKinematicCollider`) are plain (non-refcounted) `Vec3`/`RVec3`/
64
+ * `Quat` wrappers reused for the object's whole lifetime — freed in that
65
+ * object's own `dispose()`.
66
+ */
67
+
68
+ import type JoltNS from 'jolt-physics/wasm-compat';
69
+ import * as THREE from 'three';
70
+
71
+ /** The type of an ALREADY-INITIALIZED `jolt-physics/wasm-compat` module
72
+ * (i.e. what `await JoltInit()` resolves to) — the parameter type every
73
+ * function below takes instead of importing/initializing Jolt itself. */
74
+ export type JoltModule = typeof JoltNS;
75
+
76
+ const LAYER_NON_MOVING = 0;
77
+ const LAYER_MOVING = 1;
78
+ const NUM_OBJECT_LAYERS = 2;
79
+ const NUM_BROAD_PHASE_LAYERS = 2;
80
+
81
+ // ---------------------------------------------------------------------------
82
+ // Jolt world (the minimal two-layer broadphase boilerplate every Jolt scene
83
+ // needs, regardless of what bodies it holds)
84
+ // ---------------------------------------------------------------------------
85
+
86
+ export interface JoltWorld {
87
+ jolt: JoltModule;
88
+ joltInterface: JoltNS.JoltInterface;
89
+ physicsSystem: JoltNS.PhysicsSystem;
90
+ bodyInterface: JoltNS.BodyInterface;
91
+ /** The single "moving" object layer every body built through this world's
92
+ * helpers is placed on (cloth + kinematic colliders all collide with
93
+ * each other; nothing here needs more than one moving layer). */
94
+ movingLayer: number;
95
+ /** Advance the simulation by `dt` seconds (`options.collisionSteps` Jolt
96
+ * collision sub-steps). Position any kinematic bodies (colliders, cloth
97
+ * pins) for this frame BEFORE calling `step` — this is what
98
+ * `ClothGrid.tick` does internally. */
99
+ step(dt: number): void;
100
+ dispose(): void;
101
+ }
102
+
103
+ export interface JoltWorldOptions {
104
+ gravity?: { x: number; y: number; z: number };
105
+ /** Jolt collision sub-steps per `step()` call. Soft-body contact has no
106
+ * CCD — a cloth vertex moving faster than its collision radius per
107
+ * collision step TUNNELS through kinematic colliders (measured live on a
108
+ * whipping cape). 2 halves the per-step travel for ~2x the (sub-ms) sim
109
+ * cost. @default 1 */
110
+ collisionSteps?: number;
111
+ }
112
+
113
+ export function createJoltWorld(jolt: JoltModule, options: JoltWorldOptions = {}): JoltWorld {
114
+ const gravity = options.gravity ?? { x: 0, y: -9.81, z: 0 };
115
+ const collisionSteps = options.collisionSteps ?? 1;
116
+ const objectFilter = new jolt.ObjectLayerPairFilterTable(NUM_OBJECT_LAYERS);
117
+ objectFilter.EnableCollision(LAYER_MOVING, LAYER_MOVING);
118
+ const bpInterface = new jolt.BroadPhaseLayerInterfaceTable(
119
+ NUM_OBJECT_LAYERS,
120
+ NUM_BROAD_PHASE_LAYERS,
121
+ );
122
+ bpInterface.MapObjectToBroadPhaseLayer(
123
+ LAYER_NON_MOVING,
124
+ new jolt.BroadPhaseLayer(LAYER_NON_MOVING),
125
+ );
126
+ bpInterface.MapObjectToBroadPhaseLayer(LAYER_MOVING, new jolt.BroadPhaseLayer(LAYER_MOVING));
127
+
128
+ const settings = new jolt.JoltSettings();
129
+ settings.mObjectLayerPairFilter = objectFilter;
130
+ settings.mBroadPhaseLayerInterface = bpInterface;
131
+ settings.mObjectVsBroadPhaseLayerFilter = new jolt.ObjectVsBroadPhaseLayerFilterTable(
132
+ bpInterface,
133
+ NUM_BROAD_PHASE_LAYERS,
134
+ objectFilter,
135
+ NUM_OBJECT_LAYERS,
136
+ );
137
+ const joltInterface = new jolt.JoltInterface(settings);
138
+ jolt.destroy(settings);
139
+ const physicsSystem = joltInterface.GetPhysicsSystem();
140
+ physicsSystem.SetGravity(new jolt.Vec3(gravity.x, gravity.y, gravity.z));
141
+ const bodyInterface = physicsSystem.GetBodyInterface();
142
+
143
+ return {
144
+ jolt,
145
+ joltInterface,
146
+ physicsSystem,
147
+ bodyInterface,
148
+ movingLayer: LAYER_MOVING,
149
+ step(dt: number): void {
150
+ joltInterface.Step(dt, collisionSteps);
151
+ },
152
+ dispose(): void {
153
+ jolt.destroy(joltInterface);
154
+ },
155
+ };
156
+ }
157
+
158
+ // ---------------------------------------------------------------------------
159
+ // Kinematic colliders (torso/head/limb shapes the cloth drapes over)
160
+ // ---------------------------------------------------------------------------
161
+
162
+ export type KinematicColliderShape =
163
+ | { kind: 'sphere'; radius: number }
164
+ | { kind: 'capsule'; radius: number; halfHeight: number };
165
+
166
+ export interface KinematicCollider {
167
+ body: JoltNS.Body;
168
+ /** Move the collider to `worldPos` (identity rotation — these fixtures
169
+ * only need translation) for tick `dt`. Call BEFORE the cloth grid's
170
+ * `tick()` for the same frame (that is what steps the Jolt world). */
171
+ moveTo(worldPos: THREE.Vector3, dt: number): void;
172
+ dispose(): void;
173
+ }
174
+
175
+ export function buildKinematicCollider(
176
+ world: JoltWorld,
177
+ shape: KinematicColliderShape,
178
+ ): KinematicCollider {
179
+ const { jolt, bodyInterface } = world;
180
+ const joltShape =
181
+ shape.kind === 'sphere'
182
+ ? new jolt.SphereShape(shape.radius)
183
+ : new jolt.CapsuleShape(shape.halfHeight, shape.radius);
184
+ const creation = new jolt.BodyCreationSettings(
185
+ joltShape,
186
+ new jolt.RVec3(0, 0, 0),
187
+ new jolt.Quat(0, 0, 0, 1),
188
+ jolt.EMotionType_Kinematic,
189
+ world.movingLayer,
190
+ );
191
+ // A per-tick-driven kinematic collider must NEVER sleep: Jolt puts a
192
+ // near-zero-velocity kinematic body to sleep after ~0.5 s, and
193
+ // `Body.MoveKinematic` (unlike the BodyInterface variant) does NOT wake
194
+ // it — after any brief stillness (e.g. a garment-settle warm-up) the
195
+ // collider would silently freeze at its old pose FOREVER while the
196
+ // character walks away (observed live: the whole cape sailed through the
197
+ // torso and every contact-robustness knob was powerless against it).
198
+ creation.mAllowSleeping = false;
199
+ const body = bodyInterface.CreateBody(creation);
200
+ bodyInterface.AddBody(body.GetID(), jolt.EActivation_Activate);
201
+ // `CreateBody` copied what it needs — free the one-shot settings struct
202
+ // now (same contract as `SoftBodyCreationSettings` in `buildClothGrid`).
203
+ // This also releases `creation`'s reference on the REFCOUNTED `joltShape`;
204
+ // the body holds the remaining shape ref, so `joltShape` must NOT get a
205
+ // `jolt.destroy` anywhere — `DestroyBody` in `dispose()` releases that
206
+ // last ref and Jolt deletes the shape itself (see module header).
207
+ jolt.destroy(creation);
208
+
209
+ // Preallocated once, reused every `moveTo` call — see module header.
210
+ const tmpPos = new jolt.RVec3(0, 0, 0);
211
+ const identityQuat = new jolt.Quat(0, 0, 0, 1);
212
+
213
+ return {
214
+ body,
215
+ moveTo(worldPos: THREE.Vector3, dt: number): void {
216
+ tmpPos.Set(worldPos.x, worldPos.y, worldPos.z);
217
+ body.MoveKinematic(tmpPos, identityQuat, dt);
218
+ },
219
+ dispose(): void {
220
+ bodyInterface.RemoveBody(body.GetID());
221
+ bodyInterface.DestroyBody(body.GetID());
222
+ jolt.destroy(tmpPos);
223
+ jolt.destroy(identityQuat);
224
+ },
225
+ };
226
+ }
227
+
228
+ // ---------------------------------------------------------------------------
229
+ // Cloth grid
230
+ // ---------------------------------------------------------------------------
231
+
232
+ export interface ClothPin {
233
+ row: number;
234
+ col: number;
235
+ }
236
+
237
+ export interface ClothGridOptions {
238
+ cols: number;
239
+ rows: number;
240
+ /** World-space rest position for grid vertex (row, col) — row 0 is
241
+ * conventionally the top/pinned edge, but that's a caller convention,
242
+ * not enforced here. */
243
+ restPosition(row: number, col: number): THREE.Vector3;
244
+ /** Which vertices are pinned (kinematically driven every tick, infinite
245
+ * mass). Default: every vertex in row 0. */
246
+ pins?: ClothPin[];
247
+ vertexMassKg?: number;
248
+ compliance?: number;
249
+ shearCompliance?: number;
250
+ bendCompliance?: number;
251
+ numIterations?: number;
252
+ gravityFactor?: number;
253
+ /** Collision radius of every cloth vertex (Jolt `mVertexRadius`), metres.
254
+ * The default 0 lets a pressed vertex sit exactly ON (and, under
255
+ * sustained gait-driven contact, sink INTO) a collider surface; a few cm
256
+ * here keeps the cloth standing off colliders and makes contact
257
+ * resolution far more robust against sinking. @default 0 (Jolt's own) */
258
+ vertexRadius?: number;
259
+ /** Jolt `mLinearDamping` for the soft body (higher = calmer cloth).
260
+ * @default Jolt's own default */
261
+ linearDamping?: number;
262
+ color?: number;
263
+ /** @default true */
264
+ doubleSided?: boolean;
265
+ }
266
+
267
+ export interface ClothGrid {
268
+ cols: number;
269
+ rows: number;
270
+ vertexCount: number;
271
+ /** Pin list this grid was built with, in the order `tick()`'s
272
+ * `pinTargets` must match. */
273
+ pins: ClothPin[];
274
+ mesh: THREE.Mesh;
275
+ geometry: THREE.BufferGeometry;
276
+ /** One tick: overwrite each pinned vertex's position/velocity from
277
+ * `pinTargets[i]` (world-space, matching `pins` order — velocity is
278
+ * finite-differenced against the previous call), STEP the owning
279
+ * `JoltWorld` (this is the only thing in this module that calls
280
+ * `world.step`, so build at most one `ClothGrid` per `JoltWorld` unless
281
+ * you step the world yourself and skip that side effect), then sync
282
+ * `geometry`'s `position` attribute + recompute normals from the
283
+ * simulated vertices. Position any kinematic colliders for this frame
284
+ * BEFORE calling this. */
285
+ tick(dt: number, pinTargets: readonly THREE.Vector3[]): void;
286
+ /** World-space position of every vertex, row-major (`row * cols + col`).
287
+ * Reuses `out` if given (avoids an allocation for callers sampling every
288
+ * frame, e.g. a numeric collision-clearance check). */
289
+ samplePoints(out?: THREE.Vector3[]): THREE.Vector3[];
290
+ dispose(): void;
291
+ }
292
+
293
+ type GridIndex = (row: number, col: number) => number;
294
+
295
+ /** Build the `SoftBodySharedSettings` (vertex grid + face list + edge/shear/
296
+ * bend constraints) for a `cols`x`rows` cloth. Split out of `buildClothGrid`
297
+ * purely to keep that function's complexity down — not reused elsewhere. */
298
+ function buildSoftBodySharedSettings(
299
+ jolt: JoltModule,
300
+ options: ClothGridOptions,
301
+ pins: ClothPin[],
302
+ idx: GridIndex,
303
+ ): JoltNS.SoftBodySharedSettings {
304
+ const { cols, rows } = options;
305
+ const vertexMassKg = options.vertexMassKg ?? 0.02;
306
+ const pinSet = new Set(pins.map((p) => idx(p.row, p.col)));
307
+
308
+ const sharedSettings = new jolt.SoftBodySharedSettings();
309
+ const vertices = sharedSettings.mVertices;
310
+ for (let row = 0; row < rows; row++) {
311
+ for (let col = 0; col < cols; col++) {
312
+ const p = options.restPosition(row, col);
313
+ const v = new jolt.SoftBodySharedSettingsVertex();
314
+ const pos = new jolt.Float3(p.x, p.y, p.z);
315
+ v.mPosition = pos;
316
+ v.mInvMass = pinSet.has(idx(row, col)) ? 0 : 1 / vertexMassKg;
317
+ vertices.push_back(v);
318
+ // `push_back` (like the property-setter assignment above it) copies
319
+ // BY VALUE into `sharedSettings`'s own storage — Jolt's own
320
+ // `soft-body-creator.js` sample reuses/destroys a single `v` the same
321
+ // way across its whole grid loop — so both temporaries are safe to
322
+ // free the instant the copy has happened.
323
+ jolt.destroy(pos);
324
+ jolt.destroy(v);
325
+ }
326
+ }
327
+ sharedSettings.mVertices = vertices;
328
+
329
+ const faces = sharedSettings.mFaces;
330
+ for (let row = 0; row < rows - 1; row++) {
331
+ for (let col = 0; col < cols - 1; col++) {
332
+ const faceA = new jolt.SoftBodySharedSettingsFace(
333
+ idx(row, col),
334
+ idx(row + 1, col),
335
+ idx(row, col + 1),
336
+ 0,
337
+ );
338
+ faces.push_back(faceA);
339
+ jolt.destroy(faceA);
340
+ const faceB = new jolt.SoftBodySharedSettingsFace(
341
+ idx(row, col + 1),
342
+ idx(row + 1, col),
343
+ idx(row + 1, col + 1),
344
+ 0,
345
+ );
346
+ faces.push_back(faceB);
347
+ jolt.destroy(faceB);
348
+ }
349
+ }
350
+ sharedSettings.mFaces = faces;
351
+
352
+ const vertexAttr = new jolt.SoftBodySharedSettingsVertexAttributes();
353
+ vertexAttr.mCompliance = options.compliance ?? 0.0002;
354
+ vertexAttr.mShearCompliance = options.shearCompliance ?? 0.0002;
355
+ vertexAttr.mBendCompliance = options.bendCompliance ?? 0.002;
356
+ const vertexAttrArr = new jolt.ArraySoftBodySharedSettingsVertexAttributes();
357
+ vertexAttrArr.push_back(vertexAttr);
358
+ // Same value-copy-then-free story as above: `CreateConstraints` below
359
+ // reads from `vertexAttrArr`'s OWN backing copy (via `.data()`), not this
360
+ // original wrapper, so it's safe to free right after the push_back.
361
+ jolt.destroy(vertexAttr);
362
+ sharedSettings.CreateConstraints(
363
+ vertexAttrArr.data(),
364
+ vertexAttrArr.size(),
365
+ jolt.SoftBodySharedSettings_EBendType_Distance,
366
+ );
367
+ sharedSettings.Optimize();
368
+ jolt.destroy(vertexAttrArr);
369
+ return sharedSettings;
370
+ }
371
+
372
+ /** Build the renderable (non-skinned) mesh: a plain `BufferGeometry` whose
373
+ * `position` attribute `ClothGrid.tick` overwrites every frame. */
374
+ function buildClothMesh(
375
+ scene: THREE.Scene,
376
+ options: ClothGridOptions,
377
+ idx: GridIndex,
378
+ ): {
379
+ mesh: THREE.Mesh;
380
+ geometry: THREE.BufferGeometry;
381
+ positionAttr: THREE.BufferAttribute;
382
+ material: THREE.MeshStandardMaterial;
383
+ } {
384
+ const { cols, rows } = options;
385
+ const geometry = new THREE.BufferGeometry();
386
+ const positionAttr = new THREE.BufferAttribute(new Float32Array(rows * cols * 3), 3);
387
+ positionAttr.setUsage(THREE.DynamicDrawUsage);
388
+ geometry.setAttribute('position', positionAttr);
389
+ const indices: number[] = [];
390
+ for (let row = 0; row < rows - 1; row++) {
391
+ for (let col = 0; col < cols - 1; col++) {
392
+ indices.push(idx(row, col), idx(row + 1, col), idx(row, col + 1));
393
+ indices.push(idx(row, col + 1), idx(row + 1, col), idx(row + 1, col + 1));
394
+ }
395
+ }
396
+ geometry.setIndex(indices);
397
+ const material = new THREE.MeshStandardMaterial({
398
+ color: options.color ?? 0x7a2f3a,
399
+ roughness: 0.85,
400
+ side: options.doubleSided === false ? THREE.FrontSide : THREE.DoubleSide,
401
+ });
402
+ const mesh = new THREE.Mesh(geometry, material);
403
+ mesh.castShadow = true;
404
+ mesh.frustumCulled = false;
405
+ scene.add(mesh);
406
+ return { mesh, geometry, positionAttr, material };
407
+ }
408
+
409
+ export function buildClothGrid(
410
+ world: JoltWorld,
411
+ scene: THREE.Scene,
412
+ options: ClothGridOptions,
413
+ ): ClothGrid {
414
+ const { jolt, bodyInterface } = world;
415
+ const { cols, rows } = options;
416
+ const pins = options.pins ?? Array.from({ length: cols }, (_, col) => ({ row: 0, col }));
417
+ const idx: GridIndex = (row, col) => row * cols + col;
418
+
419
+ const sharedSettings = buildSoftBodySharedSettings(jolt, options, pins, idx);
420
+
421
+ const bodyCreation = new jolt.SoftBodyCreationSettings(
422
+ sharedSettings,
423
+ new jolt.RVec3(0, 0, 0),
424
+ new jolt.Quat(0, 0, 0, 1),
425
+ world.movingLayer,
426
+ );
427
+ bodyCreation.mNumIterations = options.numIterations ?? 4;
428
+ if (options.vertexRadius !== undefined) bodyCreation.mVertexRadius = options.vertexRadius;
429
+ if (options.linearDamping !== undefined) bodyCreation.mLinearDamping = options.linearDamping;
430
+ bodyCreation.mPressure = 0;
431
+ bodyCreation.mGravityFactor = options.gravityFactor ?? 1;
432
+ bodyCreation.mFacesDoubleSided = true;
433
+ bodyCreation.mUpdatePosition = false;
434
+ // Kinematically-pinned cloth must never sleep either: `tick()` drives the
435
+ // pins by writing `SoftBodyVertex.mPosition/.mVelocity` DIRECTLY, which
436
+ // does not count as activity — a cape that settles still for ~0.5 s would
437
+ // sleep and stay frozen while its pins walk away.
438
+ bodyCreation.mAllowSleeping = false;
439
+ const clothBody = bodyInterface.CreateSoftBody(bodyCreation);
440
+ bodyInterface.AddBody(clothBody.GetID(), jolt.EActivation_Activate);
441
+ // `CreateSoftBody` copies what it needs out of `bodyCreation` into the
442
+ // body's own storage (same "settings object is a one-shot constructor
443
+ // argument" contract as `JoltSettings` → `JoltInterface` above) — safe to
444
+ // free right away, same as that pattern. `bodyCreation` itself is a plain
445
+ // (non-refcounted) settings struct, unlike `sharedSettings` below: freeing
446
+ // it here still correctly releases bodyCreation's OWN internal reference
447
+ // to `sharedSettings` (which the body has, by now, separately taken its
448
+ // own reference to via `CreateSoftBody`).
449
+ jolt.destroy(bodyCreation);
450
+ // `Body` only exposes the base `GetMotionProperties()`; soft bodies need
451
+ // the downcast to reach `GetVertex`/`GetVertices`.
452
+ const motionProperties = jolt.castObject(
453
+ clothBody.GetMotionProperties(),
454
+ jolt.SoftBodyMotionProperties,
455
+ );
456
+
457
+ const { mesh, geometry, positionAttr, material } = buildClothMesh(scene, options, idx);
458
+
459
+ // Preallocated tick-loop temporaries — see module header.
460
+ const tmpPos = new jolt.Vec3(0, 0, 0);
461
+ const tmpVel = new jolt.Vec3(0, 0, 0);
462
+ const prevPinWorld: THREE.Vector3[] = pins.map((p) => options.restPosition(p.row, p.col));
463
+
464
+ return {
465
+ cols,
466
+ rows,
467
+ vertexCount: rows * cols,
468
+ pins,
469
+ mesh,
470
+ geometry,
471
+ tick(dt: number, pinTargets: readonly THREE.Vector3[]): void {
472
+ for (let i = 0; i < pins.length; i++) {
473
+ const target = pinTargets[i];
474
+ if (!target) continue;
475
+ const pin = pins[i]!;
476
+ const prev = prevPinWorld[i]!;
477
+ const vertex = motionProperties.GetVertex(idx(pin.row, pin.col));
478
+ tmpPos.Set(target.x, target.y, target.z);
479
+ vertex.mPosition = tmpPos;
480
+ if (dt > 0) {
481
+ tmpVel.Set((target.x - prev.x) / dt, (target.y - prev.y) / dt, (target.z - prev.z) / dt);
482
+ } else {
483
+ tmpVel.Set(0, 0, 0);
484
+ }
485
+ vertex.mVelocity = tmpVel;
486
+ prev.copy(target);
487
+ }
488
+
489
+ world.step(dt);
490
+
491
+ const posArr = positionAttr.array as Float32Array;
492
+ for (let i = 0; i < rows * cols; i++) {
493
+ const v = motionProperties.GetVertex(i);
494
+ const p = v.mPosition;
495
+ posArr[i * 3] = p.GetX();
496
+ posArr[i * 3 + 1] = p.GetY();
497
+ posArr[i * 3 + 2] = p.GetZ();
498
+ }
499
+ positionAttr.needsUpdate = true;
500
+ geometry.computeVertexNormals();
501
+ },
502
+ samplePoints(out: THREE.Vector3[] = []): THREE.Vector3[] {
503
+ const n = rows * cols;
504
+ out.length = n;
505
+ for (let i = 0; i < n; i++) {
506
+ const v = motionProperties.GetVertex(i);
507
+ const p = v.mPosition;
508
+ if (!out[i]) out[i] = new THREE.Vector3();
509
+ out[i]!.set(p.GetX(), p.GetY(), p.GetZ());
510
+ }
511
+ return out;
512
+ },
513
+ dispose(): void {
514
+ bodyInterface.RemoveBody(clothBody.GetID());
515
+ // NO explicit `jolt.destroy(sharedSettings)` — anywhere. It is a
516
+ // refcounted RefTarget that Jolt frees ITSELF when its last reference
517
+ // drops, and in Jolt's RefTarget::Release releasing the last ref IS
518
+ // the delete. Refcount chain, probed live against real Jolt WASM via
519
+ // `GetRefCount()`: 0 after `new` → 1 after the
520
+ // `SoftBodyCreationSettings` ctor took its ref → 2 after
521
+ // `CreateSoftBody` (the body's own ref) → back to 1 after the
522
+ // construction-time `destroy(bodyCreation)` → `DestroyBody` below
523
+ // releases that last (body) reference and thereby deletes it. Any
524
+ // `destroy(sharedSettings)` after this line would be a use-after-free.
525
+ bodyInterface.DestroyBody(clothBody.GetID());
526
+ jolt.destroy(tmpPos);
527
+ jolt.destroy(tmpVel);
528
+ scene.remove(mesh);
529
+ geometry.dispose();
530
+ material.dispose();
531
+ },
532
+ };
533
+ }