@aethervtt/shard-physics 0.0.1 → 0.1.0

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/dist/render.js ADDED
@@ -0,0 +1,264 @@
1
+ import { defineOverlay, Meshes } from "@aethervtt/shard-render";
2
+ import { CharacterState } from "./components.js";
3
+ import { rotate } from "./pose.js";
4
+ import { Physics } from "./world.js";
5
+ const KIND_DYNAMIC = 0;
6
+ const KIND_FIXED = 1;
7
+ function rendererMeshes(world, ref) {
8
+ return world.tryResource(Meshes)?.get(ref);
9
+ }
10
+ const COLORS = [
11
+ [1, 0.55, 0.2, 1],
12
+ // dynamic
13
+ [0.55, 0.55, 0.6, 1],
14
+ // fixed
15
+ [0.3, 0.8, 1, 1],
16
+ // kinematic
17
+ [0.3, 0.8, 1, 1]
18
+ ];
19
+ const SLEEPING = [0.5, 0.35, 0.2, 1];
20
+ const SENSOR = [0.3, 1, 0.4, 1];
21
+ const CONTACT = [1, 0.2, 0.3, 1];
22
+ const UP = [1, 0.9, 0.2, 1];
23
+ const pa = new Float64Array(3);
24
+ const pb = new Float64Array(3);
25
+ const q = new Float64Array(4);
26
+ const center = new Float64Array(3);
27
+ const collidersOverlay = defineOverlay({
28
+ name: "colliders",
29
+ description: "Physics collider outlines, colored by body kind (sleeping dimmed, sensors green, characters cyan with their up in yellow), and contact normals.",
30
+ draw(world, g, passes) {
31
+ const p = world.tryResource(Physics);
32
+ if (!p) return;
33
+ for (const [entity, collider] of p.colliders) {
34
+ if (!passes(entity)) continue;
35
+ const owner = p.colliderOwner.get(entity);
36
+ const record = owner === void 0 ? void 0 : p.bodies.get(owner);
37
+ const color = collider.isSensor() ? SENSOR : record?.body.isSleeping() ? SLEEPING : COLORS[record?.kind ?? KIND_FIXED];
38
+ const t = collider.translation(p.v3);
39
+ center[0] = t.x;
40
+ center[1] = t.y;
41
+ center[2] = p.dim === 3 ? t.z : 0;
42
+ if (p.dim === 3) {
43
+ const r = collider.rotation(p.q4);
44
+ q[0] = r.x;
45
+ q[1] = r.y;
46
+ q[2] = r.z;
47
+ q[3] = r.w;
48
+ } else {
49
+ const angle = collider.rotation();
50
+ q[0] = 0;
51
+ q[1] = 0;
52
+ q[2] = Math.sin(angle / 2);
53
+ q[3] = Math.cos(angle / 2);
54
+ }
55
+ drawCollider(p, g, collider, p.colliderShape.get(entity), color);
56
+ }
57
+ for (const [entity, c] of p.characters) {
58
+ if (!passes(entity) || !world.has(entity, CharacterState)) continue;
59
+ const up = world.get(entity, CharacterState).up;
60
+ const t = c.collider.translation(p.v3);
61
+ const tall = p.colliderShape.get(entity) === "capsule" ? c.collider.halfHeight() : 0;
62
+ const reach = c.collider.radius() + tall + 0.5;
63
+ pa[0] = t.x;
64
+ pa[1] = t.y;
65
+ pa[2] = p.dim === 3 ? t.z : 0;
66
+ pb[0] = pa[0] + up[0] * reach;
67
+ pb[1] = pa[1] + up[1] * reach;
68
+ pb[2] = pa[2] + up[2] * reach;
69
+ g.line(pa, pb, UP);
70
+ }
71
+ contactBudget = MAX_CONTACT_PAIRS;
72
+ for (let i = 0; i < p.list.length && contactBudget > 0; i++) {
73
+ const record = p.list[i];
74
+ if (record.kind !== KIND_DYNAMIC || record.body.isSleeping()) continue;
75
+ const n = record.body.numColliders();
76
+ for (let c = 0; c < n; c++) drawContacts(p, g, record.body.collider(c));
77
+ }
78
+ }
79
+ });
80
+ const MAX_CONTACT_PAIRS = 1e3;
81
+ let contactBudget = 0;
82
+ function visitedFromOtherSide(other) {
83
+ const body = other.parent();
84
+ return body?.isDynamic() === true && !body.isSleeping();
85
+ }
86
+ function drawContacts(p, g, collider) {
87
+ p.raw.contactPairsWith(collider, (other) => {
88
+ if (contactBudget <= 0) return;
89
+ if (other.handle < collider.handle && visitedFromOtherSide(other)) return;
90
+ contactBudget--;
91
+ p.raw.contactPair(collider, other, (manifold) => {
92
+ const count = manifold.numSolverContacts();
93
+ if (count === 0) return;
94
+ const n = manifold.normal();
95
+ for (let i = 0; i < count; i++) {
96
+ const point = manifold.solverContactPoint(i);
97
+ if (!point) continue;
98
+ pa[0] = point.x;
99
+ pa[1] = point.y;
100
+ pa[2] = p.dim === 3 ? point.z : 0;
101
+ pb[0] = pa[0] + n.x * 0.25;
102
+ pb[1] = pa[1] + n.y * 0.25;
103
+ pb[2] = pa[2] + (p.dim === 3 ? n.z : 0) * 0.25;
104
+ g.line(pa, pb, CONTACT);
105
+ }
106
+ });
107
+ });
108
+ }
109
+ function local(out, x, y, z) {
110
+ rotate(out, 0, q, 0, x, y, z);
111
+ out[0] = out[0] + center[0];
112
+ out[1] = out[1] + center[1];
113
+ out[2] = out[2] + center[2];
114
+ return out;
115
+ }
116
+ const SEGMENTS = 24;
117
+ const UNIT = (() => {
118
+ const out = new Float64Array((SEGMENTS + 1) * 2);
119
+ for (let i = 0; i <= SEGMENTS; i++) {
120
+ const t = i / SEGMENTS * Math.PI * 2;
121
+ out[i * 2] = Math.cos(t);
122
+ out[i * 2 + 1] = Math.sin(t);
123
+ }
124
+ return out;
125
+ })();
126
+ function localCircle(g, axis, r, dy, color) {
127
+ for (let i = 0; i <= SEGMENTS; i++) {
128
+ const u = UNIT[i * 2] * r;
129
+ const v = UNIT[i * 2 + 1] * r;
130
+ if (axis === 0) local(pb, 0, u + dy, v);
131
+ else if (axis === 1) local(pb, u, dy, v);
132
+ else local(pb, u, v + dy, 0);
133
+ if (i > 0) g.line(pa, pb, color);
134
+ pa[0] = pb[0];
135
+ pa[1] = pb[1];
136
+ pa[2] = pb[2];
137
+ }
138
+ }
139
+ function drawCollider(p, g, collider, shape, color) {
140
+ const is2d = p.dim === 2;
141
+ switch (shape) {
142
+ case "ball": {
143
+ const r = collider.radius();
144
+ localCircle(g, 2, r, 0, color);
145
+ if (!is2d) {
146
+ localCircle(g, 0, r, 0, color);
147
+ localCircle(g, 1, r, 0, color);
148
+ }
149
+ return;
150
+ }
151
+ case "cuboid": {
152
+ const he = collider.halfExtents();
153
+ const hz = is2d ? 0 : he.z;
154
+ const size = [he.x * 2, he.y * 2, hz * 2];
155
+ g.box(center, size, q, color);
156
+ return;
157
+ }
158
+ case "capsule":
159
+ case "cylinder":
160
+ case "cone": {
161
+ const r = collider.radius();
162
+ const h = collider.halfHeight();
163
+ if (is2d) {
164
+ local(pa, -r, -h, 0);
165
+ local(pb, -r, h, 0);
166
+ g.line(pa, pb, color);
167
+ local(pa, r, -h, 0);
168
+ local(pb, r, h, 0);
169
+ g.line(pa, pb, color);
170
+ localCircle(g, 2, r, h, color);
171
+ localCircle(g, 2, r, -h, color);
172
+ return;
173
+ }
174
+ const top = shape === "cone" ? 0 : r;
175
+ localCircle(g, 1, r, -h, color);
176
+ if (top > 0) localCircle(g, 1, top, h, color);
177
+ for (let k = 0; k < 4; k++) {
178
+ const a = k / 4 * Math.PI * 2;
179
+ local(pa, Math.cos(a) * r, -h, Math.sin(a) * r);
180
+ local(pb, Math.cos(a) * top, h, Math.sin(a) * top);
181
+ g.line(pa, pb, color);
182
+ }
183
+ if (shape === "capsule") {
184
+ localCircle(g, 0, r, h, color);
185
+ localCircle(g, 0, r, -h, color);
186
+ localCircle(g, 2, r, h, color);
187
+ localCircle(g, 2, r, -h, color);
188
+ }
189
+ return;
190
+ }
191
+ case "heightfield":
192
+ drawHeightfield(p, g, collider, color);
193
+ return;
194
+ default: {
195
+ const vertices = collider.vertices?.();
196
+ const indices = collider.indices?.();
197
+ if (!vertices || vertices.length === 0) return;
198
+ const d = p.dim;
199
+ const edge = (i, j) => {
200
+ local(pa, vertices[i * d], vertices[i * d + 1], d === 3 ? vertices[i * d + 2] : 0);
201
+ local(pb, vertices[j * d], vertices[j * d + 1], d === 3 ? vertices[j * d + 2] : 0);
202
+ g.line(pa, pb, color);
203
+ };
204
+ if (indices && indices.length > 0) {
205
+ const stride = shape === "polyline" || is2d ? 2 : 3;
206
+ const limit = Math.min(indices.length, 3e4);
207
+ for (let i = 0; i + stride - 1 < limit; i += stride) {
208
+ for (let k = 0; k < stride; k++) {
209
+ if (stride === 2 && k === 1) break;
210
+ edge(indices[i + k], indices[i + (k + 1) % stride]);
211
+ }
212
+ }
213
+ } else {
214
+ const n = vertices.length / d;
215
+ for (let i = 0; i + 1 < n; i++) edge(i, i + 1);
216
+ if (shape === "convex" && n > 2) edge(n - 1, 0);
217
+ }
218
+ }
219
+ }
220
+ }
221
+ function drawHeightfield(p, g, collider, color) {
222
+ const hf = collider;
223
+ const heights = hf.heightfieldHeights();
224
+ const scale = hf.heightfieldScale();
225
+ if (p.dim === 2) {
226
+ const n = heights.length;
227
+ for (let i = 0; i < n; i++) {
228
+ local(pb, (i / (n - 1) - 0.5) * scale.x, heights[i] * scale.y, 0);
229
+ if (i > 0) g.line(pa, pb, color);
230
+ pa[0] = pb[0];
231
+ pa[1] = pb[1];
232
+ pa[2] = pb[2];
233
+ }
234
+ return;
235
+ }
236
+ const rows = hf.heightfieldNRows() + 1;
237
+ const cols = hf.heightfieldNCols() + 1;
238
+ const sz = scale.z ?? 1;
239
+ const at = (out, r, c) => local(
240
+ out,
241
+ (c / (cols - 1) - 0.5) * scale.x,
242
+ heights[c * rows + r] * scale.y,
243
+ (r / (rows - 1) - 0.5) * sz
244
+ );
245
+ const step = Math.max(1, Math.ceil(Math.max(rows, cols) / 64));
246
+ for (let r = 0; r < rows; r += step) {
247
+ for (let c = 0; c + step < cols; c += step) {
248
+ at(pa, r, c);
249
+ at(pb, r, c + step);
250
+ g.line(pa, pb, color);
251
+ }
252
+ }
253
+ for (let c = 0; c < cols; c += step) {
254
+ for (let r = 0; r + step < rows; r += step) {
255
+ at(pa, r, c);
256
+ at(pb, r + step, c);
257
+ g.line(pa, pb, color);
258
+ }
259
+ }
260
+ }
261
+ export {
262
+ collidersOverlay,
263
+ rendererMeshes
264
+ };
@@ -0,0 +1,58 @@
1
+ /** A recorded track: every body's pose at every fixed step, plus the contacts that mattered. */
2
+ export interface Track {
3
+ version: 1;
4
+ /** The Rapier build that recorded it, e.g. `rapier3d-deterministic@0.20.0`. */
5
+ engine: string;
6
+ /** `sceneHash` of the scene it was recorded from. */
7
+ sceneHash: number;
8
+ /** Seconds per step. */
9
+ step: number;
10
+ /** Steps simulated; poses exist for steps 0 to `steps`. */
11
+ steps: number;
12
+ bodyCount: number;
13
+ /** The settle rule ended it. */
14
+ settled: boolean;
15
+ /** It ran to the scene's maxSteps without settling. */
16
+ maxStepsHit: boolean;
17
+ /** (steps + 1) × bodies × 3, step-major. */
18
+ positions: Float32Array;
19
+ /** (steps + 1) × bodies × 4 (x y z w), step-major. */
20
+ rotations: Float32Array;
21
+ /** Contacts in the order they happened; `b` is -1 for a fixed collider. */
22
+ contacts: TrackContacts;
23
+ /** Time spent stepping. Excluded from equality and hashing. */
24
+ simulationMs: number;
25
+ }
26
+ export interface TrackContacts {
27
+ steps: Uint16Array;
28
+ a: Int16Array;
29
+ b: Int16Array;
30
+ /** Total contact force, N. */
31
+ force: Float32Array;
32
+ }
33
+ export declare const TRACK_VERSION = 1;
34
+ /** The engine tracks are recorded and played with; another one fails with `physics/track-version`. */
35
+ export declare const TRACK_ENGINE = "rapier3d-deterministic@0.20.0";
36
+ /** One buffer, header and arrays: what a worker transfers and what a host can store. */
37
+ export declare function encodeTrack(track: Track): ArrayBuffer;
38
+ export interface DecodeTrackOptions {
39
+ /** The engine the track must come from (default TRACK_ENGINE); null accepts any. */
40
+ engine?: string | null;
41
+ }
42
+ /**
43
+ * A track from `encodeTrack`'s buffer. Its arrays are views on the buffer, not copies. A track from
44
+ * another format version or engine fails with `physics/track-version`: poses from another build
45
+ * of Rapier might not be what this one would record.
46
+ */
47
+ export declare function decodeTrack(buffer: ArrayBuffer, options?: DecodeTrackOptions): Track;
48
+ /** A hash over the header and every array (not simulationMs): equal tracks hash equal. */
49
+ export declare function trackHash(track: Track): number;
50
+ /** Anything indexable to write numbers into: a typed array, a tuple, a component column. */
51
+ export interface Writable {
52
+ [index: number]: number;
53
+ }
54
+ /**
55
+ * A body's pose at `time` seconds (clamped to the track), interpolated between the two steps
56
+ * around it: positions linearly, rotations by slerp. Allocates nothing.
57
+ */
58
+ export declare function sampleTrack(track: Track, time: number, body: number, outPos: Writable, outRot: Writable): void;
@@ -0,0 +1,171 @@
1
+ import { ShardError } from "@aethervtt/shard-core";
2
+ import { Hasher } from "./hash.js";
3
+ const TRACK_VERSION = 1;
4
+ const TRACK_ENGINE = "rapier3d-deterministic@0.20.0";
5
+ const MAGIC = 1263686739;
6
+ const HEADER = 52;
7
+ const SETTLED = 1;
8
+ const MAX_STEPS_HIT = 2;
9
+ function corrupt(message) {
10
+ return new ShardError("physics/track-invalid", message, {
11
+ hint: "Pass the ArrayBuffer encodeTrack made, whole."
12
+ });
13
+ }
14
+ function encodeTrack(track) {
15
+ const engineBytes = track.engine.length + 3 & ~3;
16
+ const c = track.contacts.steps.length;
17
+ const size = HEADER + engineBytes + (track.positions.length + track.rotations.length) * 4 + c * 10;
18
+ const buf = new ArrayBuffer(size);
19
+ const view = new DataView(buf);
20
+ view.setUint32(0, MAGIC, true);
21
+ view.setUint32(4, track.version, true);
22
+ view.setUint32(8, track.sceneHash >>> 0, true);
23
+ view.setFloat64(12, track.step, true);
24
+ view.setUint32(20, track.steps, true);
25
+ view.setUint32(24, track.bodyCount, true);
26
+ view.setUint32(28, (track.settled ? SETTLED : 0) | (track.maxStepsHit ? MAX_STEPS_HIT : 0), true);
27
+ view.setUint32(32, c, true);
28
+ view.setFloat64(36, track.simulationMs, true);
29
+ view.setUint32(44, track.engine.length, true);
30
+ view.setUint32(48, 0, true);
31
+ const bytes = new Uint8Array(buf);
32
+ for (let i = 0; i < track.engine.length; i++)
33
+ bytes[HEADER + i] = track.engine.charCodeAt(i) & 127;
34
+ let o = HEADER + engineBytes;
35
+ new Float32Array(buf, o, track.positions.length).set(track.positions);
36
+ o += track.positions.length * 4;
37
+ new Float32Array(buf, o, track.rotations.length).set(track.rotations);
38
+ o += track.rotations.length * 4;
39
+ new Float32Array(buf, o, c).set(track.contacts.force);
40
+ o += c * 4;
41
+ new Uint16Array(buf, o, c).set(track.contacts.steps);
42
+ o += c * 2;
43
+ new Int16Array(buf, o, c).set(track.contacts.a);
44
+ o += c * 2;
45
+ new Int16Array(buf, o, c).set(track.contacts.b);
46
+ return buf;
47
+ }
48
+ function decodeTrack(buffer, options) {
49
+ if (buffer.byteLength < HEADER) throw corrupt("The buffer is too short for a track");
50
+ const view = new DataView(buffer);
51
+ if (view.getUint32(0, true) !== MAGIC) throw corrupt("The buffer isn't a track");
52
+ const version = view.getUint32(4, true);
53
+ const engineLength = view.getUint32(44, true);
54
+ if (HEADER + engineLength > buffer.byteLength) throw corrupt("The track header is cut off");
55
+ const bytes = new Uint8Array(buffer, HEADER, engineLength);
56
+ let engine = "";
57
+ for (let i = 0; i < engineLength; i++) engine += String.fromCharCode(bytes[i]);
58
+ const expected = options?.engine === void 0 ? TRACK_ENGINE : options.engine;
59
+ if (version !== TRACK_VERSION || expected !== null && engine !== expected) {
60
+ throw new ShardError(
61
+ "physics/track-version",
62
+ `The track is version ${version} from ${engine}; expected version ${TRACK_VERSION} from ${expected ?? "any engine"}`,
63
+ { hint: "Record it again with this build, or play it with the build that recorded it." }
64
+ );
65
+ }
66
+ const steps = view.getUint32(20, true);
67
+ const bodyCount = view.getUint32(24, true);
68
+ const flags = view.getUint32(28, true);
69
+ const c = view.getUint32(32, true);
70
+ const poses = (steps + 1) * bodyCount;
71
+ let o = HEADER + (engineLength + 3 & ~3);
72
+ if (o + poses * 28 + c * 10 > buffer.byteLength) throw corrupt("The track is cut off");
73
+ const positions = new Float32Array(buffer, o, poses * 3);
74
+ o += poses * 12;
75
+ const rotations = new Float32Array(buffer, o, poses * 4);
76
+ o += poses * 16;
77
+ const force = new Float32Array(buffer, o, c);
78
+ o += c * 4;
79
+ const contactSteps = new Uint16Array(buffer, o, c);
80
+ o += c * 2;
81
+ const a = new Int16Array(buffer, o, c);
82
+ o += c * 2;
83
+ const b = new Int16Array(buffer, o, c);
84
+ return {
85
+ version: TRACK_VERSION,
86
+ engine,
87
+ sceneHash: view.getUint32(8, true),
88
+ step: view.getFloat64(12, true),
89
+ steps,
90
+ bodyCount,
91
+ settled: (flags & SETTLED) !== 0,
92
+ maxStepsHit: (flags & MAX_STEPS_HIT) !== 0,
93
+ positions,
94
+ rotations,
95
+ contacts: { steps: contactSteps, a, b, force },
96
+ simulationMs: view.getFloat64(36, true)
97
+ };
98
+ }
99
+ function trackHash(track) {
100
+ const h = new Hasher();
101
+ h.u32(track.version).str(track.engine).u32(track.sceneHash).f64(track.step);
102
+ h.u32(track.steps).u32(track.bodyCount).bool(track.settled).bool(track.maxStepsHit);
103
+ h.array(track.positions).array(track.rotations);
104
+ const c = track.contacts;
105
+ h.array(c.steps).array(c.a).array(c.b).array(c.force);
106
+ return h.digest();
107
+ }
108
+ function sampleTrack(track, time, body, outPos, outRot) {
109
+ const n = track.bodyCount;
110
+ if (body < 0 || body >= n || !Number.isInteger(body)) {
111
+ throw new ShardError("physics/track-body", `The track has no body ${body}`, {
112
+ hint: `Bodies are 0 to ${n - 1}, in the scene's order.`
113
+ });
114
+ }
115
+ let f = time / track.step;
116
+ if (!(f > 0)) f = 0;
117
+ if (f > track.steps) f = track.steps;
118
+ const i0 = Math.floor(f);
119
+ const i1 = i0 < track.steps ? i0 + 1 : i0;
120
+ const t = f - i0;
121
+ const p = track.positions;
122
+ const a3 = (i0 * n + body) * 3;
123
+ const b3 = (i1 * n + body) * 3;
124
+ outPos[0] = p[a3] + (p[b3] - p[a3]) * t;
125
+ outPos[1] = p[a3 + 1] + (p[b3 + 1] - p[a3 + 1]) * t;
126
+ outPos[2] = p[a3 + 2] + (p[b3 + 2] - p[a3 + 2]) * t;
127
+ const r = track.rotations;
128
+ const a4 = (i0 * n + body) * 4;
129
+ const b4 = (i1 * n + body) * 4;
130
+ const ax = r[a4];
131
+ const ay = r[a4 + 1];
132
+ const az = r[a4 + 2];
133
+ const aw = r[a4 + 3];
134
+ let bx = r[b4];
135
+ let by = r[b4 + 1];
136
+ let bz = r[b4 + 2];
137
+ let bw = r[b4 + 3];
138
+ let cos = ax * bx + ay * by + az * bz + aw * bw;
139
+ if (cos < 0) {
140
+ cos = -cos;
141
+ bx = -bx;
142
+ by = -by;
143
+ bz = -bz;
144
+ bw = -bw;
145
+ }
146
+ let ka = 1 - t;
147
+ let kb = t;
148
+ if (cos < 0.9995) {
149
+ const angle = Math.acos(cos);
150
+ const sin = Math.sin(angle);
151
+ ka = Math.sin((1 - t) * angle) / sin;
152
+ kb = Math.sin(t * angle) / sin;
153
+ }
154
+ const x = ax * ka + bx * kb;
155
+ const y = ay * ka + by * kb;
156
+ const z = az * ka + bz * kb;
157
+ const w = aw * ka + bw * kb;
158
+ const len = Math.sqrt(x * x + y * y + z * z + w * w) || 1;
159
+ outRot[0] = x / len;
160
+ outRot[1] = y / len;
161
+ outRot[2] = z / len;
162
+ outRot[3] = w / len;
163
+ }
164
+ export {
165
+ TRACK_ENGINE,
166
+ TRACK_VERSION,
167
+ decodeTrack,
168
+ encodeTrack,
169
+ sampleTrack,
170
+ trackHash
171
+ };