@adobe/data-gpu 0.9.87 → 0.9.88

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.
Files changed (48) hide show
  1. package/dist/graphics/rendering/bone-collider-plugin.d.ts +2724 -331
  2. package/dist/graphics/rendering/interpolation-plugin.d.ts +2532 -138
  3. package/dist/graphics/rendering/jolt-ragdoll-plugin.d.ts +2398 -5
  4. package/dist/graphics/rendering/model-collider-plugin.d.ts +2675 -282
  5. package/dist/graphics/rendering/pbr-render/physics-bridge-plugin.d.ts +2396 -3
  6. package/dist/physics/physics-data-plugin.d.ts +2398 -2
  7. package/dist/physics/physics-data-plugin.js +26 -0
  8. package/dist/physics/physics-data-plugin.js.map +1 -1
  9. package/dist/physics/physics-hit.d.ts +18 -0
  10. package/dist/physics/physics-hit.js +3 -0
  11. package/dist/physics/physics-hit.js.map +1 -0
  12. package/dist/physics/physics-query.d.ts +43 -0
  13. package/dist/physics/physics-query.js +3 -0
  14. package/dist/physics/physics-query.js.map +1 -0
  15. package/dist/physics/public.d.ts +2 -0
  16. package/dist/physics/public.js +2 -0
  17. package/dist/physics/public.js.map +1 -1
  18. package/dist/physics/solvers/jolt-solver-plugin.d.ts +2394 -4
  19. package/dist/physics/solvers/jolt-solver-plugin.js +149 -0
  20. package/dist/physics/solvers/jolt-solver-plugin.js.map +1 -1
  21. package/dist/physics/solvers/mutable-hit.d.ts +14 -0
  22. package/dist/physics/solvers/mutable-hit.js +3 -0
  23. package/dist/physics/solvers/mutable-hit.js.map +1 -0
  24. package/dist/physics/solvers/orient-normal-against-ray.d.ts +11 -0
  25. package/dist/physics/solvers/orient-normal-against-ray.js +25 -0
  26. package/dist/physics/solvers/orient-normal-against-ray.js.map +1 -0
  27. package/dist/physics/solvers/orient-normal-against-ray.test.d.ts +1 -0
  28. package/dist/physics/solvers/orient-normal-against-ray.test.js +27 -0
  29. package/dist/physics/solvers/orient-normal-against-ray.test.js.map +1 -0
  30. package/dist/physics/solvers/pick-ray-conformance.test.d.ts +1 -0
  31. package/dist/physics/solvers/pick-ray-conformance.test.js +159 -0
  32. package/dist/physics/solvers/pick-ray-conformance.test.js.map +1 -0
  33. package/dist/physics/solvers/rapier-solver-plugin.d.ts +2394 -4
  34. package/dist/physics/solvers/rapier-solver-plugin.js +110 -1
  35. package/dist/physics/solvers/rapier-solver-plugin.js.map +1 -1
  36. package/package.json +2 -2
  37. package/src/physics/README.md +15 -2
  38. package/src/physics/physics-data-plugin.ts +29 -1
  39. package/src/physics/physics-hit.ts +21 -0
  40. package/src/physics/physics-query.ts +42 -0
  41. package/src/physics/public.ts +2 -0
  42. package/src/physics/solvers/jolt-solver-plugin.ts +119 -0
  43. package/src/physics/solvers/mutable-hit.ts +18 -0
  44. package/src/physics/solvers/orient-normal-against-ray.test.ts +31 -0
  45. package/src/physics/solvers/orient-normal-against-ray.ts +18 -0
  46. package/src/physics/solvers/pick-ray-conformance.test.ts +200 -0
  47. package/src/physics/solvers/rapier-solver-plugin.ts +92 -1
  48. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,200 @@
