pelican-engine 0.3.18 → 0.3.20
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 +10 -2
- package/distribution.json +12 -12
- package/docs/STATUS.md +2 -2
- package/docs/api/REFERENCE.md +104 -94
- package/docs/api/index.json +110 -95
- package/docs/npm/GAME-AUTHOR.md +10 -2
- package/engine.manifest.json +1 -1
- package/lib/sdk/index.d.ts +11 -1
- package/lib/sdk/index.js +1 -1
- package/lib/simulation/cloth-plugin.js +10 -3
- package/lib/simulation/cloth.js +54 -10
- package/package.json +1 -1
package/docs/npm/GAME-AUTHOR.md
CHANGED
|
@@ -157,8 +157,8 @@ reports the active mode. Set `selfCollisions: false` to disable it. Adjacent
|
|
|
157
157
|
segments and collisions between separate ropes are not handled. Immovable anchored
|
|
158
158
|
endpoints stay fixed, and contacts introduced by corrections can wait until the
|
|
159
159
|
next iteration. Dense overlap can approach quadratic work. Snapshots from before
|
|
160
|
-
this feature retain the disabled mode when migrated;
|
|
161
|
-
still apply. This is unverified discrete contact, not a guarantee against
|
|
160
|
+
this feature retain the disabled mode when migrated; invalid or duplicate self
|
|
161
|
+
contacts are rejected atomically; Host engine-version rules still apply. This is unverified discrete contact, not a guarantee against
|
|
162
162
|
penetration, continuous collision, friction or full rope physics. The source
|
|
163
163
|
checkout includes `examples/rope-self`.
|
|
164
164
|
|
|
@@ -233,6 +233,14 @@ manual cloth uses `ctx.cloth.step`. Use `setAnchor`, `releaseAnchor`, `applyForc
|
|
|
233
233
|
(next tick), `applyImpulse`, `setColliders`, `inspect`, and `destroy` to manage it.
|
|
234
234
|
The source checkout includes `examples/cloth-sheet`.
|
|
235
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
|
+
|
|
236
244
|
The CPU solver uses edge distance constraints and an optional distance-based
|
|
237
245
|
bending approximation (`bending: true` by default). Sampled planes and spheres
|
|
238
246
|
only move cloth particles; they do not receive reactions. Contacts do not cover
|
package/engine.manifest.json
CHANGED
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.20";
|
|
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];
|
|
@@ -269,6 +269,8 @@ export interface ClothDefinition {
|
|
|
269
269
|
position: Vec3;
|
|
270
270
|
}[];
|
|
271
271
|
visual?: ClothVisualOptions;
|
|
272
|
+
/** Discrete non-adjacent particle self-contact; defaults to true for new cloth. */
|
|
273
|
+
selfCollisions?: boolean;
|
|
272
274
|
}
|
|
273
275
|
export interface ClothContact {
|
|
274
276
|
tick: number;
|
|
@@ -276,10 +278,17 @@ export interface ClothContact {
|
|
|
276
278
|
colliderId: string;
|
|
277
279
|
penetration: number;
|
|
278
280
|
}
|
|
281
|
+
export interface ClothSelfContact {
|
|
282
|
+
tick: number;
|
|
283
|
+
firstParticle: number;
|
|
284
|
+
secondParticle: number;
|
|
285
|
+
penetration: number;
|
|
286
|
+
}
|
|
279
287
|
export interface ClothState {
|
|
280
288
|
tick: number;
|
|
281
289
|
simulatedSeconds: number;
|
|
282
290
|
disposed: boolean;
|
|
291
|
+
selfCollisions: boolean;
|
|
283
292
|
positions: Vec3[];
|
|
284
293
|
velocities: Vec3[];
|
|
285
294
|
triangles: ClothTriangle[];
|
|
@@ -288,6 +297,7 @@ export interface ClothState {
|
|
|
288
297
|
position: Vec3;
|
|
289
298
|
}[];
|
|
290
299
|
contacts: ClothContact[];
|
|
300
|
+
selfContacts: ClothSelfContact[];
|
|
291
301
|
maxDistanceError: number;
|
|
292
302
|
resources: {
|
|
293
303
|
particles: number;
|
package/lib/sdk/index.js
CHANGED
|
@@ -15,12 +15,16 @@ function planarUvs(points) {
|
|
|
15
15
|
return fail("VISUAL_UV", "Initial points need two nonzero projection axes or explicit UVs.");
|
|
16
16
|
return points.map(point => [(point[first] - min[first]) / width, (point[second] - min[second]) / height]);
|
|
17
17
|
}
|
|
18
|
-
function createEntry(input, fixedDt, materials) {
|
|
18
|
+
function createEntry(input, fixedDt, materials, restoredSelfMode) {
|
|
19
19
|
const definition = cloneSerializable(input);
|
|
20
20
|
if (!definition || !validId(definition.id))
|
|
21
21
|
return fail("ID", "Expected a nonempty id of at most 128 characters.");
|
|
22
22
|
if (definition.autoStep !== undefined && typeof definition.autoStep !== "boolean")
|
|
23
23
|
return fail("AUTO_STEP", "autoStep must be boolean.");
|
|
24
|
+
if (definition.selfCollisions !== undefined && typeof definition.selfCollisions !== "boolean")
|
|
25
|
+
return fail("SELF_COLLISION", "selfCollisions must be boolean.");
|
|
26
|
+
if (restoredSelfMode !== undefined && definition.selfCollisions !== undefined && definition.selfCollisions !== restoredSelfMode)
|
|
27
|
+
return fail("SNAPSHOT", "Self collision configuration does not match the saved solver.");
|
|
24
28
|
if (definition.fixedDt !== undefined && definition.fixedDt !== fixedDt)
|
|
25
29
|
return fail("FIXED_DT", "Cloth fixedDt must match the host fixedDt.");
|
|
26
30
|
if (fixedDt < 1 / 1000 || fixedDt > 1 / 30)
|
|
@@ -35,7 +39,7 @@ function createEntry(input, fixedDt, materials) {
|
|
|
35
39
|
visual.uvs.some(uv => !Array.isArray(uv) || uv.length !== 2 || !uv.every(value => typeof value === "number" && Number.isFinite(value) && Math.abs(value) <= 1e6))))
|
|
36
40
|
return fail("VISUAL_UV", "UVs must contain one finite 2D coordinate per particle.");
|
|
37
41
|
}
|
|
38
|
-
const solver = createCloth({ ...definition, fixedDt });
|
|
42
|
+
const solver = createCloth({ ...definition, fixedDt, ...(restoredSelfMode === undefined ? {} : { selfCollisions: restoredSelfMode }) });
|
|
39
43
|
try {
|
|
40
44
|
const uvs = definition.visual ? (definition.visual.uvs ? definition.visual.uvs.map(uv => [uv[0], uv[1]]) : planarUvs(definition.points)) : [];
|
|
41
45
|
return { definition, solver, uvs };
|
|
@@ -55,7 +59,10 @@ function parseSnapshot(value, fixedDt, materials) {
|
|
|
55
59
|
for (const item of value.cloths) {
|
|
56
60
|
if (!item || typeof item !== "object")
|
|
57
61
|
fail("SNAPSHOT", "Invalid cloth entry.");
|
|
58
|
-
const
|
|
62
|
+
const savedSelfMode = item.state?.config?.selfCollisions;
|
|
63
|
+
if (savedSelfMode !== undefined && typeof savedSelfMode !== "boolean")
|
|
64
|
+
fail("SNAPSHOT", "Invalid self collision configuration.");
|
|
65
|
+
const entry = createEntry(item.definition, fixedDt, materials, savedSelfMode ?? false);
|
|
59
66
|
if (entries.has(entry.definition.id)) {
|
|
60
67
|
entry.solver.dispose();
|
|
61
68
|
fail("SNAPSHOT", "Duplicate cloth id.");
|
package/lib/simulation/cloth.js
CHANGED
|
@@ -110,12 +110,14 @@ export function createCloth(input) {
|
|
|
110
110
|
fail("CLOTH_INPUT", "damping must be in [0,100]");
|
|
111
111
|
if (input.bending !== undefined && typeof input.bending !== "boolean")
|
|
112
112
|
fail("CLOTH_INPUT", "bending must be boolean");
|
|
113
|
-
const bending = input.bending ?? true, gravity = readVec(input.gravity ?? [0, -9.81, 0], "gravity");
|
|
113
|
+
const bending = input.bending ?? true, selfCollisions = input.selfCollisions ?? true, gravity = readVec(input.gravity ?? [0, -9.81, 0], "gravity");
|
|
114
|
+
if (typeof selfCollisions !== "boolean")
|
|
115
|
+
fail("CLOTH_INPUT", "selfCollisions must be boolean");
|
|
114
116
|
const { triangles, constraints } = topology(positions, input.triangles, bending);
|
|
115
117
|
const anchors = readAnchors(input.anchors ?? [], positions.length);
|
|
116
118
|
for (const [index, point] of anchors)
|
|
117
119
|
positions[index] = copy(point);
|
|
118
|
-
return new ClothSolver({ mass, radius, fixedDt, substeps, iterations, damping, gravity, bending, triangles, constraints }, positions, anchors);
|
|
120
|
+
return new ClothSolver({ mass, radius, fixedDt, substeps, iterations, damping, gravity, bending, selfCollisions, triangles, constraints }, positions, anchors);
|
|
119
121
|
}
|
|
120
122
|
export class ClothSolver {
|
|
121
123
|
config;
|
|
@@ -127,12 +129,15 @@ export class ClothSolver {
|
|
|
127
129
|
forces;
|
|
128
130
|
colliders = [];
|
|
129
131
|
contacts = [];
|
|
132
|
+
selfContacts = [];
|
|
133
|
+
adjacent;
|
|
130
134
|
constructor(config, positions, anchors) {
|
|
131
135
|
this.config = config;
|
|
132
136
|
this.positions = positions;
|
|
133
137
|
this.anchors = anchors;
|
|
134
138
|
this.velocities = positions.map(() => [0, 0, 0]);
|
|
135
139
|
this.forces = positions.map(() => [0, 0, 0]);
|
|
140
|
+
this.adjacent = new Set(config.triangles.flatMap(([a, b, c]) => [`${Math.min(a, b)}:${Math.max(a, b)}`, `${Math.min(b, c)}:${Math.max(b, c)}`, `${Math.min(c, a)}:${Math.max(c, a)}`]));
|
|
136
141
|
}
|
|
137
142
|
alive() { if (this.disposed)
|
|
138
143
|
fail("CLOTH_DISPOSED", "cloth has been disposed"); }
|
|
@@ -171,7 +176,7 @@ export class ClothSolver {
|
|
|
171
176
|
this.alive();
|
|
172
177
|
if (!Number.isSafeInteger(steps) || steps < 1 || steps > 1000 || this.tickValue + steps > Number.MAX_SAFE_INTEGER)
|
|
173
178
|
fail("CLOTH_INPUT", "steps must be in [1,1000] without tick overflow");
|
|
174
|
-
const before = { positions: this.positions, velocities: this.velocities, forces: this.forces, contacts: this.contacts, tick: this.tickValue };
|
|
179
|
+
const before = { positions: this.positions, velocities: this.velocities, forces: this.forces, contacts: this.contacts, selfContacts: this.selfContacts, tick: this.tickValue };
|
|
175
180
|
try {
|
|
176
181
|
for (let i = 0; i < steps; i++)
|
|
177
182
|
this.stepOne();
|
|
@@ -181,6 +186,7 @@ export class ClothSolver {
|
|
|
181
186
|
this.velocities = before.velocities;
|
|
182
187
|
this.forces = before.forces;
|
|
183
188
|
this.contacts = before.contacts;
|
|
189
|
+
this.selfContacts = before.selfContacts;
|
|
184
190
|
this.tickValue = before.tick;
|
|
185
191
|
throw error;
|
|
186
192
|
}
|
|
@@ -189,7 +195,7 @@ export class ClothSolver {
|
|
|
189
195
|
stepOne() {
|
|
190
196
|
const { fixedDt, substeps, iterations, mass, gravity, damping, constraints, radius } = this.config;
|
|
191
197
|
const h = fixedDt / substeps, decay = Math.exp(-damping * h);
|
|
192
|
-
const positions = this.positions.map(copy), velocities = this.velocities.map(copy), contacts = new Map();
|
|
198
|
+
const positions = this.positions.map(copy), velocities = this.velocities.map(copy), contacts = new Map(), selfContacts = new Map();
|
|
193
199
|
for (let sub = 0; sub < substeps; sub++) {
|
|
194
200
|
const previous = positions.map(copy);
|
|
195
201
|
for (let i = 0; i < positions.length; i++) {
|
|
@@ -250,6 +256,29 @@ export class ClothSolver {
|
|
|
250
256
|
contacts.set(key, { tick: this.tickValue + 1, particle: i, colliderId: collider.id, penetration: depth });
|
|
251
257
|
}
|
|
252
258
|
}
|
|
259
|
+
if (this.config.selfCollisions)
|
|
260
|
+
for (let i = 0; i < positions.length; i++)
|
|
261
|
+
for (let j = i + 1; j < positions.length; j++) {
|
|
262
|
+
if (this.adjacent.has(`${i}:${j}`))
|
|
263
|
+
continue;
|
|
264
|
+
const a = positions[i], b = positions[j], dx = a[0] - b[0], dy = a[1] - b[1], dz = a[2] - b[2], length = Math.hypot(dx, dy, dz), depth = 2 * radius - length;
|
|
265
|
+
if (depth <= 0)
|
|
266
|
+
continue;
|
|
267
|
+
const normal = length < 1e-12 ? [1, 0, 0] : [dx / length, dy / length, dz / length];
|
|
268
|
+
const wa = this.anchors.has(i) ? 0 : 1, wb = this.anchors.has(j) ? 0 : 1;
|
|
269
|
+
if (wa + wb === 0)
|
|
270
|
+
continue;
|
|
271
|
+
const correction = depth / (wa + wb);
|
|
272
|
+
for (let axis = 0; axis < 3; axis++) {
|
|
273
|
+
a[axis] += normal[axis] * correction * wa;
|
|
274
|
+
b[axis] -= normal[axis] * correction * wb;
|
|
275
|
+
}
|
|
276
|
+
const key = `${i}:${j}`, old = selfContacts.get(key);
|
|
277
|
+
if (old)
|
|
278
|
+
old.penetration = Math.max(old.penetration, depth);
|
|
279
|
+
else
|
|
280
|
+
selfContacts.set(key, { tick: this.tickValue + 1, firstParticle: i, secondParticle: j, penetration: depth });
|
|
281
|
+
}
|
|
253
282
|
}
|
|
254
283
|
for (let i = 0; i < positions.length; i++) {
|
|
255
284
|
if (this.anchors.has(i))
|
|
@@ -264,18 +293,19 @@ export class ClothSolver {
|
|
|
264
293
|
this.velocities = velocities;
|
|
265
294
|
this.forces = positions.map(() => [0, 0, 0]);
|
|
266
295
|
this.contacts = [...contacts.values()];
|
|
296
|
+
this.selfContacts = [...selfContacts.values()];
|
|
267
297
|
this.tickValue++;
|
|
268
298
|
}
|
|
269
299
|
inspect() {
|
|
270
300
|
if (this.disposed)
|
|
271
|
-
return { tick: this.tickValue, simulatedSeconds: this.tickValue * this.config.fixedDt, disposed: true, positions: [], velocities: [], triangles: [], anchors: [], contacts: [], maxDistanceError: 0, resources: { particles: 0, triangles: 0, constraints: 0, anchors: 0, colliders: 0 } };
|
|
301
|
+
return { tick: this.tickValue, simulatedSeconds: this.tickValue * this.config.fixedDt, disposed: true, selfCollisions: this.config.selfCollisions, positions: [], velocities: [], triangles: [], anchors: [], contacts: [], selfContacts: [], maxDistanceError: 0, resources: { particles: 0, triangles: 0, constraints: 0, anchors: 0, colliders: 0 } };
|
|
272
302
|
let maxDistanceError = 0;
|
|
273
303
|
for (const [a, b, rest] of this.config.constraints)
|
|
274
304
|
maxDistanceError = Math.max(maxDistanceError, Math.abs(distance(this.positions[a], this.positions[b]) - rest));
|
|
275
|
-
return { tick: this.tickValue, simulatedSeconds: this.tickValue * this.config.fixedDt, disposed: false,
|
|
305
|
+
return { tick: this.tickValue, simulatedSeconds: this.tickValue * this.config.fixedDt, disposed: false, selfCollisions: this.config.selfCollisions,
|
|
276
306
|
positions: this.positions.map(copy), velocities: this.velocities.map(copy), triangles: this.config.triangles.map(tuple),
|
|
277
307
|
anchors: [...this.anchors].map(([index, position]) => ({ index, position: copy(position) })).sort((a, b) => a.index - b.index),
|
|
278
|
-
contacts: this.contacts.map(contact => ({ ...contact })), maxDistanceError,
|
|
308
|
+
contacts: this.contacts.map(contact => ({ ...contact })), selfContacts: this.selfContacts.map(contact => ({ ...contact })), maxDistanceError,
|
|
279
309
|
resources: { particles: this.positions.length, triangles: this.config.triangles.length, constraints: this.config.constraints.length, anchors: this.anchors.size, colliders: this.colliders.length } };
|
|
280
310
|
}
|
|
281
311
|
snapshot() {
|
|
@@ -283,7 +313,7 @@ export class ClothSolver {
|
|
|
283
313
|
const state = this.inspect();
|
|
284
314
|
return { format: "pelican-cloth-cpu", version: 1, tick: this.tickValue,
|
|
285
315
|
config: { ...this.config, gravity: copy(this.config.gravity), triangles: this.config.triangles.map(tuple), constraints: this.config.constraints.map(tuple) },
|
|
286
|
-
positions: state.positions, velocities: state.velocities, forces: this.forces.map(copy), anchors: state.anchors, colliders: readColliders(this.colliders), contacts: state.contacts };
|
|
316
|
+
positions: state.positions, velocities: state.velocities, forces: this.forces.map(copy), anchors: state.anchors, colliders: readColliders(this.colliders), contacts: state.contacts, selfContacts: state.selfContacts };
|
|
287
317
|
}
|
|
288
318
|
restore(input) {
|
|
289
319
|
this.alive();
|
|
@@ -291,7 +321,7 @@ export class ClothSolver {
|
|
|
291
321
|
fail("CLOTH_SNAPSHOT", "unsupported snapshot format or tick");
|
|
292
322
|
const config = input.config, expected = this.config;
|
|
293
323
|
if (!config || config.mass !== expected.mass || config.radius !== expected.radius || config.fixedDt !== expected.fixedDt || config.substeps !== expected.substeps ||
|
|
294
|
-
config.iterations !== expected.iterations || config.damping !== expected.damping || config.bending !== expected.bending ||
|
|
324
|
+
config.iterations !== expected.iterations || config.damping !== expected.damping || config.bending !== expected.bending || (config.selfCollisions ?? false) !== expected.selfCollisions ||
|
|
295
325
|
!vec(config.gravity) || config.gravity.some((value, i) => value !== expected.gravity[i]) ||
|
|
296
326
|
!Array.isArray(config.triangles) || JSON.stringify(config.triangles) !== JSON.stringify(expected.triangles) ||
|
|
297
327
|
!Array.isArray(config.constraints) || JSON.stringify(config.constraints) !== JSON.stringify(expected.constraints))
|
|
@@ -314,14 +344,28 @@ export class ClothSolver {
|
|
|
314
344
|
fail("CLOTH_SNAPSHOT", "duplicate contact");
|
|
315
345
|
keys.add(key);
|
|
316
346
|
}
|
|
347
|
+
const selfContacts = input.selfContacts ?? [];
|
|
348
|
+
const selfKeys = new Set();
|
|
349
|
+
if (!Array.isArray(selfContacts) || selfContacts.length > count * Math.max(0, count - 1) / 2 || (!this.config.selfCollisions && selfContacts.length > 0) ||
|
|
350
|
+
selfContacts.some(contact => !contact || contact.tick !== input.tick || !Number.isSafeInteger(contact.firstParticle) || !Number.isSafeInteger(contact.secondParticle) ||
|
|
351
|
+
contact.firstParticle < 0 || contact.secondParticle >= count || contact.firstParticle >= contact.secondParticle ||
|
|
352
|
+
this.adjacent.has(`${contact.firstParticle}:${contact.secondParticle}`) || typeof contact.penetration !== "number" || !Number.isFinite(contact.penetration) || contact.penetration <= 0))
|
|
353
|
+
fail("CLOTH_SNAPSHOT", "invalid self contact state");
|
|
354
|
+
for (const contact of selfContacts) {
|
|
355
|
+
const key = `${contact.firstParticle}:${contact.secondParticle}`;
|
|
356
|
+
if (selfKeys.has(key))
|
|
357
|
+
fail("CLOTH_SNAPSHOT", "duplicate self contact");
|
|
358
|
+
selfKeys.add(key);
|
|
359
|
+
}
|
|
317
360
|
this.positions = input.positions.map(copy);
|
|
318
361
|
this.velocities = input.velocities.map(copy);
|
|
319
362
|
this.forces = input.forces.map(copy);
|
|
320
363
|
this.anchors = anchors;
|
|
321
364
|
this.colliders = colliders;
|
|
322
365
|
this.contacts = input.contacts.map(contact => ({ ...contact }));
|
|
366
|
+
this.selfContacts = selfContacts.map(contact => ({ ...contact }));
|
|
323
367
|
this.tickValue = input.tick;
|
|
324
368
|
}
|
|
325
369
|
dispose() { if (this.disposed)
|
|
326
|
-
return; this.disposed = true; this.positions = []; this.velocities = []; this.forces = []; this.colliders = []; this.contacts = []; this.anchors.clear(); }
|
|
370
|
+
return; this.disposed = true; this.positions = []; this.velocities = []; this.forces = []; this.colliders = []; this.contacts = []; this.selfContacts = []; this.anchors.clear(); }
|
|
327
371
|
}
|