pelican-engine 0.3.16 → 0.3.18
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/AGENTS.md +37 -3
- package/distribution.json +19 -15
- package/docs/STATUS.md +3 -2
- package/docs/api/REFERENCE.md +227 -97
- package/docs/api/index.json +228 -101
- package/docs/npm/GAME-AUTHOR.md +37 -3
- package/engine.manifest.json +1 -1
- package/lib/renderer/browser.js +21 -13
- package/lib/renderer/cloth-visuals.js +140 -0
- package/lib/renderer/gameplay.js +2 -0
- package/lib/runtime/host.js +3 -0
- package/lib/sdk/index.d.ts +99 -2
- package/lib/sdk/index.js +1 -1
- package/lib/simulation/cloth-plugin.js +152 -0
- package/lib/simulation/cloth.js +327 -0
- package/lib/simulation/rope-plugin.js +12 -4
- package/lib/simulation/rope-self-collision.js +102 -0
- package/lib/simulation/rope.js +36 -5
- package/package.json +1 -1
package/docs/npm/GAME-AUTHOR.md
CHANGED
|
@@ -147,8 +147,20 @@ solver snapshots without this field retain particle-only behavior when migrated;
|
|
|
147
147
|
engine-version restrictions still apply. Plane contacts use particle endpoints.
|
|
148
148
|
The segment index names its first particle; `fraction` runs toward the next.
|
|
149
149
|
Work increases with segment, sphere, substep and iteration counts. This does not
|
|
150
|
-
provide continuous collision detection
|
|
151
|
-
|
|
150
|
+
provide continuous collision detection; the segment solver and snapshot migration
|
|
151
|
+
have not been runtime validated.
|
|
152
|
+
|
|
153
|
+
New ropes also resolve discrete self-contact between non-adjacent segments using
|
|
154
|
+
sweep-and-prune and endpoint projection. `inspect(id).selfContacts` reports
|
|
155
|
+
segment indices, closest-point fractions and maximum overlap; `selfCollisions`
|
|
156
|
+
reports the active mode. Set `selfCollisions: false` to disable it. Adjacent
|
|
157
|
+
segments and collisions between separate ropes are not handled. Immovable anchored
|
|
158
|
+
endpoints stay fixed, and contacts introduced by corrections can wait until the
|
|
159
|
+
next iteration. Dense overlap can approach quadratic work. Snapshots from before
|
|
160
|
+
this feature retain the disabled mode when migrated; Host engine-version rules
|
|
161
|
+
still apply. This is unverified discrete contact, not a guarantee against
|
|
162
|
+
penetration, continuous collision, friction or full rope physics. The source
|
|
163
|
+
checkout includes `examples/rope-self`.
|
|
152
164
|
|
|
153
165
|
For optional visual presentation, declare a material in `defineGame({ materials:
|
|
154
166
|
[...] })` and create the rope with `visual: { material: "your-material-id" }`.
|
|
@@ -206,10 +218,32 @@ checkpoint. It must not be represented as complete two-way rigid-body interactio
|
|
|
206
218
|
|
|
207
219
|
This is an unverified development-stage increment, not complete rope physics:
|
|
208
220
|
the optional segmented visual does not provide a continuous deformable rope mesh,
|
|
209
|
-
general rigid-body coupling,
|
|
221
|
+
general rigid-body coupling, continuous collision, cross-rope collision, or tearing. Plane normals must be unit length; `offset` is the signed
|
|
210
222
|
normal distance from the origin. Host intervals must be between 1/1000 and
|
|
211
223
|
1/30 seconds inclusive.
|
|
212
224
|
|
|
225
|
+
### Development-stage triangular cloth
|
|
226
|
+
|
|
227
|
+
Declare a material in `defineGame({ materials: [...] })`, then use
|
|
228
|
+
`ctx.cloth.create({ id, points, triangles, mass, autoStep: true, visual: {
|
|
229
|
+
material: "material-id" } })`. Triangles reference particle indices and shared
|
|
230
|
+
edges must be manifold with consistent winding. Anchors are explicit; no particles
|
|
231
|
+
are pinned by default. Automatic cloth advances after the physics phase;
|
|
232
|
+
manual cloth uses `ctx.cloth.step`. Use `setAnchor`, `releaseAnchor`, `applyForce`
|
|
233
|
+
(next tick), `applyImpulse`, `setColliders`, `inspect`, and `destroy` to manage it.
|
|
234
|
+
The source checkout includes `examples/cloth-sheet`.
|
|
235
|
+
|
|
236
|
+
The CPU solver uses edge distance constraints and an optional distance-based
|
|
237
|
+
bending approximation (`bending: true` by default). Sampled planes and spheres
|
|
238
|
+
only move cloth particles; they do not receive reactions. Contacts do not cover
|
|
239
|
+
triangle interiors or edges. There is no self-contact, cloth–rope interaction,
|
|
240
|
+
rigid-body coupling, tearing, friction or continuous collision. The optional
|
|
241
|
+
visual deforms the actual triangle mesh; UVs may be provided per particle or use a
|
|
242
|
+
planar projection. Use a double-sided material when both sides must be visible.
|
|
243
|
+
Collapsed or unrepresentable triangles return `TC_CLOTH_VISUAL_DEGENERATE` rather
|
|
244
|
+
than being silently replaced. Runtime, GPU, performance, stability and snapshot
|
|
245
|
+
compatibility have not been validated; this is not complete cloth physics.
|
|
246
|
+
|
|
213
247
|
Keyboard bindings use the browser's `KeyboardEvent.key` values, not physical
|
|
214
248
|
`KeyboardEvent.code` values. For example, use `input: { up: ["w", "W", "ArrowUp"],
|
|
215
249
|
restart: ["r", "R"] }`, not `"KeyW"` or `"KeyR"`. Letter case depends on the
|
package/engine.manifest.json
CHANGED
package/lib/renderer/browser.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import * as THREE from "three/webgpu";
|
|
2
2
|
import { prepareGameplay } from "./gameplay.js";
|
|
3
3
|
import { createRopeVisualOwner } from "./rope-visuals.js";
|
|
4
|
+
import { createClothVisualOwner } from "./cloth-visuals.js";
|
|
4
5
|
import { GPUTracer } from "./pathtrace.js";
|
|
5
6
|
import { SurfaceTracer } from "./surface-tracer.js";
|
|
6
7
|
import { createRenderSettingsDriver, resolveRenderSettings, } from "../runtime/render-settings.js";
|
|
@@ -136,10 +137,14 @@ const gameObjects = new Map();
|
|
|
136
137
|
const gameRoot = new THREE.Group();
|
|
137
138
|
scene.add(gameRoot);
|
|
138
139
|
const ropeVisuals = createRopeVisualOwner();
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
140
|
+
const clothVisuals = createClothVisualOwner();
|
|
141
|
+
scene.add(ropeVisuals.root, clothVisuals.root);
|
|
142
|
+
let dynamicVisualRevision = 0, renderedDynamicVisualRevision = 0;
|
|
143
|
+
const clearDynamicVisuals = () => {
|
|
144
|
+
const ropesChanged = ropeVisuals.clear(), clothChanged = clothVisuals.clear();
|
|
145
|
+
if (ropesChanged || clothChanged)
|
|
146
|
+
dynamicVisualRevision++;
|
|
147
|
+
};
|
|
143
148
|
let authoredCamera = { position: [0, 3.4, 7], target: [0, 0.9, 0] };
|
|
144
149
|
let authoredLights = [];
|
|
145
150
|
let compiledIR = null;
|
|
@@ -359,8 +364,8 @@ function createEntityView(entity, materials) {
|
|
|
359
364
|
}
|
|
360
365
|
function syncRuntime() {
|
|
361
366
|
if (!runtime) {
|
|
362
|
-
|
|
363
|
-
return
|
|
367
|
+
clearDynamicVisuals();
|
|
368
|
+
return dynamicVisualRevision !== renderedDynamicVisualRevision;
|
|
364
369
|
}
|
|
365
370
|
transforms.clear();
|
|
366
371
|
componentState.clear();
|
|
@@ -438,11 +443,13 @@ function syncRuntime() {
|
|
|
438
443
|
}
|
|
439
444
|
if (gameplay) {
|
|
440
445
|
if (ropeVisuals.sync(runtime.facade.ropes.visuals(), gameMaterials))
|
|
441
|
-
|
|
446
|
+
dynamicVisualRevision++;
|
|
447
|
+
if (clothVisuals.sync(runtime.facade.cloth.visuals(), gameMaterials))
|
|
448
|
+
dynamicVisualRevision++;
|
|
442
449
|
}
|
|
443
450
|
else
|
|
444
|
-
|
|
445
|
-
return
|
|
451
|
+
clearDynamicVisuals();
|
|
452
|
+
return dynamicVisualRevision !== renderedDynamicVisualRevision;
|
|
446
453
|
}
|
|
447
454
|
let simulation;
|
|
448
455
|
const simulationGroup = new THREE.Group();
|
|
@@ -735,7 +742,7 @@ async function loadGame(sceneId, saved, loadSlot) {
|
|
|
735
742
|
contentScope = nextScope;
|
|
736
743
|
gameMaterials = nextMaterials;
|
|
737
744
|
committed = true;
|
|
738
|
-
|
|
745
|
+
clearDynamicVisuals();
|
|
739
746
|
if (nextRoot.children.length)
|
|
740
747
|
gameRoot.add(...nextRoot.children);
|
|
741
748
|
gameObjects.clear();
|
|
@@ -967,7 +974,7 @@ async function drawFrame(accumulate = false, changed = true) {
|
|
|
967
974
|
await computeControl?.flush();
|
|
968
975
|
const start = performance.now();
|
|
969
976
|
const ropeChanged = syncRuntime();
|
|
970
|
-
const ropeRevision =
|
|
977
|
+
const ropeRevision = dynamicVisualRevision;
|
|
971
978
|
changed = changed || ropeChanged;
|
|
972
979
|
animate();
|
|
973
980
|
renderBufferBridge?.update();
|
|
@@ -1048,7 +1055,7 @@ async function drawFrame(accumulate = false, changed = true) {
|
|
|
1048
1055
|
triangles: triangleCount,
|
|
1049
1056
|
faulted: terminalFault,
|
|
1050
1057
|
});
|
|
1051
|
-
|
|
1058
|
+
renderedDynamicVisualRevision = ropeRevision;
|
|
1052
1059
|
return;
|
|
1053
1060
|
}
|
|
1054
1061
|
if (changed) {
|
|
@@ -1101,7 +1108,7 @@ async function drawFrame(accumulate = false, changed = true) {
|
|
|
1101
1108
|
triangles: triangleCount,
|
|
1102
1109
|
faulted: terminalFault,
|
|
1103
1110
|
});
|
|
1104
|
-
|
|
1111
|
+
renderedDynamicVisualRevision = ropeRevision;
|
|
1105
1112
|
}
|
|
1106
1113
|
async function applyInput(key) {
|
|
1107
1114
|
if (!probeEnabled) {
|
|
@@ -1348,6 +1355,7 @@ async function disposeBrowser() {
|
|
|
1348
1355
|
clean(() => world?.dispose());
|
|
1349
1356
|
clean(() => resourceManager.dispose());
|
|
1350
1357
|
clean(() => ropeVisuals.dispose());
|
|
1358
|
+
clean(() => clothVisuals.dispose());
|
|
1351
1359
|
clean(() => releaseObject(scene, true, gameMaterials.values()));
|
|
1352
1360
|
clean(() => pendingSurface?.abort());
|
|
1353
1361
|
clean(() => tracer?.dispose());
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import * as THREE from "three/webgpu";
|
|
2
|
+
/** Owns dynamic cloth geometry and buffers; scene materials remain borrowed. */
|
|
3
|
+
export function createClothVisualOwner() {
|
|
4
|
+
const root = new THREE.Group();
|
|
5
|
+
root.name = "pelican-cloth-visuals";
|
|
6
|
+
const views = new Map();
|
|
7
|
+
let disposed = false;
|
|
8
|
+
const remove = (view) => { view.mesh.removeFromParent(); view.mesh.dispose(); view.geometry.dispose(); };
|
|
9
|
+
const clear = () => { const changed = views.size > 0; for (const view of views.values())
|
|
10
|
+
remove(view); views.clear(); return changed; };
|
|
11
|
+
const signature = (state) => JSON.stringify([state.material, state.castShadow, state.receiveShadow, state.positions.length, state.triangles, state.uvs]);
|
|
12
|
+
const create = (state, material) => {
|
|
13
|
+
const geometry = new THREE.BufferGeometry();
|
|
14
|
+
let mesh;
|
|
15
|
+
try {
|
|
16
|
+
const count = state.triangles.length * 3;
|
|
17
|
+
const position = new THREE.BufferAttribute(new Float32Array(count * 3), 3), normal = new THREE.BufferAttribute(new Float32Array(count * 3), 3);
|
|
18
|
+
position.setUsage(THREE.DynamicDrawUsage);
|
|
19
|
+
normal.setUsage(THREE.DynamicDrawUsage);
|
|
20
|
+
geometry.setAttribute("position", position);
|
|
21
|
+
geometry.setAttribute("normal", normal);
|
|
22
|
+
const values = new Float32Array(count * 2);
|
|
23
|
+
let offset = 0;
|
|
24
|
+
for (const triangle of state.triangles)
|
|
25
|
+
for (const index of triangle) {
|
|
26
|
+
const uv = state.uvs[index];
|
|
27
|
+
values[offset++] = uv[0];
|
|
28
|
+
values[offset++] = uv[1];
|
|
29
|
+
}
|
|
30
|
+
const uv = new THREE.BufferAttribute(values, 2);
|
|
31
|
+
geometry.setAttribute("uv", uv);
|
|
32
|
+
geometry.setAttribute("uv1", uv.clone());
|
|
33
|
+
mesh = new THREE.Mesh(geometry, material);
|
|
34
|
+
mesh.name = `cloth:${state.id}`;
|
|
35
|
+
mesh.castShadow = state.castShadow;
|
|
36
|
+
mesh.receiveShadow = state.receiveShadow;
|
|
37
|
+
return { mesh, geometry, signature: signature(state), positions: [] };
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
mesh?.dispose();
|
|
41
|
+
geometry.dispose();
|
|
42
|
+
throw error;
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
const geometryData = (state) => {
|
|
46
|
+
const vertices = new Float32Array(state.triangles.length * 9), normals = new Float32Array(vertices.length);
|
|
47
|
+
const accumulated = Array.from({ length: state.positions.length }, () => [0, 0, 0]);
|
|
48
|
+
const faces = [];
|
|
49
|
+
let offset = 0;
|
|
50
|
+
for (let face = 0; face < state.triangles.length; face++) {
|
|
51
|
+
const triangle = state.triangles[face];
|
|
52
|
+
const points = triangle.map(index => state.positions[index].map(Math.fround));
|
|
53
|
+
const u = points[1].map((value, axis) => value - points[0][axis]), v = points[2].map((value, axis) => value - points[0][axis]);
|
|
54
|
+
const n = [u[1] * v[2] - u[2] * v[1], u[2] * v[0] - u[0] * v[2], u[0] * v[1] - u[1] * v[0]];
|
|
55
|
+
const length = Math.hypot(...n);
|
|
56
|
+
if (!Number.isFinite(length) || length === 0)
|
|
57
|
+
throw new Error(`TC_CLOTH_VISUAL_DEGENERATE: Triangle ${face} of '${state.id}' collapsed or is not representable; adjust cloth geometry or simulation.`);
|
|
58
|
+
faces.push([n[0] / length, n[1] / length, n[2] / length]);
|
|
59
|
+
for (let vertex = 0; vertex < 3; vertex++) {
|
|
60
|
+
const index = triangle[vertex];
|
|
61
|
+
for (let axis = 0; axis < 3; axis++) {
|
|
62
|
+
vertices[offset++] = points[vertex][axis];
|
|
63
|
+
accumulated[index][axis] += n[axis];
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
offset = 0;
|
|
68
|
+
for (let face = 0; face < state.triangles.length; face++)
|
|
69
|
+
for (const index of state.triangles[face]) {
|
|
70
|
+
const n = accumulated[index], length = Math.hypot(...n), source = length > 1e-20 ? [n[0] / length, n[1] / length, n[2] / length] : faces[face];
|
|
71
|
+
for (const value of source) {
|
|
72
|
+
if (!Number.isFinite(value))
|
|
73
|
+
throw new Error(`TC_CLOTH_VISUAL_NUMERIC: Invalid normal in '${state.id}'.`);
|
|
74
|
+
normals[offset++] = value;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return { vertices, normals };
|
|
78
|
+
};
|
|
79
|
+
const sync = (states, materials) => {
|
|
80
|
+
if (disposed)
|
|
81
|
+
throw new Error("TC_CLOTH_VISUAL_DISPOSED: Create a new presentation owner.");
|
|
82
|
+
const staged = new Map(), created = [];
|
|
83
|
+
try {
|
|
84
|
+
for (const state of states) {
|
|
85
|
+
if (staged.has(state.id))
|
|
86
|
+
throw new Error(`TC_CLOTH_VISUAL_ID: Duplicate cloth '${state.id}'.`);
|
|
87
|
+
const material = materials.get(state.material);
|
|
88
|
+
if (!material)
|
|
89
|
+
throw new Error(`TC_CLOTH_VISUAL_MATERIAL: Material '${state.material}' is not declared.`);
|
|
90
|
+
if (!Array.isArray(state.positions) || state.positions.length < 3 || state.positions.length > 4096 ||
|
|
91
|
+
state.positions.some(point => !Array.isArray(point) || point.length !== 3 || !point.every(value => Number.isFinite(value) && Number.isFinite(Math.fround(value)))) ||
|
|
92
|
+
!Array.isArray(state.triangles) || state.triangles.length < 1 || state.triangles.length > 8192 ||
|
|
93
|
+
state.triangles.some(triangle => !Array.isArray(triangle) || triangle.length !== 3 || triangle.some(index => !Number.isSafeInteger(index) || index < 0 || index >= state.positions.length)) ||
|
|
94
|
+
!Array.isArray(state.uvs) || state.uvs.length !== state.positions.length || state.uvs.some(uv => !Array.isArray(uv) || uv.length !== 2 || !uv.every(value => Number.isFinite(value) && Number.isFinite(Math.fround(value)))))
|
|
95
|
+
throw new Error(`TC_CLOTH_VISUAL_INPUT: Cloth '${state.id}' has invalid positions, topology or UVs.`);
|
|
96
|
+
const old = views.get(state.id), same = old && old.signature === signature(state) && old.mesh.material === material;
|
|
97
|
+
const view = same ? old : create(state, material);
|
|
98
|
+
if (!same)
|
|
99
|
+
created.push(view);
|
|
100
|
+
const positions = state.positions.flatMap(point => [...point]);
|
|
101
|
+
const changed = !same || positions.length !== view.positions.length || positions.some((value, index) => value !== view.positions[index]);
|
|
102
|
+
const update = changed ? geometryData(state) : undefined;
|
|
103
|
+
staged.set(state.id, { view, created: !same, positions, ...update });
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
for (const view of created)
|
|
108
|
+
remove(view);
|
|
109
|
+
throw error;
|
|
110
|
+
}
|
|
111
|
+
let changed = false;
|
|
112
|
+
for (const [id, view] of views)
|
|
113
|
+
if (!staged.has(id) || staged.get(id).view !== view) {
|
|
114
|
+
remove(view);
|
|
115
|
+
changed = true;
|
|
116
|
+
}
|
|
117
|
+
views.clear();
|
|
118
|
+
for (const [id, item] of staged) {
|
|
119
|
+
if (item.created) {
|
|
120
|
+
root.add(item.view.mesh);
|
|
121
|
+
changed = true;
|
|
122
|
+
}
|
|
123
|
+
if (item.vertices && item.normals) {
|
|
124
|
+
const position = item.view.geometry.getAttribute("position"), normal = item.view.geometry.getAttribute("normal");
|
|
125
|
+
position.array.set(item.vertices);
|
|
126
|
+
normal.array.set(item.normals);
|
|
127
|
+
position.needsUpdate = true;
|
|
128
|
+
normal.needsUpdate = true;
|
|
129
|
+
item.view.geometry.computeBoundingBox();
|
|
130
|
+
item.view.geometry.computeBoundingSphere();
|
|
131
|
+
item.view.positions = item.positions;
|
|
132
|
+
changed = true;
|
|
133
|
+
}
|
|
134
|
+
views.set(id, item.view);
|
|
135
|
+
}
|
|
136
|
+
return changed;
|
|
137
|
+
};
|
|
138
|
+
return { root, sync, clear, dispose() { if (disposed)
|
|
139
|
+
return; clear(); root.removeFromParent(); disposed = true; } };
|
|
140
|
+
}
|
package/lib/renderer/gameplay.js
CHANGED
|
@@ -5,6 +5,7 @@ import { createGameServices } from "../runtime/game-services.js";
|
|
|
5
5
|
import { createPhysicsFactory } from "../physics/index.js";
|
|
6
6
|
import { createNavigationPlugin } from "../navigation/index.js";
|
|
7
7
|
import { createRopePlugin } from "../simulation/rope-plugin.js";
|
|
8
|
+
import { createClothPlugin } from "../simulation/cloth-plugin.js";
|
|
8
9
|
import { createModelLibrary } from "./models.js";
|
|
9
10
|
import { resolveEntityDefinition } from "../runtime/prefabs.js";
|
|
10
11
|
/** Private presentation ownership. Games receive only SDK services, never browser/library objects. */
|
|
@@ -136,6 +137,7 @@ export async function prepareGameplay(ir, baseURL, renderer, materials = new Map
|
|
|
136
137
|
services.factory,
|
|
137
138
|
createNavigationPlugin,
|
|
138
139
|
createRopePlugin,
|
|
140
|
+
createClothPlugin,
|
|
139
141
|
(context) => ({
|
|
140
142
|
id: "$engine.models",
|
|
141
143
|
version: "2",
|
package/lib/runtime/host.js
CHANGED
|
@@ -203,6 +203,9 @@ export class RuntimeHost {
|
|
|
203
203
|
get ropes() {
|
|
204
204
|
return host.service("ropes");
|
|
205
205
|
},
|
|
206
|
+
get cloth() {
|
|
207
|
+
return host.service("cloth");
|
|
208
|
+
},
|
|
206
209
|
entities: {
|
|
207
210
|
resolve: (entity) => this.world.resolve(entity),
|
|
208
211
|
isAlive: (entity) => this.world.isAlive(entity),
|
package/lib/sdk/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/** Pelican Engine public API. No renderer or GPU implementation types escape this module. */
|
|
2
|
-
export declare const SDK_VERSION = "0.3.
|
|
2
|
+
export declare const SDK_VERSION = "0.3.18";
|
|
3
3
|
export type Vec2 = readonly [number, number];
|
|
4
4
|
export type Vec3 = readonly [number, number, number];
|
|
5
5
|
export type Vec4 = readonly [number, number, number, number];
|
|
@@ -102,9 +102,10 @@ export interface SystemContext extends EngineFacade {
|
|
|
102
102
|
readonly compute: ComputeService;
|
|
103
103
|
readonly navigation: NavigationService;
|
|
104
104
|
readonly ropes: RopeService;
|
|
105
|
+
readonly cloth: ClothService;
|
|
105
106
|
}
|
|
106
107
|
/** CPU particles with sampled sphere/plane collisions. Explicit body bindings apply delayed
|
|
107
|
-
* contact impulses to dynamic spheres; there is no full rigid-body coupling
|
|
108
|
+
* contact impulses to dynamic spheres; there is no full rigid-body coupling. */
|
|
108
109
|
export type RopeCollider = {
|
|
109
110
|
kind: "sphere";
|
|
110
111
|
id: string;
|
|
@@ -153,6 +154,8 @@ export interface RopeDefinition {
|
|
|
153
154
|
bodyColliders?: readonly RopeBodyCollider[];
|
|
154
155
|
/** Resolve sphere contacts on segment interiors; defaults to true for new ropes. */
|
|
155
156
|
segmentCollisions?: boolean;
|
|
157
|
+
/** Resolve discrete non-adjacent segment self contacts; defaults to true for new ropes. */
|
|
158
|
+
selfCollisions?: boolean;
|
|
156
159
|
/** Optional segmented presentation using an already-declared material id. */
|
|
157
160
|
visual?: RopeVisualOptions;
|
|
158
161
|
substeps?: number;
|
|
@@ -187,11 +190,24 @@ export interface RopeSegmentContact {
|
|
|
187
190
|
reactionImpulse?: Vec3;
|
|
188
191
|
reactionAngularImpulse?: Vec3;
|
|
189
192
|
}
|
|
193
|
+
/** Discrete self-contact between two non-adjacent rope segments. */
|
|
194
|
+
export interface RopeSelfContact {
|
|
195
|
+
tick: number;
|
|
196
|
+
/** Zero-based first endpoint of each non-adjacent segment, ordered ascending. */
|
|
197
|
+
firstSegment: number;
|
|
198
|
+
secondSegment: number;
|
|
199
|
+
/** Last closest-point fractions along the respective segments. */
|
|
200
|
+
firstFraction: number;
|
|
201
|
+
secondFraction: number;
|
|
202
|
+
/** Maximum observed overlap in metres during this tick. */
|
|
203
|
+
penetration: number;
|
|
204
|
+
}
|
|
190
205
|
export interface RopeState {
|
|
191
206
|
tick: number;
|
|
192
207
|
simulatedSeconds: number;
|
|
193
208
|
disposed: boolean;
|
|
194
209
|
segmentCollisions: boolean;
|
|
210
|
+
selfCollisions: boolean;
|
|
195
211
|
positions: Vec3[];
|
|
196
212
|
velocities: Vec3[];
|
|
197
213
|
anchors: {
|
|
@@ -200,6 +216,7 @@ export interface RopeState {
|
|
|
200
216
|
}[];
|
|
201
217
|
contacts: RopeContact[];
|
|
202
218
|
segmentContacts: RopeSegmentContact[];
|
|
219
|
+
selfContacts: RopeSelfContact[];
|
|
203
220
|
maxDistanceError: number;
|
|
204
221
|
resources: {
|
|
205
222
|
particles: number;
|
|
@@ -222,6 +239,86 @@ export interface RopeService {
|
|
|
222
239
|
applyImpulse(id: string, index: number, impulse: Vec3): void;
|
|
223
240
|
destroy(id: string): void;
|
|
224
241
|
}
|
|
242
|
+
/** CPU triangulated cloth with particle contacts against sampled spheres and planes. */
|
|
243
|
+
export type ClothCollider = RopeCollider;
|
|
244
|
+
export type ClothTriangle = readonly [number, number, number];
|
|
245
|
+
export interface ClothVisualOptions {
|
|
246
|
+
material: string;
|
|
247
|
+
/** Optional per-particle UV coordinates; defaults to a planar projection. */
|
|
248
|
+
uvs?: readonly Vec2[];
|
|
249
|
+
castShadow?: boolean;
|
|
250
|
+
receiveShadow?: boolean;
|
|
251
|
+
}
|
|
252
|
+
export interface ClothDefinition {
|
|
253
|
+
id: string;
|
|
254
|
+
points: readonly Vec3[];
|
|
255
|
+
triangles: readonly ClothTriangle[];
|
|
256
|
+
/** Kilograms per movable particle. */
|
|
257
|
+
mass: number;
|
|
258
|
+
radius?: number;
|
|
259
|
+
fixedDt?: number;
|
|
260
|
+
autoStep?: boolean;
|
|
261
|
+
substeps?: number;
|
|
262
|
+
iterations?: number;
|
|
263
|
+
damping?: number;
|
|
264
|
+
gravity?: Vec3;
|
|
265
|
+
/** Distance-based bending approximation across shared triangle edges; defaults to true. */
|
|
266
|
+
bending?: boolean;
|
|
267
|
+
anchors?: readonly {
|
|
268
|
+
index: number;
|
|
269
|
+
position: Vec3;
|
|
270
|
+
}[];
|
|
271
|
+
visual?: ClothVisualOptions;
|
|
272
|
+
}
|
|
273
|
+
export interface ClothContact {
|
|
274
|
+
tick: number;
|
|
275
|
+
particle: number;
|
|
276
|
+
colliderId: string;
|
|
277
|
+
penetration: number;
|
|
278
|
+
}
|
|
279
|
+
export interface ClothState {
|
|
280
|
+
tick: number;
|
|
281
|
+
simulatedSeconds: number;
|
|
282
|
+
disposed: boolean;
|
|
283
|
+
positions: Vec3[];
|
|
284
|
+
velocities: Vec3[];
|
|
285
|
+
triangles: ClothTriangle[];
|
|
286
|
+
anchors: {
|
|
287
|
+
index: number;
|
|
288
|
+
position: Vec3;
|
|
289
|
+
}[];
|
|
290
|
+
contacts: ClothContact[];
|
|
291
|
+
maxDistanceError: number;
|
|
292
|
+
resources: {
|
|
293
|
+
particles: number;
|
|
294
|
+
triangles: number;
|
|
295
|
+
constraints: number;
|
|
296
|
+
anchors: number;
|
|
297
|
+
colliders: number;
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
export interface ClothVisualState {
|
|
301
|
+
id: string;
|
|
302
|
+
material: string;
|
|
303
|
+
positions: Vec3[];
|
|
304
|
+
triangles: ClothTriangle[];
|
|
305
|
+
uvs: Vec2[];
|
|
306
|
+
castShadow: boolean;
|
|
307
|
+
receiveShadow: boolean;
|
|
308
|
+
}
|
|
309
|
+
export interface ClothService {
|
|
310
|
+
create(definition: ClothDefinition): void;
|
|
311
|
+
/** Explicit ticks for manual cloth only; automatic cloth rejects this call. */
|
|
312
|
+
step(id: string, ticks?: number): ClothState;
|
|
313
|
+
inspect(id: string): ClothState;
|
|
314
|
+
visuals(): readonly ClothVisualState[];
|
|
315
|
+
setColliders(id: string, colliders: readonly ClothCollider[]): void;
|
|
316
|
+
setAnchor(id: string, index: number, position: Vec3): void;
|
|
317
|
+
releaseAnchor(id: string, index: number): void;
|
|
318
|
+
applyForce(id: string, index: number, force: Vec3): void;
|
|
319
|
+
applyImpulse(id: string, index: number, impulse: Vec3): void;
|
|
320
|
+
destroy(id: string): void;
|
|
321
|
+
}
|
|
225
322
|
/** Integer cells on a finite 2D orthogonal grid; not continuous, flying or underwater navigation.
|
|
226
323
|
* Searches allocate sparse state and depend on available memory. At most 2^32-1 cells may be defined.
|
|
227
324
|
* Owners are explicit strings and are not automatically tied to entities. */
|
package/lib/sdk/index.js
CHANGED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { cloneSerializable } from "../runtime/random.js";
|
|
2
|
+
import { createCloth } from "./cloth.js";
|
|
3
|
+
const fail = (code, detail) => { throw new Error(`TC_CLOTH_${code}: ${detail}`); };
|
|
4
|
+
const validId = (value) => typeof value === "string" && value.trim() === value && value.length > 0 && value.length <= 128;
|
|
5
|
+
function planarUvs(points) {
|
|
6
|
+
const min = [Infinity, Infinity, Infinity], max = [-Infinity, -Infinity, -Infinity];
|
|
7
|
+
for (const point of points)
|
|
8
|
+
for (let axis = 0; axis < 3; axis++) {
|
|
9
|
+
min[axis] = Math.min(min[axis], point[axis]);
|
|
10
|
+
max[axis] = Math.max(max[axis], point[axis]);
|
|
11
|
+
}
|
|
12
|
+
const axes = [0, 1, 2].sort((a, b) => (max[b] - min[b]) - (max[a] - min[a]) || a - b), first = axes[0], second = axes[1];
|
|
13
|
+
const width = max[first] - min[first], height = max[second] - min[second];
|
|
14
|
+
if (!(width > 0) || !(height > 0))
|
|
15
|
+
return fail("VISUAL_UV", "Initial points need two nonzero projection axes or explicit UVs.");
|
|
16
|
+
return points.map(point => [(point[first] - min[first]) / width, (point[second] - min[second]) / height]);
|
|
17
|
+
}
|
|
18
|
+
function createEntry(input, fixedDt, materials) {
|
|
19
|
+
const definition = cloneSerializable(input);
|
|
20
|
+
if (!definition || !validId(definition.id))
|
|
21
|
+
return fail("ID", "Expected a nonempty id of at most 128 characters.");
|
|
22
|
+
if (definition.autoStep !== undefined && typeof definition.autoStep !== "boolean")
|
|
23
|
+
return fail("AUTO_STEP", "autoStep must be boolean.");
|
|
24
|
+
if (definition.fixedDt !== undefined && definition.fixedDt !== fixedDt)
|
|
25
|
+
return fail("FIXED_DT", "Cloth fixedDt must match the host fixedDt.");
|
|
26
|
+
if (fixedDt < 1 / 1000 || fixedDt > 1 / 30)
|
|
27
|
+
return fail("FIXED_DT", "Host fixedDt must be in [1/1000,1/30].");
|
|
28
|
+
if (definition.visual !== undefined) {
|
|
29
|
+
const visual = definition.visual;
|
|
30
|
+
if (!visual || typeof visual !== "object" || Array.isArray(visual) || !validId(visual.material) || !materials.has(visual.material))
|
|
31
|
+
return fail("VISUAL_MATERIAL", "visual.material must name a declared engine material.");
|
|
32
|
+
if ((visual.castShadow !== undefined && typeof visual.castShadow !== "boolean") || (visual.receiveShadow !== undefined && typeof visual.receiveShadow !== "boolean"))
|
|
33
|
+
return fail("VISUAL_SHADOW", "Shadow options must be boolean.");
|
|
34
|
+
if (visual.uvs !== undefined && (!Array.isArray(visual.uvs) || !Array.isArray(definition.points) || visual.uvs.length !== definition.points.length ||
|
|
35
|
+
visual.uvs.some(uv => !Array.isArray(uv) || uv.length !== 2 || !uv.every(value => typeof value === "number" && Number.isFinite(value) && Math.abs(value) <= 1e6))))
|
|
36
|
+
return fail("VISUAL_UV", "UVs must contain one finite 2D coordinate per particle.");
|
|
37
|
+
}
|
|
38
|
+
const solver = createCloth({ ...definition, fixedDt });
|
|
39
|
+
try {
|
|
40
|
+
const uvs = definition.visual ? (definition.visual.uvs ? definition.visual.uvs.map(uv => [uv[0], uv[1]]) : planarUvs(definition.points)) : [];
|
|
41
|
+
return { definition, solver, uvs };
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
solver.dispose();
|
|
45
|
+
throw error;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
function disposeEntries(entries) { for (const entry of entries.values())
|
|
49
|
+
entry.solver.dispose(); entries.clear(); }
|
|
50
|
+
function parseSnapshot(value, fixedDt, materials) {
|
|
51
|
+
if (!value || typeof value !== "object" || value.version !== 1 || !Array.isArray(value.cloths) || value.cloths.length > 256)
|
|
52
|
+
return fail("SNAPSHOT", "Expected a version 1 snapshot containing at most 256 cloths.");
|
|
53
|
+
const entries = new Map();
|
|
54
|
+
try {
|
|
55
|
+
for (const item of value.cloths) {
|
|
56
|
+
if (!item || typeof item !== "object")
|
|
57
|
+
fail("SNAPSHOT", "Invalid cloth entry.");
|
|
58
|
+
const entry = createEntry(item.definition, fixedDt, materials);
|
|
59
|
+
if (entries.has(entry.definition.id)) {
|
|
60
|
+
entry.solver.dispose();
|
|
61
|
+
fail("SNAPSHOT", "Duplicate cloth id.");
|
|
62
|
+
}
|
|
63
|
+
entries.set(entry.definition.id, entry);
|
|
64
|
+
entry.solver.restore(item.state);
|
|
65
|
+
}
|
|
66
|
+
return entries;
|
|
67
|
+
}
|
|
68
|
+
catch (error) {
|
|
69
|
+
disposeEntries(entries);
|
|
70
|
+
throw error;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
export const createClothPlugin = context => {
|
|
74
|
+
const { fixedDt } = context;
|
|
75
|
+
if (typeof fixedDt !== "number" || !Number.isFinite(fixedDt))
|
|
76
|
+
return fail("FIXED_DT", "RuntimeHost must provide a finite fixed interval.");
|
|
77
|
+
const hostDt = fixedDt, materials = new Set(context.scene.materials.map(material => material.id));
|
|
78
|
+
let entries = new Map(), disposed = false;
|
|
79
|
+
const alive = () => { if (disposed)
|
|
80
|
+
fail("DISPOSED", "Cloth service is disposed."); };
|
|
81
|
+
const get = (id) => { alive(); if (!validId(id))
|
|
82
|
+
return fail("ID", "Expected a nonempty cloth id."); return entries.get(id) ?? fail("MISSING", `Cloth '${id}' does not exist.`); };
|
|
83
|
+
const service = {
|
|
84
|
+
create(definition) {
|
|
85
|
+
alive();
|
|
86
|
+
if (!definition || !validId(definition.id))
|
|
87
|
+
return fail("ID", "Expected a nonempty cloth id.");
|
|
88
|
+
if (entries.has(definition.id))
|
|
89
|
+
return fail("DUPLICATE", `Cloth '${definition.id}' already exists.`);
|
|
90
|
+
if (entries.size >= 256)
|
|
91
|
+
return fail("CAPACITY", "At most 256 cloths per host.");
|
|
92
|
+
const entry = createEntry(definition, hostDt, materials);
|
|
93
|
+
entries.set(entry.definition.id, entry);
|
|
94
|
+
},
|
|
95
|
+
step(id, ticks) { const entry = get(id); if (entry.definition.autoStep)
|
|
96
|
+
return fail("AUTO_STEP", `Cloth '${id}' is automatically stepped.`); return entry.solver.step(ticks); },
|
|
97
|
+
inspect(id) { return get(id).solver.inspect(); },
|
|
98
|
+
visuals() {
|
|
99
|
+
alive();
|
|
100
|
+
return [...entries.values()].filter(entry => entry.definition.visual !== undefined)
|
|
101
|
+
.sort((a, b) => a.definition.id < b.definition.id ? -1 : a.definition.id > b.definition.id ? 1 : 0)
|
|
102
|
+
.map(entry => {
|
|
103
|
+
const state = entry.solver.inspect(), visual = entry.definition.visual;
|
|
104
|
+
return { id: entry.definition.id, material: visual.material,
|
|
105
|
+
positions: state.positions, triangles: state.triangles, uvs: entry.uvs.map(uv => [uv[0], uv[1]]),
|
|
106
|
+
castShadow: visual.castShadow ?? true, receiveShadow: visual.receiveShadow ?? true };
|
|
107
|
+
});
|
|
108
|
+
},
|
|
109
|
+
setColliders(id, colliders) { get(id).solver.setColliders(cloneSerializable(colliders)); },
|
|
110
|
+
setAnchor(id, index, position) { get(id).solver.setAnchor(index, cloneSerializable(position)); },
|
|
111
|
+
releaseAnchor(id, index) { get(id).solver.releaseAnchor(index); },
|
|
112
|
+
applyForce(id, index, force) { get(id).solver.applyForce(index, cloneSerializable(force)); },
|
|
113
|
+
applyImpulse(id, index, impulse) { get(id).solver.applyImpulse(index, cloneSerializable(impulse)); },
|
|
114
|
+
destroy(id) { const entry = get(id); entry.solver.dispose(); entries.delete(id); },
|
|
115
|
+
};
|
|
116
|
+
return {
|
|
117
|
+
id: "$engine.cloth", version: "1", services: { cloth: service },
|
|
118
|
+
afterPhysics() {
|
|
119
|
+
alive();
|
|
120
|
+
const committed = [];
|
|
121
|
+
try {
|
|
122
|
+
const automatic = [...entries.values()].filter(entry => entry.definition.autoStep)
|
|
123
|
+
.sort((a, b) => a.definition.id < b.definition.id ? -1 : a.definition.id > b.definition.id ? 1 : 0);
|
|
124
|
+
for (const { solver } of automatic) {
|
|
125
|
+
const snapshot = solver.snapshot();
|
|
126
|
+
committed.push({ solver, snapshot });
|
|
127
|
+
solver.step();
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
catch (error) {
|
|
131
|
+
const failures = [];
|
|
132
|
+
for (const { solver, snapshot } of committed.reverse())
|
|
133
|
+
try {
|
|
134
|
+
solver.restore(snapshot);
|
|
135
|
+
}
|
|
136
|
+
catch (rollbackError) {
|
|
137
|
+
failures.push(String(rollbackError));
|
|
138
|
+
}
|
|
139
|
+
if (failures.length)
|
|
140
|
+
throw new Error(`TC_CLOTH_ROLLBACK: ${String(error)}; partialState=true; rollback failures: ${failures.join("; ")}`);
|
|
141
|
+
throw error;
|
|
142
|
+
}
|
|
143
|
+
},
|
|
144
|
+
legacySnapshot: () => ({ version: 1, cloths: [] }),
|
|
145
|
+
snapshot: () => ({ version: 1, cloths: [...entries.values()].sort((a, b) => a.definition.id < b.definition.id ? -1 : a.definition.id > b.definition.id ? 1 : 0)
|
|
146
|
+
.map(({ definition, solver }) => ({ definition: cloneSerializable(definition), state: solver.snapshot() })) }),
|
|
147
|
+
validateSnapshot(value) { const staged = parseSnapshot(value, hostDt, materials); disposeEntries(staged); },
|
|
148
|
+
restore(value) { alive(); const staged = parseSnapshot(value, hostDt, materials); disposeEntries(entries); entries = staged; },
|
|
149
|
+
dispose() { if (disposed)
|
|
150
|
+
return; disposed = true; disposeEntries(entries); },
|
|
151
|
+
};
|
|
152
|
+
};
|