pelican-engine 0.3.13 → 0.3.15
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 +28 -11
- package/distribution.json +17 -15
- package/docs/STATUS.md +3 -3
- package/docs/api/REFERENCE.md +127 -93
- package/docs/api/index.json +125 -95
- package/docs/npm/GAME-AUTHOR.md +28 -11
- package/engine.manifest.json +1 -1
- package/lib/physics/index.js +20 -7
- package/lib/renderer/browser.js +24 -3
- package/lib/renderer/geometry.js +26 -16
- package/lib/renderer/rope-visuals.js +122 -0
- package/lib/sdk/index.d.ts +26 -4
- package/lib/sdk/index.js +1 -1
- package/lib/simulation/rope-body-coupling.js +107 -0
- package/lib/simulation/rope-plugin.js +49 -28
- package/lib/simulation/rope.js +32 -16
- package/package.json +1 -1
package/docs/npm/GAME-AUTHOR.md
CHANGED
|
@@ -137,12 +137,28 @@ all earlier automatic ropes from that phase; the host tick may still have other
|
|
|
137
137
|
partial changes and must be recovered from a known-good checkpoint. The source
|
|
138
138
|
checkout includes `examples/rope-particles`.
|
|
139
139
|
|
|
140
|
+
For optional visual presentation, declare a material in `defineGame({ materials:
|
|
141
|
+
[...] })` and create the rope with `visual: { material: "your-material-id" }`.
|
|
142
|
+
The renderer draws capped cylindrical instances between consecutive simulated
|
|
143
|
+
particles and updates their transforms as the rope moves. `radius` defaults to
|
|
144
|
+
the particle collision radius (or 0.025); `radialSegments` defaults to 8 and
|
|
145
|
+
accepts 3–32. `castShadow` and `receiveShadow` default to true. A collapsed
|
|
146
|
+
zero-length segment has no visible surface. `ctx.ropes.visuals()` returns
|
|
147
|
+
detached descriptions, not renderer objects. Visual configuration is included
|
|
148
|
+
in the rope snapshot and is disposed with the owning content.
|
|
149
|
+
|
|
150
|
+
This is segmented geometry with overlapping caps, not a continuous deformable
|
|
151
|
+
tube. Large ropes may be expensive. Raster, ray-traced modes, GPU resource
|
|
152
|
+
lifecycle and snapshot restoration have not been runtime validated.
|
|
153
|
+
|
|
140
154
|
To bind a rope to existing dynamic sphere bodies, create it with `autoStep: true`
|
|
141
155
|
and `bodyColliders: [{ id: "rope-collider-id", entity: "body-id" }]`. After the
|
|
142
156
|
rigid-body physics phase, the host samples each bound sphere's actual collider
|
|
143
|
-
center
|
|
144
|
-
|
|
145
|
-
|
|
157
|
+
center, radius, velocity and effective inverse mass/inertia, then steps the rope
|
|
158
|
+
and applies the resulting normal contact impulse to the body. At detected overlaps,
|
|
159
|
+
particle and virtual body corrections share effective mass and address closing
|
|
160
|
+
normal velocity; contacts are not predicted before overlap.
|
|
161
|
+
The actual body's displacement responds on the next body step. Bindings require live dynamic non-trigger spheres and unique
|
|
146
162
|
collider ids and body entities within each rope. Do not use a bound collider id
|
|
147
163
|
in `setColliders`; its manual planes and spheres remain separate. A destroyed body
|
|
148
164
|
is unbound at the next automatic step, and a new entity with the same id is not
|
|
@@ -150,13 +166,15 @@ implicitly rebound. Bindings are saved with Host snapshots.
|
|
|
150
166
|
|
|
151
167
|
Do not manually apply the same contact's reaction when using a body binding: the
|
|
152
168
|
host already applies it. A native failure can still leave the host tick partially
|
|
153
|
-
changed, so restore a known-good checkpoint. This is
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
169
|
+
changed, so restore a known-good checkpoint. This is an approximate split-step
|
|
170
|
+
solver: virtual positions use a first-order update and the real body moves on its
|
|
171
|
+
next step. Ropes are processed sequentially, without globally coupled substeps;
|
|
172
|
+
friction, restitution and continuous collision are not included. Penetration
|
|
173
|
+
correction can inject energy; stability, conservation, runtime and snapshot
|
|
174
|
+
compatibility have not been validated.
|
|
157
175
|
|
|
158
176
|
For each latest-tick contact, `reactionImpulse` and `reactionAngularImpulse`
|
|
159
|
-
report the opposite impulse produced by the particle's
|
|
177
|
+
report the opposite impulse produced by the particle's contact response,
|
|
160
178
|
and its moment about the world origin. These optional fields are absent when
|
|
161
179
|
restoring older contact snapshots. Contacts from unbound manual colliders do not
|
|
162
180
|
automatically act on a rigid body.
|
|
@@ -174,9 +192,8 @@ operations and is not transactional if a tick fails; recover from a known-good
|
|
|
174
192
|
checkpoint. It must not be represented as complete two-way rigid-body interaction.
|
|
175
193
|
|
|
176
194
|
This is an unverified development-stage increment, not complete rope physics:
|
|
177
|
-
|
|
178
|
-
or tearing.
|
|
179
|
-
coupled physics. Plane normals must be unit length; `offset` is the signed
|
|
195
|
+
the optional segmented visual does not provide a continuous deformable rope mesh,
|
|
196
|
+
general rigid-body coupling, segment or self collision, or tearing. Plane normals must be unit length; `offset` is the signed
|
|
180
197
|
normal distance from the origin. Host intervals must be between 1/1000 and
|
|
181
198
|
1/30 seconds inclusive.
|
|
182
199
|
|
package/engine.manifest.json
CHANGED
package/lib/physics/index.js
CHANGED
|
@@ -813,7 +813,13 @@ function createPlugin(context) {
|
|
|
813
813
|
const b = get(id);
|
|
814
814
|
if (b.type !== "dynamic" || b.definition.rigidBody?.collider.shape !== "sphere" || b.collider.isSensor())
|
|
815
815
|
fail("TC_PHYSICS_ROPE_BODY", `Rope body '${id}' must be a live dynamic, non-trigger sphere.`);
|
|
816
|
-
|
|
816
|
+
const inertia = b.body.effectiveWorldInvInertia();
|
|
817
|
+
return {
|
|
818
|
+
center: tuple(b.collider.translation()), radius: b.collider.radius(),
|
|
819
|
+
centerOfMass: tuple(b.body.worldCom()), linearVelocity: tuple(b.body.linvel()),
|
|
820
|
+
angularVelocity: tuple(b.body.angvel()), inverseMass: tuple(b.body.effectiveInvMass()),
|
|
821
|
+
inverseInertia: [inertia.m11, inertia.m12, inertia.m13, inertia.m22, inertia.m23, inertia.m33],
|
|
822
|
+
};
|
|
817
823
|
};
|
|
818
824
|
return {
|
|
819
825
|
id: pluginId,
|
|
@@ -823,16 +829,23 @@ function createPlugin(context) {
|
|
|
823
829
|
sampleSphere: sampleRopeSphere,
|
|
824
830
|
applySphereReactions(reactions) {
|
|
825
831
|
alive();
|
|
826
|
-
if (history.length + reactions.length > limit)
|
|
827
|
-
fail("TC_PHYSICS_HISTORY_LIMIT", "Rope reactions exceed physics history capacity; start a new scene.");
|
|
828
832
|
const seen = new Set();
|
|
829
|
-
const actions =
|
|
833
|
+
const actions = [];
|
|
834
|
+
for (const { entity, impulse, moment } of reactions) {
|
|
830
835
|
if (seen.has(entity))
|
|
831
836
|
fail("TC_PHYSICS_ROPE_BODY", `Duplicate rope reaction body '${entity}'.`);
|
|
832
837
|
seen.add(entity);
|
|
833
|
-
const sample = sampleRopeSphere(entity);
|
|
834
|
-
|
|
835
|
-
|
|
838
|
+
const sample = sampleRopeSphere(entity), j = tuple(vec(impulse, "rope reaction"));
|
|
839
|
+
const m = tuple(vec(moment, "rope reaction moment")), c = sample.centerOfMass;
|
|
840
|
+
const torque = [m[0] - (c[1] * j[2] - c[2] * j[1]), m[1] - (c[2] * j[0] - c[0] * j[2]), m[2] - (c[0] * j[1] - c[1] * j[0])];
|
|
841
|
+
vec(torque, "rope reaction torque");
|
|
842
|
+
if (j.some(value => value !== 0))
|
|
843
|
+
actions.push({ kind: "impulse", id: entity, value: j });
|
|
844
|
+
if (torque.some(value => value !== 0))
|
|
845
|
+
actions.push({ kind: "torqueImpulse", id: entity, value: torque });
|
|
846
|
+
}
|
|
847
|
+
if (history.length + actions.length > limit)
|
|
848
|
+
fail("TC_PHYSICS_HISTORY_LIMIT", "Rope reactions exceed physics history capacity; start a new scene.");
|
|
836
849
|
// Validation precedes mutation; a native error can still leave a partial batch.
|
|
837
850
|
for (const action of actions)
|
|
838
851
|
record(action);
|
package/lib/renderer/browser.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import * as THREE from "three/webgpu";
|
|
2
2
|
import { prepareGameplay } from "./gameplay.js";
|
|
3
|
+
import { createRopeVisualOwner } from "./rope-visuals.js";
|
|
3
4
|
import { GPUTracer } from "./pathtrace.js";
|
|
4
5
|
import { SurfaceTracer } from "./surface-tracer.js";
|
|
5
6
|
import { createRenderSettingsDriver, resolveRenderSettings, } from "../runtime/render-settings.js";
|
|
@@ -134,6 +135,11 @@ const recoveryEvents = [];
|
|
|
134
135
|
const gameObjects = new Map();
|
|
135
136
|
const gameRoot = new THREE.Group();
|
|
136
137
|
scene.add(gameRoot);
|
|
138
|
+
const ropeVisuals = createRopeVisualOwner();
|
|
139
|
+
scene.add(ropeVisuals.root);
|
|
140
|
+
let ropeVisualRevision = 0, renderedRopeVisualRevision = 0;
|
|
141
|
+
const clearRopeVisuals = () => { if (ropeVisuals.clear())
|
|
142
|
+
ropeVisualRevision++; };
|
|
137
143
|
let authoredCamera = { position: [0, 3.4, 7], target: [0, 0.9, 0] };
|
|
138
144
|
let authoredLights = [];
|
|
139
145
|
let compiledIR = null;
|
|
@@ -352,8 +358,10 @@ function createEntityView(entity, materials) {
|
|
|
352
358
|
return node;
|
|
353
359
|
}
|
|
354
360
|
function syncRuntime() {
|
|
355
|
-
if (!runtime)
|
|
356
|
-
|
|
361
|
+
if (!runtime) {
|
|
362
|
+
clearRopeVisuals();
|
|
363
|
+
return ropeVisualRevision !== renderedRopeVisualRevision;
|
|
364
|
+
}
|
|
357
365
|
transforms.clear();
|
|
358
366
|
componentState.clear();
|
|
359
367
|
const records = runtime.world.records();
|
|
@@ -428,6 +436,13 @@ function syncRuntime() {
|
|
|
428
436
|
if (light)
|
|
429
437
|
authoredLights.push({ definition: entity.light, object: light });
|
|
430
438
|
}
|
|
439
|
+
if (gameplay) {
|
|
440
|
+
if (ropeVisuals.sync(runtime.facade.ropes.visuals(), gameMaterials))
|
|
441
|
+
ropeVisualRevision++;
|
|
442
|
+
}
|
|
443
|
+
else
|
|
444
|
+
clearRopeVisuals();
|
|
445
|
+
return ropeVisualRevision !== renderedRopeVisualRevision;
|
|
431
446
|
}
|
|
432
447
|
let simulation;
|
|
433
448
|
const simulationGroup = new THREE.Group();
|
|
@@ -720,6 +735,7 @@ async function loadGame(sceneId, saved, loadSlot) {
|
|
|
720
735
|
contentScope = nextScope;
|
|
721
736
|
gameMaterials = nextMaterials;
|
|
722
737
|
committed = true;
|
|
738
|
+
clearRopeVisuals();
|
|
723
739
|
if (nextRoot.children.length)
|
|
724
740
|
gameRoot.add(...nextRoot.children);
|
|
725
741
|
gameObjects.clear();
|
|
@@ -950,7 +966,9 @@ async function drawFrame(accumulate = false, changed = true) {
|
|
|
950
966
|
throw new Error("TC_BROWSER_DISPOSED");
|
|
951
967
|
await computeControl?.flush();
|
|
952
968
|
const start = performance.now();
|
|
953
|
-
syncRuntime();
|
|
969
|
+
const ropeChanged = syncRuntime();
|
|
970
|
+
const ropeRevision = ropeVisualRevision;
|
|
971
|
+
changed = changed || ropeChanged;
|
|
954
972
|
animate();
|
|
955
973
|
renderBufferBridge?.update();
|
|
956
974
|
renderTextureBridge?.update();
|
|
@@ -1030,6 +1048,7 @@ async function drawFrame(accumulate = false, changed = true) {
|
|
|
1030
1048
|
triangles: triangleCount,
|
|
1031
1049
|
faulted: terminalFault,
|
|
1032
1050
|
});
|
|
1051
|
+
renderedRopeVisualRevision = ropeRevision;
|
|
1033
1052
|
return;
|
|
1034
1053
|
}
|
|
1035
1054
|
if (changed) {
|
|
@@ -1082,6 +1101,7 @@ async function drawFrame(accumulate = false, changed = true) {
|
|
|
1082
1101
|
triangles: triangleCount,
|
|
1083
1102
|
faulted: terminalFault,
|
|
1084
1103
|
});
|
|
1104
|
+
renderedRopeVisualRevision = ropeRevision;
|
|
1085
1105
|
}
|
|
1086
1106
|
async function applyInput(key) {
|
|
1087
1107
|
if (!probeEnabled) {
|
|
@@ -1327,6 +1347,7 @@ async function disposeBrowser() {
|
|
|
1327
1347
|
clean(() => simulation?.dispose());
|
|
1328
1348
|
clean(() => world?.dispose());
|
|
1329
1349
|
clean(() => resourceManager.dispose());
|
|
1350
|
+
clean(() => ropeVisuals.dispose());
|
|
1330
1351
|
clean(() => releaseObject(scene, true, gameMaterials.values()));
|
|
1331
1352
|
clean(() => pendingSurface?.abort());
|
|
1332
1353
|
clean(() => tracer?.dispose());
|
package/lib/renderer/geometry.js
CHANGED
|
@@ -18,22 +18,32 @@ export function extractTriangles(scene) {
|
|
|
18
18
|
new THREE.Vector3(),
|
|
19
19
|
new THREE.Vector3(),
|
|
20
20
|
];
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
21
|
+
const instanced = object instanceof THREE.InstancedMesh ? object : undefined;
|
|
22
|
+
const instance = new THREE.Matrix4(), transform = new THREE.Matrix4();
|
|
23
|
+
for (let slot = 0; slot < (instanced?.count ?? 1); slot++) {
|
|
24
|
+
if (instanced) {
|
|
25
|
+
instanced.getMatrixAt(slot, instance);
|
|
26
|
+
transform.multiplyMatrices(object.matrixWorld, instance);
|
|
27
|
+
}
|
|
28
|
+
else
|
|
29
|
+
transform.copy(object.matrixWorld);
|
|
30
|
+
for (let i = 0; i < count; i += 3) {
|
|
31
|
+
for (let k = 0; k < 3; k++)
|
|
32
|
+
object
|
|
33
|
+
.getVertexPosition(geometry.index?.getX(i + k) ?? i + k, points[k])
|
|
34
|
+
.applyMatrix4(transform);
|
|
35
|
+
const color = material.color ?? new THREE.Color(0.6, 0.6, 0.6);
|
|
36
|
+
triangles.push({
|
|
37
|
+
a: points[0].toArray(),
|
|
38
|
+
b: points[1].toArray(),
|
|
39
|
+
c: points[2].toArray(),
|
|
40
|
+
color: color.toArray(),
|
|
41
|
+
emission: material.emissiveIntensity && material.emissive?.getHex()
|
|
42
|
+
? material.emissiveIntensity
|
|
43
|
+
: 0,
|
|
44
|
+
roughness: material.roughness ?? 0.8,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
37
47
|
}
|
|
38
48
|
});
|
|
39
49
|
return triangles;
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import * as THREE from "three/webgpu";
|
|
2
|
+
/** Presentation owns its instance buffers and geometries; scene materials remain borrowed. */
|
|
3
|
+
export function createRopeVisualOwner() {
|
|
4
|
+
const root = new THREE.Group();
|
|
5
|
+
root.name = "pelican-rope-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.radius, state.radialSegments, state.castShadow, state.receiveShadow, state.positions.length]);
|
|
12
|
+
const create = (state, material) => {
|
|
13
|
+
let geometry;
|
|
14
|
+
let mesh;
|
|
15
|
+
try {
|
|
16
|
+
geometry = new THREE.CylinderGeometry(state.radius, state.radius, 1, state.radialSegments, 1, false);
|
|
17
|
+
const uv = geometry.getAttribute("uv");
|
|
18
|
+
if (uv)
|
|
19
|
+
geometry.setAttribute("uv1", uv.clone());
|
|
20
|
+
mesh = new THREE.InstancedMesh(geometry, material, state.positions.length - 1);
|
|
21
|
+
mesh.name = `rope:${state.id}`;
|
|
22
|
+
mesh.castShadow = state.castShadow;
|
|
23
|
+
mesh.receiveShadow = state.receiveShadow;
|
|
24
|
+
mesh.count = 0;
|
|
25
|
+
mesh.visible = false;
|
|
26
|
+
mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
|
|
27
|
+
return { mesh, geometry, signature: signature(state), positions: [] };
|
|
28
|
+
}
|
|
29
|
+
catch (error) {
|
|
30
|
+
mesh?.dispose();
|
|
31
|
+
geometry?.dispose();
|
|
32
|
+
throw error;
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
const matrices = (state) => {
|
|
36
|
+
const data = new Float32Array((state.positions.length - 1) * 16);
|
|
37
|
+
const axis = new THREE.Vector3(0, 1, 0), direction = new THREE.Vector3();
|
|
38
|
+
const position = new THREE.Vector3(), scale = new THREE.Vector3(), rotation = new THREE.Quaternion();
|
|
39
|
+
const matrix = new THREE.Matrix4();
|
|
40
|
+
let count = 0;
|
|
41
|
+
for (let index = 0; index < state.positions.length - 1; index++) {
|
|
42
|
+
const a = state.positions[index], b = state.positions[index + 1];
|
|
43
|
+
direction.set(b[0] - a[0], b[1] - a[1], b[2] - a[2]);
|
|
44
|
+
const length = direction.length();
|
|
45
|
+
// A collapsed segment has no surface; never submit a singular instance transform.
|
|
46
|
+
if (length === 0)
|
|
47
|
+
continue;
|
|
48
|
+
if (!Number.isFinite(length) || Math.fround(length) === 0)
|
|
49
|
+
throw new Error(`TC_ROPE_VISUAL_NUMERIC: Segment ${index} of '${state.id}' has an unrepresentable length.`);
|
|
50
|
+
position.set((a[0] + b[0]) / 2, (a[1] + b[1]) / 2, (a[2] + b[2]) / 2);
|
|
51
|
+
rotation.setFromUnitVectors(axis, direction.multiplyScalar(1 / length));
|
|
52
|
+
scale.set(1, length, 1);
|
|
53
|
+
matrix.compose(position, rotation, scale);
|
|
54
|
+
for (const value of matrix.elements)
|
|
55
|
+
if (!Number.isFinite(value) || !Number.isFinite(Math.fround(value)))
|
|
56
|
+
throw new Error(`TC_ROPE_VISUAL_NUMERIC: Segment ${index} of '${state.id}' has a non-finite transform.`);
|
|
57
|
+
data.set(matrix.elements, count++ * 16);
|
|
58
|
+
}
|
|
59
|
+
return { data, count };
|
|
60
|
+
};
|
|
61
|
+
const sync = (states, materials) => {
|
|
62
|
+
if (disposed)
|
|
63
|
+
throw new Error("TC_ROPE_VISUAL_DISPOSED: Create a new presentation owner.");
|
|
64
|
+
const staged = new Map();
|
|
65
|
+
const created = [];
|
|
66
|
+
try {
|
|
67
|
+
for (const state of states) {
|
|
68
|
+
if (staged.has(state.id))
|
|
69
|
+
throw new Error(`TC_ROPE_VISUAL_ID: Duplicate rope '${state.id}'.`);
|
|
70
|
+
const material = materials.get(state.material);
|
|
71
|
+
if (!material)
|
|
72
|
+
throw new Error(`TC_ROPE_VISUAL_MATERIAL: Material '${state.material}' is not declared.`);
|
|
73
|
+
if (!Array.isArray(state.positions) || state.positions.length < 2 || state.positions.length > 4096 ||
|
|
74
|
+
state.positions.some(point => !Array.isArray(point) || point.length !== 3 || !point.every(Number.isFinite)))
|
|
75
|
+
throw new Error(`TC_ROPE_VISUAL_POSITION: Rope '${state.id}' requires finite particle positions.`);
|
|
76
|
+
if (!Number.isFinite(state.radius) || state.radius <= 0 || Math.fround(state.radius) === 0 ||
|
|
77
|
+
!Number.isInteger(state.radialSegments) || state.radialSegments < 3 || state.radialSegments > 32)
|
|
78
|
+
throw new Error(`TC_ROPE_VISUAL_SHAPE: Rope '${state.id}' has an invalid radius or radial segment count.`);
|
|
79
|
+
const old = views.get(state.id), same = old && old.signature === signature(state) && old.mesh.material === material;
|
|
80
|
+
const view = same ? old : create(state, material);
|
|
81
|
+
if (!same)
|
|
82
|
+
created.push(view);
|
|
83
|
+
const positions = state.positions.flatMap(point => [...point]);
|
|
84
|
+
const changed = !same || positions.length !== view.positions.length || positions.some((value, index) => value !== view.positions[index]);
|
|
85
|
+
const update = changed ? matrices(state) : undefined;
|
|
86
|
+
staged.set(state.id, { view, created: !same, positions, ...(update ? { matrices: update.data, count: update.count } : {}) });
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
for (const view of created)
|
|
91
|
+
remove(view);
|
|
92
|
+
throw error;
|
|
93
|
+
}
|
|
94
|
+
let changed = false;
|
|
95
|
+
for (const [id, view] of views)
|
|
96
|
+
if (!staged.has(id) || staged.get(id).view !== view) {
|
|
97
|
+
remove(view);
|
|
98
|
+
changed = true;
|
|
99
|
+
}
|
|
100
|
+
views.clear();
|
|
101
|
+
for (const [id, item] of staged) {
|
|
102
|
+
if (item.created) {
|
|
103
|
+
root.add(item.view.mesh);
|
|
104
|
+
changed = true;
|
|
105
|
+
}
|
|
106
|
+
if (item.matrices) {
|
|
107
|
+
item.view.mesh.instanceMatrix.array.set(item.matrices);
|
|
108
|
+
item.view.mesh.count = item.count;
|
|
109
|
+
item.view.mesh.visible = item.count > 0;
|
|
110
|
+
item.view.mesh.instanceMatrix.needsUpdate = true;
|
|
111
|
+
if (item.count > 0)
|
|
112
|
+
item.view.mesh.computeBoundingSphere();
|
|
113
|
+
item.view.positions = item.positions;
|
|
114
|
+
changed = true;
|
|
115
|
+
}
|
|
116
|
+
views.set(id, item.view);
|
|
117
|
+
}
|
|
118
|
+
return changed;
|
|
119
|
+
};
|
|
120
|
+
return { root, sync, clear, dispose() { if (disposed)
|
|
121
|
+
return; clear(); root.removeFromParent(); disposed = true; } };
|
|
122
|
+
}
|
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.15";
|
|
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];
|
|
@@ -103,8 +103,8 @@ export interface SystemContext extends EngineFacade {
|
|
|
103
103
|
readonly navigation: NavigationService;
|
|
104
104
|
readonly ropes: RopeService;
|
|
105
105
|
}
|
|
106
|
-
/** CPU particles with sampled sphere/plane collisions. Explicit body bindings apply
|
|
107
|
-
*
|
|
106
|
+
/** 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 or segment/self collision. */
|
|
108
108
|
export type RopeCollider = {
|
|
109
109
|
kind: "sphere";
|
|
110
110
|
id: string;
|
|
@@ -121,6 +121,24 @@ export interface RopeBodyCollider {
|
|
|
121
121
|
readonly id: string;
|
|
122
122
|
readonly entity: string;
|
|
123
123
|
}
|
|
124
|
+
/** Optional renderer-owned cylindrical segments following simulated particle positions. */
|
|
125
|
+
export interface RopeVisualOptions {
|
|
126
|
+
material: string;
|
|
127
|
+
radius?: number;
|
|
128
|
+
radialSegments?: number;
|
|
129
|
+
castShadow?: boolean;
|
|
130
|
+
receiveShadow?: boolean;
|
|
131
|
+
}
|
|
132
|
+
/** Detached presentation state; native renderer objects are not exposed. */
|
|
133
|
+
export interface RopeVisualState {
|
|
134
|
+
id: string;
|
|
135
|
+
material: string;
|
|
136
|
+
radius: number;
|
|
137
|
+
radialSegments: number;
|
|
138
|
+
castShadow: boolean;
|
|
139
|
+
receiveShadow: boolean;
|
|
140
|
+
positions: Vec3[];
|
|
141
|
+
}
|
|
124
142
|
export interface RopeDefinition {
|
|
125
143
|
id: string;
|
|
126
144
|
points: readonly Vec3[];
|
|
@@ -133,6 +151,8 @@ export interface RopeDefinition {
|
|
|
133
151
|
autoStep?: boolean;
|
|
134
152
|
/** Automatically sample and react to existing dynamic, non-trigger sphere bodies. Requires autoStep. */
|
|
135
153
|
bodyColliders?: readonly RopeBodyCollider[];
|
|
154
|
+
/** Optional segmented presentation using an already-declared material id. */
|
|
155
|
+
visual?: RopeVisualOptions;
|
|
136
156
|
substeps?: number;
|
|
137
157
|
iterations?: number;
|
|
138
158
|
damping?: number;
|
|
@@ -148,7 +168,7 @@ export interface RopeContact {
|
|
|
148
168
|
particle: number;
|
|
149
169
|
colliderId: string;
|
|
150
170
|
penetration: number;
|
|
151
|
-
/** Opposite of particle
|
|
171
|
+
/** Opposite of particle contact impulses, N·s; absent in older snapshots. */
|
|
152
172
|
reactionImpulse?: Vec3;
|
|
153
173
|
/** Angular impulse about the world origin; absent in older snapshots. */
|
|
154
174
|
reactionAngularImpulse?: Vec3;
|
|
@@ -177,6 +197,8 @@ export interface RopeService {
|
|
|
177
197
|
/** Explicit ticks for manual ropes only. Auto-stepped ropes reject this call. A failed batch leaves the rope unchanged. */
|
|
178
198
|
step(id: string, ticks?: number): RopeState;
|
|
179
199
|
inspect(id: string): RopeState;
|
|
200
|
+
/** Detached visual descriptions, sorted by rope id; empty when no visuals are requested. */
|
|
201
|
+
visuals(): readonly RopeVisualState[];
|
|
180
202
|
setColliders(id: string, colliders: readonly RopeCollider[]): void;
|
|
181
203
|
setAnchor(id: string, index: number, position: Vec3): void;
|
|
182
204
|
releaseAnchor(id: string, index: number): void;
|
package/lib/sdk/index.js
CHANGED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
const error = (message) => { throw new Error(`TC_ROPE_BODY_NUMERIC: ${message}`); };
|
|
2
|
+
const vector = (value, label) => {
|
|
3
|
+
if (!Array.isArray(value) || value.length !== 3 || !value.every(Number.isFinite))
|
|
4
|
+
return error(`${label} must be a finite 3D vector.`);
|
|
5
|
+
return [value[0], value[1], value[2]];
|
|
6
|
+
};
|
|
7
|
+
const add = (a, b) => [a[0] + b[0], a[1] + b[1], a[2] + b[2]];
|
|
8
|
+
const sub = (a, b) => [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
|
|
9
|
+
const scale = (a, value) => [a[0] * value, a[1] * value, a[2] * value];
|
|
10
|
+
const cross = (a, b) => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]];
|
|
11
|
+
const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
|
|
12
|
+
const multiply = (m, v) => [m[0] * v[0] + m[1] * v[1] + m[2] * v[2], m[1] * v[0] + m[3] * v[1] + m[4] * v[2], m[2] * v[0] + m[4] * v[1] + m[5] * v[2]];
|
|
13
|
+
/** Mutable per-host-tick approximation. Native rigid bodies remain untouched until commit. */
|
|
14
|
+
export class VirtualRopeSphere {
|
|
15
|
+
center;
|
|
16
|
+
radius;
|
|
17
|
+
centerOfMass;
|
|
18
|
+
linearVelocity;
|
|
19
|
+
angularVelocity;
|
|
20
|
+
inverseMass;
|
|
21
|
+
inverseInertia;
|
|
22
|
+
impulse = [0, 0, 0];
|
|
23
|
+
moment = [0, 0, 0];
|
|
24
|
+
constructor(sample) {
|
|
25
|
+
this.center = vector(sample.center, "sphere center");
|
|
26
|
+
this.centerOfMass = vector(sample.centerOfMass, "center of mass");
|
|
27
|
+
this.linearVelocity = vector(sample.linearVelocity, "linear velocity");
|
|
28
|
+
this.angularVelocity = vector(sample.angularVelocity, "angular velocity");
|
|
29
|
+
this.inverseMass = vector(sample.inverseMass, "inverse mass");
|
|
30
|
+
if (this.inverseMass.some(value => value < 0))
|
|
31
|
+
error("inverse mass cannot be negative.");
|
|
32
|
+
if (!Array.isArray(sample.inverseInertia) || sample.inverseInertia.length !== 6 || !sample.inverseInertia.every(Number.isFinite))
|
|
33
|
+
throw new Error("TC_ROPE_BODY_NUMERIC: world inverse inertia must have six finite elements.");
|
|
34
|
+
this.inverseInertia = [...sample.inverseInertia];
|
|
35
|
+
if (this.inverseInertia[0] < 0 || this.inverseInertia[3] < 0 || this.inverseInertia[5] < 0)
|
|
36
|
+
error("world inverse inertia diagonal cannot be negative.");
|
|
37
|
+
if (!Number.isFinite(sample.radius) || sample.radius <= 0 || sample.radius > 1e6)
|
|
38
|
+
error("sphere radius is outside (0, 1e6].");
|
|
39
|
+
this.radius = sample.radius;
|
|
40
|
+
}
|
|
41
|
+
point(normal) { return add(this.center, scale(normal, this.radius)); }
|
|
42
|
+
pointVelocity(point) { return add(this.linearVelocity, cross(this.angularVelocity, sub(point, this.centerOfMass))); }
|
|
43
|
+
inverseEffectiveMass(point, normal) {
|
|
44
|
+
const rCrossN = cross(sub(point, this.centerOfMass), normal);
|
|
45
|
+
const linear = normal[0] * normal[0] * this.inverseMass[0] + normal[1] * normal[1] * this.inverseMass[1] + normal[2] * normal[2] * this.inverseMass[2];
|
|
46
|
+
const angular = dot(rCrossN, multiply(this.inverseInertia, rCrossN));
|
|
47
|
+
const result = linear + angular;
|
|
48
|
+
if (!Number.isFinite(result) || result < -1e-8)
|
|
49
|
+
return error("invalid effective inverse mass.");
|
|
50
|
+
return Math.max(0, result);
|
|
51
|
+
}
|
|
52
|
+
apply(impulse, point, dt) {
|
|
53
|
+
const r = sub(point, this.centerOfMass);
|
|
54
|
+
const deltaLinear = [this.inverseMass[0] * impulse[0], this.inverseMass[1] * impulse[1], this.inverseMass[2] * impulse[2]];
|
|
55
|
+
const deltaAngular = multiply(this.inverseInertia, cross(r, impulse));
|
|
56
|
+
const relativeCenter = sub(this.center, this.centerOfMass);
|
|
57
|
+
const translation = scale(deltaLinear, dt);
|
|
58
|
+
// First-order virtual transform update; the actual rigid body moves on its next physics step.
|
|
59
|
+
this.center = vector(add(add(this.center, translation), cross(scale(deltaAngular, dt), relativeCenter)), "virtual sphere center");
|
|
60
|
+
this.centerOfMass = vector(add(this.centerOfMass, translation), "virtual center of mass");
|
|
61
|
+
this.linearVelocity = vector(add(this.linearVelocity, deltaLinear), "virtual linear velocity");
|
|
62
|
+
this.angularVelocity = vector(add(this.angularVelocity, deltaAngular), "virtual angular velocity");
|
|
63
|
+
this.impulse = vector(add(this.impulse, impulse), "accumulated impulse");
|
|
64
|
+
this.moment = vector(add(this.moment, cross(point, impulse)), "accumulated moment");
|
|
65
|
+
}
|
|
66
|
+
resolve(normal, depth, position, previous, mass, dt) {
|
|
67
|
+
const invParticle = 1 / mass;
|
|
68
|
+
if (!Number.isFinite(invParticle) || invParticle <= 0 || !Number.isFinite(dt) || dt <= 0)
|
|
69
|
+
return error("particle mass and substep must be finite and positive.");
|
|
70
|
+
// n·M⁻¹n + (r×n)·I⁻¹(r×n) accounts for translation, rotation and locked axes.
|
|
71
|
+
const firstPoint = this.point(normal), denominator = invParticle + this.inverseEffectiveMass(firstPoint, normal);
|
|
72
|
+
if (!Number.isFinite(denominator) || denominator <= 0)
|
|
73
|
+
return error("contact inverse mass must be positive and finite.");
|
|
74
|
+
// Position projection is shared between particle and body by effective inverse mass.
|
|
75
|
+
const positionImpulse = depth / (dt * denominator);
|
|
76
|
+
const firstImpulse = scale(normal, -positionImpulse);
|
|
77
|
+
let correction = scale(normal, positionImpulse * invParticle * dt);
|
|
78
|
+
let reactionImpulse = firstImpulse, reactionAngularImpulse = cross(firstPoint, firstImpulse);
|
|
79
|
+
this.apply(firstImpulse, firstPoint, dt);
|
|
80
|
+
// Remove remaining closing normal velocity using the virtual body's updated velocity.
|
|
81
|
+
const secondPoint = this.point(normal);
|
|
82
|
+
const particleVelocity = scale(sub(add(position, correction), previous), 1 / dt);
|
|
83
|
+
const relativeNormal = dot(sub(particleVelocity, this.pointVelocity(secondPoint)), normal);
|
|
84
|
+
const velocityDenominator = invParticle + this.inverseEffectiveMass(secondPoint, normal);
|
|
85
|
+
if (!Number.isFinite(relativeNormal) || !Number.isFinite(velocityDenominator) || velocityDenominator <= 0)
|
|
86
|
+
return error("contact velocity or inverse mass is not finite.");
|
|
87
|
+
if (relativeNormal < 0) {
|
|
88
|
+
const velocityImpulse = -relativeNormal / velocityDenominator;
|
|
89
|
+
const secondImpulse = scale(normal, -velocityImpulse);
|
|
90
|
+
correction = add(correction, scale(normal, velocityImpulse * invParticle * dt));
|
|
91
|
+
reactionImpulse = add(reactionImpulse, secondImpulse);
|
|
92
|
+
reactionAngularImpulse = add(reactionAngularImpulse, cross(secondPoint, secondImpulse));
|
|
93
|
+
this.apply(secondImpulse, secondPoint, dt);
|
|
94
|
+
}
|
|
95
|
+
return { correction: vector(correction, "particle correction"), reactionImpulse: vector(reactionImpulse, "reaction impulse"), reactionAngularImpulse: vector(reactionAngularImpulse, "reaction moment") };
|
|
96
|
+
}
|
|
97
|
+
reaction() { return { impulse: vector(this.impulse, "reaction impulse"), moment: vector(this.moment, "reaction moment") }; }
|
|
98
|
+
}
|
|
99
|
+
export function createRopeBodyCoupling(bodies) {
|
|
100
|
+
return {
|
|
101
|
+
sphere(id) { const body = bodies.get(id); return body ? { center: body.center, radius: body.radius } : undefined; },
|
|
102
|
+
resolve(id, normal, depth, position, previous, mass, dt) {
|
|
103
|
+
const body = bodies.get(id) ?? error(`Missing virtual sphere '${id}'.`);
|
|
104
|
+
return body.resolve(normal, depth, position, previous, mass, dt);
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
}
|