1
+ // © 2026 Adobe. MIT License. See /LICENSE for details.
2
+
3
+ import { describe, it, expect, beforeAll } from "vitest";
4
+ import { Database } from "@adobe/data/ecs";
5
+ import type { Line3 } from "@adobe/data/math";
6
+ import { rapierSolver } from "./rapier-solver-plugin.js";
7
+ import { joltSolver } from "./jolt-solver-plugin.js";
8
+ import type { PhysicsHit } from "../physics-hit.js";
9
+
10
+ /**
11
+ * Abstract picking conformance suite: the SAME expected outcomes are asserted
12
+ * against every solver behind the `physicsData` seam. Building a fixed static
13
+ * scene and running identical `pickRay` calls proves both engines fulfil the
14
+ * `PhysicsQuery` contract interchangeably (entity, parametric α, hit point,
15
+ * and a ray-opposing normal), including the with/without-radius variants.
16
+ *
17
+ * npx vitest --run src/physics/solvers/pick-ray-conformance.test.ts
18
+ */
19
+
20
+ // The solver plugin extends `physicsData`, so the StaticCollider archetype, the
21
+ // `frameTime` resource, and the `pickRay` action all exist on the created db.
22
+ // The scene is driven dynamically, so the db is used through a loose shape
23
+ // (runtime invariant: these members exist on any physicsData solver).
24
+ interface LooseDb {
25
+ store: {
26
+ archetypes: Record<string, { insert(v: unknown): number }>;
27
+ resources: Record<string, unknown>;
28
+ };
29
+ system: { functions: Record<string, () => unknown>; order?: string[][] };
30
+ actions: {
31
+ pickRay(args: { ray: Line3; radius?: number }): PhysicsHit | null;
32
+ pickRayEach(args: { ray: Line3; visit: (hit: PhysicsHit) => boolean | void; radius?: number }): void;
33
+ };
34
+ }
35
+
36
+ const yieldToEventLoop = () => new Promise<void>(r => setTimeout(r, 0));
37
+
38
+ interface Scene { db: LooseDb; floor: number; ball: number; box: number }
39
+
40
+ // Build a fixed scene of static colliders and drive the sim until the solver's
41
+ // WASM world is live, the bodies are mirrored, and the query pipeline is
42
+ // populated (both engines need at least one step for that). Picks are read-only,
43
+ // so one scene is shared across all cases for a solver.
44
+ async function buildScene(solver: Database.Plugin): Promise<Scene> {
45
+ const db = Database.create(solver) as unknown as LooseDb;
46
+
47
+ // Floor: wide thick box, top face at y = 0.
48
+ const floor = db.store.archetypes.StaticCollider.insert({
49
+ colliderShape: "box", halfExtents: [10, 1, 10], material: 0,
50
+ position: [0, -1, 0], rotation: [0, 0, 0, 1],
51
+ });
52
+ // Sphere radius 1 centred at (0, 3, 0) → top at y = 4.
53
+ const ball = db.store.archetypes.StaticCollider.insert({
54
+ colliderShape: "sphere", halfExtents: [1, 0, 0], material: 0,
55
+ position: [0, 3, 0], rotation: [0, 0, 0, 1],
56
+ });
57
+ // Unit box centred at (5, 3, 0) → top at y = 4, off to the side.
58
+ const box = db.store.archetypes.StaticCollider.insert({
59
+ colliderShape: "box", halfExtents: [1, 1, 1], material: 0,
60
+ position: [5, 3, 0], rotation: [0, 0, 0, 1],
61
+ });
62
+
63
+ const order = db.system.order;
64
+ const names = order ? order.flat() : Object.keys(db.system.functions);
65
+ const runNames = names.filter(n => n !== "_frameTime" && n !== "schedulerSystem");
66
+ const dt = 1 / 60;
67
+ const tick = (f: number): void => {
68
+ db.store.resources.frameTime = { now: f * dt * 1000, dt, elapsed: f * dt };
69
+ for (const n of runNames) { const fn = db.system.functions[n]; if (fn) fn(); }
70
+ };
71
+
72
+ // Drive until the solver publishes its query, then a few more steps so the
73
+ // bodies are mirrored and the broad/narrow phase is populated.
74
+ let f = 0;
75
+ for (; f < 300 && db.store.resources.physicsQuery == null; f++) { tick(f); await yieldToEventLoop(); }
76
+ for (let k = 0; k < 30; k++, f++) { tick(f); await yieldToEventLoop(); }
77
+
78
+ return { db, floor, ball, box };
79
+ }
80
+
81
+ function describePicking(name: string, solver: Database.Plugin): void {
82
+ describe(`pickRay conformance — ${name}`, () => {
83
+ let scene: Scene;
84
+ beforeAll(async () => { scene = await buildScene(solver); }, 60_000);
85
+
86
+ it("publishes a physicsQuery once the world is live", () => {
87
+ expect((scene.db.store.resources as { physicsQuery: unknown }).physicsQuery).not.toBeNull();
88
+ });
89
+
90
+ it("hits the sphere with a ray straight down through its centre", () => {
91
+ // segment y: 10 → −10 (span 20); sphere top at y = 4 ⇒ α = (10 − 4) / 20 = 0.3
92
+ const ray: Line3 = { a: [0, 10, 0], b: [0, -10, 0] };
93
+ const hit = scene.db.actions.pickRay({ ray });
94
+ expect(hit).not.toBeNull();
95
+ expect(hit!.entity).toBe(scene.ball);
96
+ expect(hit!.distance).toBeCloseTo(0.3, 1);
97
+ expect(hit!.point[1]).toBeCloseTo(4, 1);
98
+ // ray points down, so the surface normal points up (opposes the ray)
99
+ expect(hit!.normal[1]).toBeGreaterThan(0.5);
100
+ });
101
+
102
+ it("hits the box off to the side", () => {
103
+ const ray: Line3 = { a: [5, 10, 0], b: [5, -10, 0] };
104
+ const hit = scene.db.actions.pickRay({ ray });
105
+ expect(hit).not.toBeNull();
106
+ expect(hit!.entity).toBe(scene.box);
107
+ expect(hit!.point[1]).toBeCloseTo(4, 1); // box top face
108
+ expect(hit!.normal[1]).toBeGreaterThan(0.5);
109
+ });
110
+
111
+ it("returns the nearest collider when several lie along the ray", () => {
112
+ // straight down through the centre passes the sphere (top y = 4) before
113
+ // the floor (top y = 0) → the nearer sphere wins
114
+ const hit = scene.db.actions.pickRay({ ray: { a: [0, 10, 0], b: [0, -10, 0] } });
115
+ expect(hit!.entity).toBe(scene.ball);
116
+ });
117
+
118
+ it("returns null when the ray misses every collider", () => {
119
+ const ray: Line3 = { a: [100, 10, 100], b: [100, -10, 100] };
120
+ expect(scene.db.actions.pickRay({ ray })).toBeNull();
121
+ });
122
+
123
+ it("with/without radius: a grazing ray misses as a line but hits as a thick ray", () => {
124
+ // Short segment offset 1.4 in x from the sphere centre, kept above the
125
+ // floor (y: 5 → 2.5). As a thin line it clears the sphere (1.4 > radius 1)
126
+ // and never reaches the floor → miss. A 0.5-radius sweep has effective
127
+ // reach 1.5 > 1.4 → it grazes the sphere.
128
+ const ray: Line3 = { a: [1.4, 5, 0], b: [1.4, 2.5, 0] };
129
+ expect(scene.db.actions.pickRay({ ray })).toBeNull();
130
+
131
+ const hit = scene.db.actions.pickRay({ ray, radius: 0.5 });
132
+ expect(hit).not.toBeNull();
133
+ expect(hit!.entity).toBe(scene.ball);
134
+ expect(hit!.distance).toBeGreaterThanOrEqual(0);
135
+ expect(hit!.distance).toBeLessThanOrEqual(1);
136
+ });
137
+
138
+ it("castRayEach visits every collider along the ray, nearest first", () => {
139
+ // straight down at x = 0 crosses the sphere (top y = 4, α = 0.3) then the
140
+ // floor (top y = 0, α = 0.5); the box at x = 5 is not on the ray
141
+ const seen: number[] = [];
142
+ const dists: number[] = [];
143
+ scene.db.actions.pickRayEach({
144
+ ray: { a: [0, 10, 0], b: [0, -10, 0] },
145
+ visit: (h) => { seen.push(h.entity); dists.push(h.distance); },
146
+ });
147
+ expect(seen).toEqual([scene.ball, scene.floor]);
148
+ expect(dists).toEqual([...dists].sort((x, y) => x - y)); // ascending
149
+ });
150
+
151
+ it("castRayEach stops early when visit returns false", () => {
152
+ const seen: number[] = [];
153
+ scene.db.actions.pickRayEach({
154
+ ray: { a: [0, 10, 0], b: [0, -10, 0] },
155
+ visit: (h) => { seen.push(h.entity); return false; }, // stop after the nearest
156
+ });
157
+ expect(seen).toEqual([scene.ball]);
158
+ });
159
+
160
+ it("castRayEach visits nothing when the ray misses everything", () => {
161
+ let count = 0;
162
+ scene.db.actions.pickRayEach({
163
+ ray: { a: [100, 10, 100], b: [100, -10, 100] },
164
+ visit: () => { count++; },
165
+ });
166
+ expect(count).toBe(0);
167
+ });
168
+
169
+ it("castRayEach with radius visits at least the nearest swept hit on every solver", () => {
170
+ // The Rapier compat binding can't sweep-all, so radius degrades to the
171
+ // nearest there; Jolt returns all. Both must at least visit the nearest.
172
+ const seen: number[] = [];
173
+ scene.db.actions.pickRayEach({
174
+ ray: { a: [1.4, 5, 0], b: [1.4, 2.5, 0] },
175
+ radius: 0.5,
176
+ visit: (h) => { seen.push(h.entity); },
177
+ });
178
+ expect(seen.length).toBeGreaterThanOrEqual(1);
179
+ expect(seen[0]).toBe(scene.ball);
180
+ });
181
+
182
+ it("castRayEach reuses a single hit instance across visits (zero-alloc contract)", () => {
183
+ const refs: PhysicsHit[] = [];
184
+ scene.db.actions.pickRayEach({ ray: { a: [0, 10, 0], b: [0, -10, 0] }, visit: (h) => { refs.push(h); } });
185
+ expect(refs.length).toBe(2);
186
+ expect(refs[0]).toBe(refs[1]); // same object, refilled per visit — do not retain
187
+ });
188
+
189
+ it("castRay returns fresh, independent hits (safe to retain)", () => {
190
+ const h1 = scene.db.actions.pickRay({ ray: { a: [0, 10, 0], b: [0, -10, 0] } });
191
+ const h2 = scene.db.actions.pickRay({ ray: { a: [5, 10, 0], b: [5, -10, 0] } });
192
+ expect(h1).not.toBe(h2);
193
+ expect(h1!.entity).toBe(scene.ball); // unaffected by the second pick
194
+ expect(h2!.entity).toBe(scene.box);
195
+ });
196
+ });
197
+ }
198
+
199
+ describePicking("rapierSolver", rapierSolver);
200
+ describePicking("joltSolver", joltSolver);
@@ -2,6 +2,7 @@
2
2
 
