@series-inc/rundot-syncplay 5.26.0-beta.2 → 5.26.0-beta.3
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/README.md +17 -0
- package/core/native/deterministic_math.hpp +4 -3
- package/core/native/fixed_math.cpp +741 -0
- package/core/native/fixed_math.hpp +111 -0
- package/core/native/kinetix-native-main.cpp +227 -88
- package/core/native/mobile/box3d_probe_scenario.cpp +101 -0
- package/core/native/mobile/box3d_probe_scenario.hpp +25 -0
- package/core/native/mobile/syncplay_native_probe.cpp +83 -0
- package/core/native/mobile/syncplay_native_probe_abi.h +41 -0
- package/core/physics.d.ts +48 -0
- package/core/scripts/build-mobile-native.mjs +99 -0
- package/core/scripts/build-native.mjs +15 -1
- package/core/scripts/build-wasm-fixed-math.mjs +58 -0
- package/core/scripts/run-box3d-benchmark.mjs +22 -124
- package/core/src/physics/box3d-probe.mjs +171 -0
- package/core/src/physics/index.mjs +156 -19
- package/core/src/physics/physics-wasm-module.generated.mjs +3 -3
- package/dist/browser.d.ts +1 -0
- package/dist/browser.js +1 -0
- package/dist/creator.d.ts +1 -0
- package/dist/creator.js +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/movement3d.d.ts +9 -0
- package/dist/movement3d.js +185 -24
- package/dist/physics3d-heightfield.d.ts +10 -0
- package/dist/physics3d-heightfield.js +33 -0
- package/dist/physics3d-hull.d.ts +14 -0
- package/dist/physics3d-hull.js +45 -0
- package/dist/physics3d.js +28 -1
- package/package.json +8 -4
- package/dist/prediction-culling.d.ts +0 -34
- package/dist/prediction-culling.js +0 -200
|
@@ -9,6 +9,55 @@ import {
|
|
|
9
9
|
} from './mass-properties.mjs';
|
|
10
10
|
|
|
11
11
|
const PHYSICS_ABI_VERSION = 14;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Mirrors `kBroadphaseCapacity` (native/physics/physics.cpp:1858).
|
|
15
|
+
* recordContactTelemetry silently returns at this count, so the exported contact
|
|
16
|
+
* count saturates here and a step that reaches it may be missing contacts. No
|
|
17
|
+
* overflow flag crosses the ABI, so this boundary is the only signal available.
|
|
18
|
+
*/
|
|
19
|
+
export const PHYSICS_CONTACT_TELEMETRY_CAP = 100_000;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Post-step contact contract, published so other runtimes (notably the native
|
|
23
|
+
* C++ player) consume the same semantics without reimplementing physics.
|
|
24
|
+
*/
|
|
25
|
+
export const PHYSICS_CONTACT_CONTRACT = Object.freeze({
|
|
26
|
+
entry: Object.freeze([
|
|
27
|
+
'pair', 'anchorX', 'anchorY', 'anchorZ',
|
|
28
|
+
'normalImpulse', 'tangentImpulse1', 'tangentImpulse2',
|
|
29
|
+
]),
|
|
30
|
+
ordering: 'contactCache is sorted by pair id; ordering is canonical world state and feeds checksums',
|
|
31
|
+
authority: Object.freeze({
|
|
32
|
+
solid: 'cpp-telemetry',
|
|
33
|
+
triggerOrSensor: 'js-detect-contacts',
|
|
34
|
+
note: 'the solver never resolves triggers or sensors, so telemetry cannot report them',
|
|
35
|
+
}),
|
|
36
|
+
warmStart: 'contactCache is returned to the solver next step via rundot_kinetix_physics_set_contact_cache',
|
|
37
|
+
budgets: Object.freeze({
|
|
38
|
+
contactTelemetry: Object.freeze({
|
|
39
|
+
limit: PHYSICS_CONTACT_TELEMETRY_CAP,
|
|
40
|
+
onOverflow: 'silently-truncates-in-cpp; detected here by count saturation and raised',
|
|
41
|
+
signal: 'KINETIX_PHYSICS_CONTACT_TELEMETRY_SATURATED',
|
|
42
|
+
}),
|
|
43
|
+
broadphasePairs: Object.freeze({
|
|
44
|
+
limit: 1 << 20,
|
|
45
|
+
onOverflow: 'falls-back-to-all-pairs-in-cpp; lossless, no signal needed',
|
|
46
|
+
signal: null,
|
|
47
|
+
}),
|
|
48
|
+
}),
|
|
49
|
+
errors: Object.freeze(['KINETIX_PHYSICS_CONTACT_TELEMETRY_SATURATED']),
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Tripwire against obviously-wrong geometry (a mis-scaled voxel cook, a units
|
|
54
|
+
* error), NOT a technical limit — the marshaling path has been exercised to
|
|
55
|
+
* 12.6M elements without failing. Set far above any realtime-usable size: at
|
|
56
|
+
* 1M elements (a ~577x577 heightfield) a single body already costs ~22ms/tick,
|
|
57
|
+
* so anything reaching this ceiling is broken rather than merely slow. See
|
|
58
|
+
* SYNCPLAY-PHYSICS.md for the practical per-step budget, which is much lower.
|
|
59
|
+
*/
|
|
60
|
+
export const PHYSICS_MAX_SHAPE_GEOMETRY_ELEMENTS = 1_000_000;
|
|
12
61
|
const PHYSICS_BODY_STRIDE = 44;
|
|
13
62
|
const PHYSICS_JOINT_STRIDE = 48;
|
|
14
63
|
const PHYSICS_JOINT_TELEMETRY_STRIDE = 5;
|
|
@@ -183,6 +232,23 @@ function cloneOptionalNumber(source, target, key, minimum = Number.NEGATIVE_INFI
|
|
|
183
232
|
target[key] = value;
|
|
184
233
|
}
|
|
185
234
|
|
|
235
|
+
/**
|
|
236
|
+
* Flat geometry element count without materializing the geometry — an oversized
|
|
237
|
+
* shape must fail before it allocates the array that would have blown up. The
|
|
238
|
+
* per-child term must match `childStrideValues` in shapeGeometryValues; an
|
|
239
|
+
* over-count here would reject shapes that marshal fine.
|
|
240
|
+
*/
|
|
241
|
+
function shapeGeometryElementCount(shape) {
|
|
242
|
+
if (shape.type === 'mesh' || shape.type === 'hull') return shape.vertices.length * 3;
|
|
243
|
+
if (shape.type === 'heightfield') return 3 + shape.columns * shape.rows * 3;
|
|
244
|
+
if (shape.type === 'compound') {
|
|
245
|
+
let total = 0;
|
|
246
|
+
for (const child of shape.children) total += 18 + shapeGeometryElementCount(child.shape);
|
|
247
|
+
return total;
|
|
248
|
+
}
|
|
249
|
+
return 0;
|
|
250
|
+
}
|
|
251
|
+
|
|
186
252
|
function normalizeBody(body) {
|
|
187
253
|
if (!isRecord(body) || typeof body.id !== 'string' || body.id.length === 0 || body.id.includes('|')) {
|
|
188
254
|
fail('KINETIX_PHYSICS_BODY_ID_INVALID');
|
|
@@ -190,6 +256,11 @@ function normalizeBody(body) {
|
|
|
190
256
|
if (body.kind !== 'static' && body.kind !== 'dynamic' && body.kind !== 'kinematic') {
|
|
191
257
|
fail('KINETIX_PHYSICS_BODY_KIND_INVALID');
|
|
192
258
|
}
|
|
259
|
+
const shape = cloneShape(body.shape);
|
|
260
|
+
const geometryElements = shapeGeometryElementCount(shape);
|
|
261
|
+
if (geometryElements > PHYSICS_MAX_SHAPE_GEOMETRY_ELEMENTS) {
|
|
262
|
+
fail(`KINETIX_PHYSICS_SHAPE_GEOMETRY_TOO_LARGE ${body.id} ${geometryElements}`);
|
|
263
|
+
}
|
|
193
264
|
const normalized = {
|
|
194
265
|
id: body.id,
|
|
195
266
|
kind: body.kind,
|
|
@@ -199,7 +270,7 @@ function normalizeBody(body) {
|
|
|
199
270
|
vx: finite(body.vx, 'VX'),
|
|
200
271
|
vy: finite(body.vy, 'VY'),
|
|
201
272
|
vz: finite(body.vz, 'VZ'),
|
|
202
|
-
shape
|
|
273
|
+
shape,
|
|
203
274
|
layer: bitMask(body.layer ?? 1, 'LAYER'),
|
|
204
275
|
mask: bitMask(body.mask ?? 1, 'MASK'),
|
|
205
276
|
};
|
|
@@ -404,7 +475,23 @@ function minMaxVertices(vertices) {
|
|
|
404
475
|
return { minX, minY, minZ, maxX, maxY, maxZ };
|
|
405
476
|
}
|
|
406
477
|
|
|
478
|
+
/**
|
|
479
|
+
* Bounds are a pure function of a frozen shape, and marshalBodyData recomputes
|
|
480
|
+
* them per static body per step via shapeLocalCenter — recursing over every
|
|
481
|
+
* compound child each time. Identity-keyed memoization is sound and removes
|
|
482
|
+
* that entirely.
|
|
483
|
+
*/
|
|
484
|
+
let shapeLocalBoundsCache = new WeakMap();
|
|
485
|
+
|
|
407
486
|
function shapeLocalBounds(shape) {
|
|
487
|
+
const memoized = shapeLocalBoundsCache.get(shape);
|
|
488
|
+
if (memoized !== undefined) return memoized;
|
|
489
|
+
const computed = computeShapeLocalBounds(shape);
|
|
490
|
+
shapeLocalBoundsCache.set(shape, computed);
|
|
491
|
+
return computed;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function computeShapeLocalBounds(shape) {
|
|
408
495
|
if (shape.type === 'box') {
|
|
409
496
|
return {
|
|
410
497
|
minX: -shape.halfX,
|
|
@@ -769,11 +856,21 @@ function bodiesContactingAny(bodies, changedBodies) {
|
|
|
769
856
|
return affectedIds;
|
|
770
857
|
}
|
|
771
858
|
|
|
859
|
+
/**
|
|
860
|
+
* Values already deep-frozen by us. Without this, createWorld re-walks every
|
|
861
|
+
* static body's full shape tree on every step — the dominant per-step cost once
|
|
862
|
+
* checksums are off (32 statics x 16 compound children: 0.80 -> 0.13 ms/tick).
|
|
863
|
+
* A frozen value cannot change, so a hit is always sound.
|
|
864
|
+
*/
|
|
865
|
+
let deepFrozenValues = new WeakSet();
|
|
866
|
+
|
|
772
867
|
function deepFreeze(value, seen = new WeakSet()) {
|
|
773
868
|
if (!isRecord(value) && !Array.isArray(value)) return value;
|
|
869
|
+
if (deepFrozenValues.has(value)) return value;
|
|
774
870
|
if (seen.has(value)) return value;
|
|
775
871
|
seen.add(value);
|
|
776
872
|
for (const child of Object.values(value)) deepFreeze(child, seen);
|
|
873
|
+
deepFrozenValues.add(value);
|
|
777
874
|
return Object.freeze(value);
|
|
778
875
|
}
|
|
779
876
|
|
|
@@ -1052,11 +1149,25 @@ function shapeGeometryValues(shape) {
|
|
|
1052
1149
|
return undefined;
|
|
1053
1150
|
}
|
|
1054
1151
|
|
|
1152
|
+
/**
|
|
1153
|
+
* Bounded append. `push(...values)` passes every element as a call argument and
|
|
1154
|
+
* overflows the engine's argument limit around ~100k elements, surfacing as an
|
|
1155
|
+
* opaque RangeError from inside stepPhysicsWorld rather than a typed physics
|
|
1156
|
+
* error. Geometry is 3 values per element, so a 192x192 heightfield is enough.
|
|
1157
|
+
*/
|
|
1158
|
+
function appendGeometryValues(target, values) {
|
|
1159
|
+
const base = target.length;
|
|
1160
|
+
target.length = base + values.length;
|
|
1161
|
+
for (let index = 0; index < values.length; index += 1) {
|
|
1162
|
+
target[base + index] = values[index];
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1055
1166
|
function shapeFields(shape, geometryValues) {
|
|
1056
1167
|
const materialize = (cached) => {
|
|
1057
1168
|
if (cached.geometry !== undefined) {
|
|
1058
1169
|
const geometryOffset = geometryValues.length / 3;
|
|
1059
|
-
geometryValues
|
|
1170
|
+
appendGeometryValues(geometryValues, cached.geometry);
|
|
1060
1171
|
return Object.freeze([
|
|
1061
1172
|
cached.shapeKind,
|
|
1062
1173
|
geometryOffset,
|
|
@@ -1110,7 +1221,7 @@ function shapeFields(shape, geometryValues) {
|
|
|
1110
1221
|
}
|
|
1111
1222
|
if (geometry !== undefined) {
|
|
1112
1223
|
const geometryOffset = geometryValues.length / 3;
|
|
1113
|
-
geometryValues
|
|
1224
|
+
appendGeometryValues(geometryValues, geometry);
|
|
1114
1225
|
return Object.freeze([shapeKind, geometryOffset, geometry.length / 3, geometryFormat]);
|
|
1115
1226
|
}
|
|
1116
1227
|
return fields;
|
|
@@ -1434,7 +1545,10 @@ function nativeContactTelemetry(bodies) {
|
|
|
1434
1545
|
const bodyA = bodies[values[offset]];
|
|
1435
1546
|
const bodyB = bodies[values[offset + 1]];
|
|
1436
1547
|
if (bodyA === undefined || bodyB === undefined) continue;
|
|
1437
|
-
|
|
1548
|
+
// pairKey, not raw concatenation: cache membership now depends on these keys
|
|
1549
|
+
// matching JS pair keys, which today holds only because C++ normalizes pairs
|
|
1550
|
+
// with min/max on indices and createPhysicsWorld sorts bodies by id.
|
|
1551
|
+
telemetry.set(pairKey(bodyA, bodyB), {
|
|
1438
1552
|
normalImpulse: values[offset + 2],
|
|
1439
1553
|
tangentImpulse1: values[offset + 3],
|
|
1440
1554
|
tangentImpulse2: values[offset + 4],
|
|
@@ -2731,27 +2845,44 @@ export function stepPhysicsWorld(world, options) {
|
|
|
2731
2845
|
&& (bodyById.get(contact.a)?.kind === 'dynamic' || bodyById.get(contact.b)?.kind === 'dynamic'));
|
|
2732
2846
|
const preSolverContacts = preContacts.filter((contact) => !contact.trigger && !contact.sensor
|
|
2733
2847
|
&& (bodyById.get(contact.a)?.kind === 'dynamic' || bodyById.get(contact.b)?.kind === 'dynamic'));
|
|
2734
|
-
|
|
2735
|
-
|
|
2848
|
+
if (nativeTelemetry.size >= PHYSICS_CONTACT_TELEMETRY_CAP) {
|
|
2849
|
+
fail(`KINETIX_PHYSICS_CONTACT_TELEMETRY_SATURATED ${PHYSICS_CONTACT_TELEMETRY_CAP}`);
|
|
2850
|
+
}
|
|
2851
|
+
// The solver owns solid contacts: telemetry holds exactly the pairs it
|
|
2852
|
+
// resolved, so membership comes from there rather than a second JS narrowphase.
|
|
2853
|
+
// Sorted because contactCache is canonical world state and feeds checksums.
|
|
2854
|
+
const contactCache = [...nativeTelemetry.entries()]
|
|
2855
|
+
.filter(([pair]) => !disabledPairs.has(pair))
|
|
2856
|
+
.map(([pair, measured]) => ({
|
|
2857
|
+
pair,
|
|
2858
|
+
anchorX: measured.anchorX,
|
|
2859
|
+
anchorY: measured.anchorY,
|
|
2860
|
+
anchorZ: measured.anchorZ,
|
|
2861
|
+
normalImpulse: measured.normalImpulse,
|
|
2862
|
+
tangentImpulse1: measured.tangentImpulse1,
|
|
2863
|
+
tangentImpulse2: measured.tangentImpulse2,
|
|
2864
|
+
}))
|
|
2865
|
+
.sort((left, right) => (left.pair < right.pair ? -1 : left.pair > right.pair ? 1 : 0));
|
|
2866
|
+
// Events union telemetry with JS detection. Telemetry omits static-static and
|
|
2867
|
+
// both-asleep pairs, so filtering to it would drop their events and re-fire a
|
|
2868
|
+
// spurious `enter` on wake; JS alone misses solver contacts it did not detect.
|
|
2869
|
+
const contactByPair = new Map(currentContacts.map((contact) => [contact.pair, contact]));
|
|
2870
|
+
const reconciledContacts = [...currentContacts, ...contactCache
|
|
2871
|
+
.filter((entry) => !contactByPair.has(entry.pair))
|
|
2872
|
+
.map((entry) => {
|
|
2873
|
+
const [a, b] = entry.pair.split('|');
|
|
2874
|
+
return { pair: entry.pair, a, b, trigger: false, sensor: false };
|
|
2875
|
+
})]
|
|
2876
|
+
.sort((left, right) => (left.pair < right.pair ? -1 : left.pair > right.pair ? 1 : 0));
|
|
2877
|
+
const activePairs = reconciledContacts.map((contact) => contact.pair);
|
|
2878
|
+
const activePairMetadata = reconciledContacts.map((contact) => ({
|
|
2736
2879
|
pair: contact.pair,
|
|
2737
2880
|
a: contact.a,
|
|
2738
2881
|
b: contact.b,
|
|
2739
2882
|
trigger: contact.trigger,
|
|
2740
2883
|
sensor: contact.sensor,
|
|
2741
2884
|
}));
|
|
2742
|
-
const
|
|
2743
|
-
const measured = nativeTelemetry.get(contact.pair);
|
|
2744
|
-
return {
|
|
2745
|
-
pair: contact.pair,
|
|
2746
|
-
anchorX: measured?.anchorX ?? contact.point.x,
|
|
2747
|
-
anchorY: measured?.anchorY ?? contact.point.y,
|
|
2748
|
-
anchorZ: measured?.anchorZ ?? contact.point.z,
|
|
2749
|
-
normalImpulse: measured?.normalImpulse ?? 0,
|
|
2750
|
-
tangentImpulse1: measured?.tangentImpulse1 ?? 0,
|
|
2751
|
-
tangentImpulse2: measured?.tangentImpulse2 ?? 0,
|
|
2752
|
-
};
|
|
2753
|
-
});
|
|
2754
|
-
const events = currentContacts.map((contact) => ({
|
|
2885
|
+
const events = reconciledContacts.map((contact) => ({
|
|
2755
2886
|
type: world.activePairs.includes(contact.pair) ? 'stay' : 'enter',
|
|
2756
2887
|
pair: contact.pair,
|
|
2757
2888
|
a: contact.a,
|
|
@@ -2816,6 +2947,10 @@ export function stepPhysicsWorld(world, options) {
|
|
|
2816
2947
|
postPhysicsScheduledQueryResults,
|
|
2817
2948
|
broadphasePairs,
|
|
2818
2949
|
narrowphaseContacts: solverContacts.length,
|
|
2950
|
+
// Read from the solver's exported contact count rather than derived from
|
|
2951
|
+
// contactCache, so the two remain independently checkable and callers can
|
|
2952
|
+
// see how close a step is running to PHYSICS_CONTACT_TELEMETRY_CAP.
|
|
2953
|
+
contactTelemetryCount: nativeTelemetry.size,
|
|
2819
2954
|
solverCorrections,
|
|
2820
2955
|
checksum: stepOptions.computeChecksum ? worldChecksum({ nextWorld, events: visibleEvents, scheduledQueryResults, solverCorrections }) : '',
|
|
2821
2956
|
});
|
|
@@ -3167,6 +3302,8 @@ export function resetPhysics3DCache() {
|
|
|
3167
3302
|
staticGeometryCache.clear();
|
|
3168
3303
|
staticGeometryCacheBytes = 0;
|
|
3169
3304
|
staticGeometryIdentityCache = new WeakMap();
|
|
3305
|
+
deepFrozenValues = new WeakSet();
|
|
3306
|
+
shapeLocalBoundsCache = new WeakMap();
|
|
3170
3307
|
}
|
|
3171
3308
|
|
|
3172
3309
|
export const createDeterministicPhysicsWorld3D = createPhysicsWorld;
|