-
Notifications
You must be signed in to change notification settings - Fork 7
Expand file tree
/
Copy pathrigid-stack-service.ts
More file actions
260 lines (253 loc) · 16 KB
/
Copy pathrigid-stack-service.ts
File metadata and controls
260 lines (253 loc) · 16 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
// © 2026 Adobe. MIT License. See /LICENSE for details.
import { Database, type Entity } from "@adobe/data/ecs";
import { Quat } from "@adobe/data/math";
import { pbrFactorRender, requireMaterial, rapierSolver, joltSolver, shapeGeometry, physicsRenderBridge, modelCollider, jointData, ColliderShape, Orbit, standardMaterialNames, Model } from "@adobe/data-gpu";
// Studio HDR for IBL © Poly Haven, CC0.
const ENV_URL = "https://dl.polyhaven.org/file/ph-assets/HDRIs/hdr/1k/studio_small_09_1k.hdr";
// DamagedHelmet glTF © Khronos Group, CC-BY 4.0 — dropped as a dynamic body whose
// convex-hull collider is auto-generated from its mesh (modelCollider).
const HELMET_URL = "https://raw.githubusercontent.com/KhronosGroup/glTF-Sample-Assets/main/Models/DamagedHelmet/glTF-Binary/DamagedHelmet.glb";
// Scene config. The bin is split into two zones so the moving bar doesn't
// demolish the stack: the **right** half holds the stable stack; the **left**
// half is the dynamic playground (sweeping bar + ramp). Drops rain across the
// whole bin, so some land on the stack while the bar — confined to the left —
// never reaches it.
const BIN = 12; // half-extent of the floor / containing walls
const STACK_W = 4, STACK_D = 4, STACK_H = 4; // dynamic block stack (unit cubes)
const STACK_CX = 6.5; // stack sits in the right zone, clear of the bar
const SPAWN_INTERVAL = 0.18; // seconds between dynamic drops
const SPAWN_DELAY = 2.5; // let the bare stack settle first, to verify it holds
const DYNAMIC_CAP = 120; // stop spawning at this many dropped bodies
const SPAWN_CX = 1.0; // drops rain across the whole bin — some top the stack
const SPAWN_SPREAD = 8.0; // (right), the rest feed the bar + ramp (left)
const SPAWN_HEIGHT = 14;
// Kinematic bar: sweeps only the left zone (centre −4.5 ± 4.5 ⇒ x ∈ [−9, 0]), so
// it churns the dropped bodies and ramp without ever reaching the stack at +6.5.
const SWEEP_CX = -4.5, SWEEP_AMP = 4.5, SWEEP_SPEED = 0.7, SWEEP_Y = 1.0;
const IDENTITY: [number, number, number, number] = [...Quat.identity];
/**
* One deterministic drop. The sequence is precomputed from a fixed seed so that
* two services running different solvers (Rapier vs Jolt) get the *identical*
* scene — a fair side-by-side comparison.
*/
interface Drop { shape: ColliderShape; he: [number, number, number]; pos: [number, number, number]; quat: [number, number, number, number]; points?: Float32Array; }
function seededRng(seed: number): () => number {
let a = seed >>> 0;
return () => { a |= 0; a = (a + 0x6d2b79f5) | 0; let t = Math.imul(a ^ (a >>> 15), 1 | a); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; };
}
function randomQuat(r: () => number): [number, number, number, number] {
const u1 = r(), u2 = r(), u3 = r();
const a = Math.sqrt(1 - u1), b = Math.sqrt(u1);
return [a * Math.sin(2 * Math.PI * u2), a * Math.cos(2 * Math.PI * u2), b * Math.sin(2 * Math.PI * u3), b * Math.cos(2 * Math.PI * u3)];
}
/** A small cloud of points scattered near a sphere's surface — the solver hulls
* it into a random convex polyhedron (gem-like). */
function randomHullPoints(r: () => number, count: number, radius: number): Float32Array {
const out = new Float32Array(count * 3);
for (let i = 0; i < count; i++) {
const u = r() * 2 - 1, theta = r() * 2 * Math.PI, s = Math.sqrt(1 - u * u), rad = radius * (0.7 + 0.3 * r());
out[i * 3] = rad * s * Math.cos(theta); out[i * 3 + 1] = rad * u; out[i * 3 + 2] = rad * s * Math.sin(theta);
}
return out;
}
const DROPS: Drop[] = (() => {
const r = seededRng(0x1234abcd), out: Drop[] = [];
for (let i = 0; i < DYNAMIC_CAP; i++) {
const k = r();
const shape: ColliderShape = k < 0.28 ? "box" : k < 0.5 ? "capsule" : k < 0.72 ? "hull" : "sphere";
// box: 3 half-extents; capsule: [radius, cyl half-height]; hull: halfExtents unused; sphere: [radius]
const he: [number, number, number] = shape === "box" ? [0.3 + r() * 0.3, 0.3 + r() * 0.3, 0.3 + r() * 0.3]
: shape === "capsule" ? [0.28 + r() * 0.15, 0.35 + r() * 0.35, 0]
: [0.3 + r() * 0.4, 0, 0];
out.push({
shape, he,
pos: [SPAWN_CX + (r() * 2 - 1) * SPAWN_SPREAD, SPAWN_HEIGHT, (r() * 2 - 1) * SPAWN_SPREAD],
quat: shape === "sphere" ? [0, 0, 0, 1] : randomQuat(r),
points: shape === "hull" ? randomHullPoints(r, 9, 0.45 + r() * 0.2) : undefined,
});
}
return out;
})();
/**
* rigid-stack scene — a 4×4×4 dynamic wood stack rests on a stone floor inside a
* walled bin; mixed-material bodies drop in and knock it around, rendered
* through the unified PBR + IBL path. Floor and walls are immovable
* `StaticCollider` boxes; every body becomes renderable via `physicsRenderBridge`.
*
* This **base** plugin is solver-agnostic — scene + spawning only. A solver is
* combined in below (`rapierSolver` or `joltSolver`); the same scene runs on
* either through the shared `physicsData` seam, so the two are compared side by
* side. The drop sequence is deterministic (seeded), so both see it identically.
*/
const rigidStackScene = Database.Plugin.create({
extends: Database.Plugin.combine(pbrFactorRender, shapeGeometry, physicsRenderBridge, modelCollider, jointData, Orbit.plugin),
resources: {
_spawnAccum: { default: 0 as number, transient: true },
_spawnElapsed: { default: 0 as number, transient: true },
_spawnedDynamic: { default: 0 as number, transient: true },
_sweeper: { default: 0 as Entity, transient: true }, // the kinematic bar
},
transactions: {
initializeScene(t) {
t.resources.orbit = {
...t.resources.orbit,
center: [0, 1, 0], radius: 30, height: 22, autoSpinSpeed: 0.12,
};
t.resources.light = {
...t.resources.light,
environmentUrl: ENV_URL,
direction: [-2, -5, -3], color: [1.0, 0.98, 0.92], ambientStrength: 0.4,
};
// Stone bin: a floor slab (top face at y = 0) and four walls, all
// immovable StaticCollider boxes. The render bridge gives them
// geometry once the shape meshes load — no separate render-only prop.
const stone = requireMaterial(t, "stone");
const wall = (position: [number, number, number], halfExtents: [number, number, number]) =>
t.archetypes.StaticCollider.insert({ colliderShape: "box", halfExtents, material: stone, position, rotation: IDENTITY });
wall([0, -0.5, 0], [BIN + 1, 0.5, BIN + 1]); // floor slab (top at y = 0)
const WH = 3; // wall half-height (walls 0 → 2·WH)
wall([ BIN, WH, 0], [0.5, WH, BIN + 1]); // +x
wall([-BIN, WH, 0], [0.5, WH, BIN + 1]); // −x
wall([0, WH, BIN], [BIN + 1, WH, 0.5]); // +z
wall([0, WH, -BIN], [BIN + 1, WH, 0.5]); // −z
// A static triangle-mesh ramp (level geometry) leaning into one corner —
// dropped bodies land on it and slide down. World-space verts; up-facing
// winding so it renders (front faces) and collides (trimesh is two-sided).
t.archetypes.MeshCollider.insert({
colliderShape: "mesh", halfExtents: [0, 0, 0], material: requireMaterial(t, "steel"),
position: [0, 0, 0], rotation: IDENTITY,
colliderMesh: {
// sloped quad in the left zone: high at the −x wall, low toward centre
positions: new Float32Array([-BIN + 1.5, 4.5, 6, -BIN + 1.5, 4.5, -6, -1, 0.6, -6, -1, 0.6, 6]),
indices: new Uint32Array([0, 2, 1, 0, 3, 2]),
},
});
// Dynamic block stack: a grid of unit cubes resting on the floor.
// A small gap on every axis avoids initial face-coincidence
// (degenerate SAT normals); they settle into contact.
const wood = requireMaterial(t, "wood");
const GAP = 1.04;
const x0 = -(STACK_W - 1) / 2 * GAP, z0 = -(STACK_D - 1) / 2 * GAP;
for (let y = 0; y < STACK_H; y++) {
for (let x = 0; x < STACK_W; x++) {
for (let z = 0; z < STACK_D; z++) {
t.archetypes.RigidBody.insert({
bodyType: "dynamic", colliderShape: "box", halfExtents: [0.5, 0.5, 0.5], material: wood,
position: [STACK_CX + x0 + x * GAP, 0.55 + y * 1.04, z0 + z * GAP],
rotation: IDENTITY, linearVelocity: [0, 0, 0], angularVelocity: [0, 0, 0],
});
}
}
}
// A kinematic steel bar that sweeps across the bin, plowing the stack —
// its pose is authored each frame by the `sweep` system; the solver moves
// it as a kinematic body that pushes the dynamics but is never pushed back.
t.resources._sweeper = t.archetypes.RigidBody.insert({
bodyType: "kinematic", colliderShape: "box", halfExtents: [0.4, 1.0, BIN - 1], material: requireMaterial(t, "steel"),
position: [SWEEP_CX - SWEEP_AMP, SWEEP_Y, 0], rotation: IDENTITY, linearVelocity: [0, 0, 0], angularVelocity: [0, 0, 0],
});
// A downloaded glTF model dropped as dynamic bodies: it renders in full
// detail but collides as a convex hull auto-generated from its mesh
// (colliderShape "hull" + no collision data ⇒ modelCollider fills it). One
// shared Geometry, three staggered instances. (To hand-author the collider
// instead, pass `convexPoints`/`colliderMesh` here and generation is skipped.)
const helmet = Model.plugin.transactions.insertGltfMesh(t, { url: HELMET_URL });
for (let i = 0; i < 3; i++) {
t.archetypes.ModelBody.insert({
mesh: helmet, scale: [1.5, 1.5, 1.5], visible: true, parent: 0,
bodyType: "dynamic", colliderShape: "hull", halfExtents: [0, 0, 0], material: requireMaterial(t, "steel"),
position: [SPAWN_CX - 2 + i * 2, 15 + i * 5, -1 + i],
rotation: randomQuat(seededRng(0x5eed + i)), linearVelocity: [0, 0, 0], angularVelocity: [0, 0, 0],
});
}
// A hanging chain: a static anchor + capsule links joined by `point` (ball)
// joints, so it swings freely and the sweeping bar knocks it around. Each
// link's ends are at ±(halfHeight + radius) in its local Y; consecutive
// anchors coincide in world space, so the chain forms taut.
const LINK_HY = 0.4, LINK_R = 0.22, LINK_END = LINK_HY + LINK_R, LINK_LEN = 2 * LINK_END;
const CX = SWEEP_CX, CZ = BIN - 3, ANCHOR_Y = 9, ANCHOR_HH = 0.25;
const steel = requireMaterial(t, "steel");
const anchor = t.archetypes.StaticCollider.insert({
colliderShape: "box", halfExtents: [0.3, ANCHOR_HH, 0.3], material: steel,
position: [CX, ANCHOR_Y, CZ], rotation: IDENTITY,
});
let prev = anchor, prevBottom: [number, number, number] = [0, -ANCHOR_HH, 0];
for (let i = 0; i < 6; i++) {
const link = t.archetypes.RigidBody.insert({
bodyType: "dynamic", colliderShape: "capsule", halfExtents: [LINK_R, LINK_HY, 0], material: steel,
position: [CX, ANCHOR_Y - ANCHOR_HH - LINK_END - i * LINK_LEN, CZ],
rotation: IDENTITY, linearVelocity: [0, 0, 0], angularVelocity: [0, 0, 0],
});
t.archetypes.Joint.insert({
jointType: "point", jointBodyA: prev, jointBodyB: link,
jointAnchorA: prevBottom, jointAnchorB: [0, LINK_END, 0],
jointAxis: [0, 0, 1], jointMinLimit: 0, jointMaxLimit: 0, jointSwingLimit: 0,
});
prev = link; prevBottom = [0, -LINK_END, 0];
}
// A cone (swing-twist) joint, off in a clear corner: a horizontal capsule
// arm pinned at its inner end to a floating post, free to swing only within
// a ~36° cone of the +x reference. Gravity droops it; on joltSolver the cone
// stops it at the limit (it sticks out, leaning), while rapierSolver (no cone
// limit in its binding) lets it hang straight down — the documented difference.
const ARM_END = 1.1, ARM_ROT: [number, number, number, number] = [0, 0, -Math.SQRT1_2, Math.SQRT1_2]; // local +Y → world +x
const post = t.archetypes.StaticCollider.insert({
colliderShape: "box", halfExtents: [0.3, 0.3, 0.3], material: steel,
position: [-3, 5, -BIN + 1.5], rotation: IDENTITY,
});
const arm = t.archetypes.RigidBody.insert({
bodyType: "dynamic", colliderShape: "capsule", halfExtents: [0.2, 0.9, 0], material: steel,
position: [-3 + ARM_END, 5, -BIN + 1.5], rotation: ARM_ROT, linearVelocity: [0, 0, 0], angularVelocity: [0, 0, 0],
});
t.archetypes.Joint.insert({
jointType: "cone", jointBodyA: post, jointBodyB: arm,
jointAnchorA: [0, 0, 0], jointAnchorB: [0, -ARM_END, 0],
jointAxis: [1, 0, 0], jointMinLimit: 0, jointMaxLimit: 0, jointSwingLimit: Math.PI / 5,
});
},
spawnBody(t, args: { index: number }) {
const d = DROPS[args.index];
const material = requireMaterial(t, standardMaterialNames[args.index % standardMaterialNames.length]);
const common = {
bodyType: "dynamic" as const, colliderShape: d.shape, halfExtents: d.he, material,
position: d.pos, rotation: d.quat, linearVelocity: [0, 0, 0] as [number, number, number], angularVelocity: [0, 0, 0] as [number, number, number],
};
// hull bodies carry their authored point cloud (the ConvexBody archetype)
if (d.shape === "hull" && d.points) t.archetypes.ConvexBody.insert({ ...common, convexPoints: d.points });
else t.archetypes.RigidBody.insert(common);
},
},
systems: {
// Drop a dynamic body every SPAWN_INTERVAL until the cap.
spawner: {
schedule: { during: ["update"] },
create: db => () => {
if (db.store.resources._spawnedDynamic >= DYNAMIC_CAP) return;
const dt = db.store.resources.frameTime.dt;
const elapsed = db.store.resources._spawnElapsed + dt;
db.store.resources._spawnElapsed = elapsed;
if (elapsed < SPAWN_DELAY) return;
let accum = db.store.resources._spawnAccum + dt;
while (accum >= SPAWN_INTERVAL && db.store.resources._spawnedDynamic < DYNAMIC_CAP) {
accum -= SPAWN_INTERVAL;
db.transactions.spawnBody({ index: db.store.resources._spawnedDynamic });
db.store.resources._spawnedDynamic = db.store.resources._spawnedDynamic + 1;
}
db.store.resources._spawnAccum = accum;
},
},
// Author the kinematic bar's pose: a horizontal sweep along x. The solver
// reads this pose and drives the kinematic body to it each step.
sweep: {
schedule: { during: ["update"] },
create: db => () => {
const id = db.store.resources._sweeper;
if (!id) return;
const x = SWEEP_CX + Math.sin(db.store.resources.frameTime.elapsed * SWEEP_SPEED - Math.PI / 2) * SWEEP_AMP;
db.store.update(id, { position: [x, SWEEP_Y, 0] });
},
},
},
});
export const rigidStackRapierPlugin = Database.Plugin.combine(rigidStackScene, rapierSolver);
export const rigidStackJoltPlugin = Database.Plugin.combine(rigidStackScene, joltSolver);