pelican-engine 0.3.17 → 0.3.19
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 +30 -0
- package/distribution.json +16 -13
- package/docs/STATUS.md +2 -1
- package/docs/api/REFERENCE.md +218 -98
- package/docs/api/index.json +225 -100
- package/docs/npm/GAME-AUTHOR.md +30 -0
- 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 +92 -1
- package/lib/sdk/index.js +1 -1
- package/lib/simulation/cloth-plugin.js +152 -0
- package/lib/simulation/cloth.js +358 -0
- package/package.json +1 -1
package/docs/npm/GAME-AUTHOR.md
CHANGED
|
@@ -222,6 +222,36 @@ general rigid-body coupling, continuous collision, cross-rope collision, or tear
|
|
|
222
222
|
normal distance from the origin. Host intervals must be between 1/1000 and
|
|
223
223
|
1/30 seconds inclusive.
|
|
224
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
|
+
New cloth also resolves discrete self-contact between non-adjacent particles by
|
|
237
|
+
projecting movable particles apart; shared triangle edges are excluded.
|
|
238
|
+
`selfCollisions` defaults to true for new cloth and is reported by
|
|
239
|
+
`ctx.cloth.inspect(id)`. Set it to false for particle-only cloth. Self-contact
|
|
240
|
+
state is included in snapshots. This is particle self-contact, not triangle
|
|
241
|
+
interior/edge collision, continuous collision, friction or a guarantee against
|
|
242
|
+
penetration. It has not been runtime or stability validated.
|
|
243
|
+
|
|
244
|
+
The CPU solver uses edge distance constraints and an optional distance-based
|
|
245
|
+
bending approximation (`bending: true` by default). Sampled planes and spheres
|
|
246
|
+
only move cloth particles; they do not receive reactions. Contacts do not cover
|
|
247
|
+
triangle interiors or edges. There is no self-contact, cloth–rope interaction,
|
|
248
|
+
rigid-body coupling, tearing, friction or continuous collision. The optional
|
|
249
|
+
visual deforms the actual triangle mesh; UVs may be provided per particle or use a
|
|
250
|
+
planar projection. Use a double-sided material when both sides must be visible.
|
|
251
|
+
Collapsed or unrepresentable triangles return `TC_CLOTH_VISUAL_DEGENERATE` rather
|
|
252
|
+
than being silently replaced. Runtime, GPU, performance, stability and snapshot
|
|
253
|
+
compatibility have not been validated; this is not complete cloth physics.
|
|
254
|
+
|
|
225
255
|
Keyboard bindings use the browser's `KeyboardEvent.key` values, not physical
|
|
226
256
|
`KeyboardEvent.code` values. For example, use `input: { up: ["w", "W", "ArrowUp"],
|
|
227
257
|
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.19";
|
|
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,6 +102,7 @@ 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
108
|
* contact impulses to dynamic spheres; there is no full rigid-body coupling. */
|
|
@@ -238,6 +239,96 @@ export interface RopeService {
|
|
|
238
239
|
applyImpulse(id: string, index: number, impulse: Vec3): void;
|
|
239
240
|
destroy(id: string): void;
|
|
240
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
|
+
/** Discrete non-adjacent particle self-contact; defaults to true for new cloth. */
|
|
273
|
+
selfCollisions?: boolean;
|
|
274
|
+
}
|
|
275
|
+
export interface ClothContact {
|
|
276
|
+
tick: number;
|
|
277
|
+
particle: number;
|
|
278
|
+
colliderId: string;
|
|
279
|
+
penetration: number;
|
|
280
|
+
}
|
|
281
|
+
export interface ClothSelfContact {
|
|
282
|
+
tick: number;
|
|
283
|
+
firstParticle: number;
|
|
284
|
+
secondParticle: number;
|
|
285
|
+
penetration: number;
|
|
286
|
+
}
|
|
287
|
+
export interface ClothState {
|
|
288
|
+
tick: number;
|
|
289
|
+
simulatedSeconds: number;
|
|
290
|
+
disposed: boolean;
|
|
291
|
+
selfCollisions: boolean;
|
|
292
|
+
positions: Vec3[];
|
|
293
|
+
velocities: Vec3[];
|
|
294
|
+
triangles: ClothTriangle[];
|
|
295
|
+
anchors: {
|
|
296
|
+
index: number;
|
|
297
|
+
position: Vec3;
|
|
298
|
+
}[];
|
|
299
|
+
contacts: ClothContact[];
|
|
300
|
+
selfContacts: ClothSelfContact[];
|
|
301
|
+
maxDistanceError: number;
|
|
302
|
+
resources: {
|
|
303
|
+
particles: number;
|
|
304
|
+
triangles: number;
|
|
305
|
+
constraints: number;
|
|
306
|
+
anchors: number;
|
|
307
|
+
colliders: number;
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
export interface ClothVisualState {
|
|
311
|
+
id: string;
|
|
312
|
+
material: string;
|
|
313
|
+
positions: Vec3[];
|
|
314
|
+
triangles: ClothTriangle[];
|
|
315
|
+
uvs: Vec2[];
|
|
316
|
+
castShadow: boolean;
|
|
317
|
+
receiveShadow: boolean;
|
|
318
|
+
}
|
|
319
|
+
export interface ClothService {
|
|
320
|
+
create(definition: ClothDefinition): void;
|
|
321
|
+
/** Explicit ticks for manual cloth only; automatic cloth rejects this call. */
|
|
322
|
+
step(id: string, ticks?: number): ClothState;
|
|
323
|
+
inspect(id: string): ClothState;
|
|
324
|
+
visuals(): readonly ClothVisualState[];
|
|
325
|
+
setColliders(id: string, colliders: readonly ClothCollider[]): void;
|
|
326
|
+
setAnchor(id: string, index: number, position: Vec3): void;
|
|
327
|
+
releaseAnchor(id: string, index: number): void;
|
|
328
|
+
applyForce(id: string, index: number, force: Vec3): void;
|
|
329
|
+
applyImpulse(id: string, index: number, impulse: Vec3): void;
|
|
330
|
+
destroy(id: string): void;
|
|
331
|
+
}
|
|
241
332
|
/** Integer cells on a finite 2D orthogonal grid; not continuous, flying or underwater navigation.
|
|
242
333
|
* Searches allocate sparse state and depend on available memory. At most 2^32-1 cells may be defined.
|
|
243
334
|
* 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
|
+
};
|