3
3
  import RAPIER from "@dimforge/rapier3d-compat";
4
4
  import { Database, type Entity } from "@adobe/data/ecs";
5
+ import type { Line3, Vec3 } from "@adobe/data/math";
5
6
  import { isVoxelShapePhysicsPending } from "../is-voxel-shape-physics-pending.js";
6
7
  import { True } from "@adobe/data/schema";
7
8
  import { physicsClock } from "../physics-clock-plugin.js";
@@ -9,6 +10,10 @@ import { physicsData } from "../physics-data-plugin.js";
9
10
  import { jointData } from "../joint/joint-plugin.js";
10
11
  import { BodyType } from "../body/body-type/body-type.js";
11
12
  import { ColliderShape } from "../body/collider-shape/collider-shape.js";
13
+ import type { PhysicsQuery } from "../physics-query.js";
14
+ import type { PhysicsHit } from "../physics-hit.js";
15
+ import { orientNormalAgainstRay } from "./orient-normal-against-ray.js";
16
+ import { newHit, type MutableHit } from "./mutable-hit.js";
12
17
 
13
18
  /**
14
19
  * A second rigid-body solver behind the same `physicsData` seam — the
@@ -84,6 +89,7 @@ export const rapierSolver = Database.Plugin.create({
84
89
  desc.setTranslation(px, py, pz).setRotation({ x: q[0], y: q[1], z: q[2], w: q[3] });
85
90
  if (motion === "dynamic") { desc.setLinvel(vx, vy, vz); desc.setAngvel({ x: wx, y: wy, z: wz }); }
86
91
  const body = world.createRigidBody(desc);
92
+ body.userData = id; // reverse lookup for ray-pick: collider → parent body → entity
87
93
  // capsule: Y-aligned, halfHeight = cylinder half (hy), radius = hx.
88
94
  // hull: convex hull of the authored point cloud (read once here).
89
95
  let col: RAPIER.ColliderDesc | null;
@@ -107,11 +113,96 @@ export const rapierSolver = Database.Plugin.create({
107
113
  bodies.set(id, body);
108
114
  };
109
115
 
116
+ // Solver-agnostic ray-pick over the live Rapier world. Passing the
117
+ // segment vector (b − a) as the ray/shape-cast direction and capping
118
+ // the time-of-impact at 1 makes the returned toi the parametric α ∈
119
+ // [0,1] along the segment directly (matching PhysicsHit's convention).
120
+ const makeQuery = (w: RAPIER.World): PhysicsQuery => {
121
+ // Fill `out` with a thin-ray hit. userData was set to the entity id
122
+ // when the body was mirrored. Normal is oriented to oppose the ray.
123
+ const fillRay = (out: MutableHit, entity: Entity, alpha: number, nx: number, ny: number, nz: number, a: Vec3, dx: number, dy: number, dz: number): void => {
124
+ out.entity = entity; out.distance = alpha;
125
+ out.point[0] = a[0] + dx * alpha; out.point[1] = a[1] + dy * alpha; out.point[2] = a[2] + dz * alpha;
126
+ out.normal[0] = nx; out.normal[1] = ny; out.normal[2] = nz;
127
+ orientNormalAgainstRay(out.normal, dx, dy, dz);
128
+ };
129
+ const nearest = (ray: Line3, options?: { radius?: number }): PhysicsHit | null => {
130
+ const a = ray.a, b = ray.b;
131
+ const dx = b[0] - a[0], dy = b[1] - a[1], dz = b[2] - a[2];
132
+ const radius = options?.radius ?? 0;
133
+ const out = newHit();
134
+ if (radius > 0) {
135
+ // sphere of `radius` swept along the segment
136
+ const hit = w.castShape(
137
+ { x: a[0], y: a[1], z: a[2] }, { x: 0, y: 0, z: 0, w: 1 }, { x: dx, y: dy, z: dz },
138
+ new RAPIER.Ball(radius), 0, 1, true,
139
+ );
140
+ if (!hit) return null;
141
+ const bod = hit.collider.parent();
142
+ if (!bod) return null;
143
+ const alpha = hit.time_of_impact;
144
+ out.entity = bod.userData as Entity; out.distance = alpha;
145
+ out.normal[0] = hit.normal1.x; out.normal[1] = hit.normal1.y; out.normal[2] = hit.normal1.z;
146
+ orientNormalAgainstRay(out.normal, dx, dy, dz);
147
+ // pull the swept-sphere centre at impact back onto the collider surface
148
+ out.point[0] = a[0] + dx * alpha - out.normal[0] * radius;
149
+ out.point[1] = a[1] + dy * alpha - out.normal[1] * radius;
150
+ out.point[2] = a[2] + dz * alpha - out.normal[2] * radius;
151
+ return out;
152
+ }
153
+ const hit = w.castRayAndGetNormal(new RAPIER.Ray({ x: a[0], y: a[1], z: a[2] }, { x: dx, y: dy, z: dz }), 1, true);
154
+ if (!hit) return null;
155
+ const bod = hit.collider.parent();
156
+ if (!bod) return null;
157
+ fillRay(out, bod.userData as Entity, hit.timeOfImpact, hit.normal.x, hit.normal.y, hit.normal.z, a, dx, dy, dz);
158
+ return out;
159
+ };
160
+ // Zero-alloc scratch for castRayEach: one reused hit + a grow-only pool
161
+ // of records. intersectionsWithRay reports crossings in broadphase-
162
+ // arbitrary order, so records are collected then index-sorted near→far.
163
+ const scratch = newHit();
164
+ const pool: { entity: Entity; distance: number; nx: number; ny: number; nz: number }[] = [];
165
+ const order: number[] = [];
166
+ return {
167
+ castRay: nearest,
168
+ castRayEach(ray: Line3, visit, options): void {
169
+ // The compat binding has no swept-shape all-hits query, so a
170
+ // radius query degrades to the nearest hit (see PhysicsQuery).
171
+ if ((options?.radius ?? 0) > 0) { const h = nearest(ray, options); if (h) visit(h); return; }
172
+ const a = ray.a, b = ray.b;
173
+ const dx = b[0] - a[0], dy = b[1] - a[1], dz = b[2] - a[2];
174
+ let count = 0;
175
+ w.intersectionsWithRay(new RAPIER.Ray({ x: a[0], y: a[1], z: a[2] }, { x: dx, y: dy, z: dz }), 1, true, (isect: RAPIER.RayColliderIntersection) => {
176
+ const bod = isect.collider.parent();
177
+ if (bod) {
178
+ let rec = pool[count];
179
+ if (!rec) { rec = { entity: 0 as Entity, distance: 0, nx: 0, ny: 0, nz: 0 }; pool[count] = rec; }
180
+ rec.entity = bod.userData as Entity; rec.distance = isect.timeOfImpact;
181
+ rec.nx = isect.normal.x; rec.ny = isect.normal.y; rec.nz = isect.normal.z;
182
+ count++;
183
+ }
184
+ return true; // keep collecting
185
+ });
186
+ for (let i = 0; i < count; i++) order[i] = i;
187
+ order.length = count;
188
+ order.sort((i, j) => pool[i].distance - pool[j].distance);
189
+ for (let k = 0; k < count; k++) {
190
+ const rec = pool[order[k]];
191
+ fillRay(scratch, rec.entity, rec.distance, rec.nx, rec.ny, rec.nz, a, dx, dy, dz);
192
+ if (visit(scratch) === false) break;
193
+ }
194
+ },
195
+ };
196
+ };
197
+
110
198
  return () => {
111
199
  if (!world) {
112
200
  if (!initStarted) {
113
201
  initStarted = true;
114
- RAPIER.init().then(() => { world = new RAPIER.World({ x: 0, y: -GRAVITY, z: 0 }); });
202
+ RAPIER.init().then(() => {
203
+ world = new RAPIER.World({ x: 0, y: -GRAVITY, z: 0 });
204
+ db.store.resources.physicsQuery = makeQuery(world);
205
+ });
115
206
  }
116
207
  return; // WASM not ready yet
117
208
  }