@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
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
#pragma once
|
|
2
|
+
|
|
3
|
+
#include <stdint.h>
|
|
4
|
+
|
|
5
|
+
#define RUNDOT_SYNCPLAY_NATIVE_PROBE_ABI_VERSION 1u
|
|
6
|
+
#define RUNDOT_SYNCPLAY_NATIVE_PROBE_SCENARIO_BOX3D_V1 1u
|
|
7
|
+
|
|
8
|
+
#define RUNDOT_SYNCPLAY_NATIVE_PROBE_STATUS_OK 0
|
|
9
|
+
#define RUNDOT_SYNCPLAY_NATIVE_PROBE_STATUS_INVALID_ARGUMENT (-1)
|
|
10
|
+
#define RUNDOT_SYNCPLAY_NATIVE_PROBE_STATUS_UNSUPPORTED_ABI (-2)
|
|
11
|
+
#define RUNDOT_SYNCPLAY_NATIVE_PROBE_STATUS_UNSUPPORTED_SCENARIO (-3)
|
|
12
|
+
#define RUNDOT_SYNCPLAY_NATIVE_PROBE_STATUS_INVALID_FRAMES (-4)
|
|
13
|
+
#define RUNDOT_SYNCPLAY_NATIVE_PROBE_STATUS_BUSY (-5)
|
|
14
|
+
#define RUNDOT_SYNCPLAY_NATIVE_PROBE_STATUS_STEP_FAILED (-6)
|
|
15
|
+
#define RUNDOT_SYNCPLAY_NATIVE_PROBE_STATUS_INTERNAL (-7)
|
|
16
|
+
|
|
17
|
+
#ifdef __cplusplus
|
|
18
|
+
extern "C" {
|
|
19
|
+
#endif
|
|
20
|
+
|
|
21
|
+
typedef struct {
|
|
22
|
+
uint32_t abi_version;
|
|
23
|
+
uint32_t scenario_id;
|
|
24
|
+
uint32_t frames;
|
|
25
|
+
} RundotSyncplayNativeProbeInput;
|
|
26
|
+
|
|
27
|
+
typedef struct {
|
|
28
|
+
uint32_t abi_version;
|
|
29
|
+
int32_t status;
|
|
30
|
+
uint32_t completed_frames;
|
|
31
|
+
uint64_t elapsed_ns;
|
|
32
|
+
uint64_t final_checksum;
|
|
33
|
+
} RundotSyncplayNativeProbeOutput;
|
|
34
|
+
|
|
35
|
+
int32_t rundot_syncplay_native_probe_run(
|
|
36
|
+
const RundotSyncplayNativeProbeInput* input,
|
|
37
|
+
RundotSyncplayNativeProbeOutput* output);
|
|
38
|
+
|
|
39
|
+
#ifdef __cplusplus
|
|
40
|
+
}
|
|
41
|
+
#endif
|
package/core/physics.d.ts
CHANGED
|
@@ -281,6 +281,13 @@ export interface KinetixPhysicsStepResult {
|
|
|
281
281
|
readonly postPhysicsScheduledQueryResults: readonly { readonly id: string; readonly hits: readonly KinetixPhysicsHit[] }[];
|
|
282
282
|
readonly broadphasePairs: number;
|
|
283
283
|
readonly narrowphaseContacts: number;
|
|
284
|
+
/**
|
|
285
|
+
* Contacts the solver reported this step, read from its exported count rather
|
|
286
|
+
* than derived from `world.contactCache`. Watch it against
|
|
287
|
+
* `PHYSICS_CONTACT_TELEMETRY_CAP` for budget headroom before saturation
|
|
288
|
+
* becomes `KINETIX_PHYSICS_CONTACT_TELEMETRY_SATURATED`.
|
|
289
|
+
*/
|
|
290
|
+
readonly contactTelemetryCount: number;
|
|
284
291
|
readonly solverCorrections: number;
|
|
285
292
|
readonly checksum: string;
|
|
286
293
|
}
|
|
@@ -300,6 +307,47 @@ export interface KinetixPhysicsRecording {
|
|
|
300
307
|
readonly finalChecksum: string;
|
|
301
308
|
}
|
|
302
309
|
|
|
310
|
+
/**
|
|
311
|
+
* Maximum flat geometry elements permitted on a single shape. A tripwire
|
|
312
|
+
* against obviously-wrong input, not a performance target — see
|
|
313
|
+
* SYNCPLAY-PHYSICS.md for the practical per-step budget, which is far lower.
|
|
314
|
+
* Exceeding it fails with `KINETIX_PHYSICS_SHAPE_GEOMETRY_TOO_LARGE <id> <n>`.
|
|
315
|
+
*/
|
|
316
|
+
export const PHYSICS_MAX_SHAPE_GEOMETRY_ELEMENTS: number;
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Contacts past this count are silently discarded by the C++ core, so the
|
|
320
|
+
* exported contact count saturates here and the bridge raises
|
|
321
|
+
* `KINETIX_PHYSICS_CONTACT_TELEMETRY_SATURATED` rather than returning a
|
|
322
|
+
* truncated cache.
|
|
323
|
+
*/
|
|
324
|
+
export const PHYSICS_CONTACT_TELEMETRY_CAP: number;
|
|
325
|
+
|
|
326
|
+
export interface KinetixPhysicsContactBudget {
|
|
327
|
+
readonly limit: number;
|
|
328
|
+
readonly onOverflow: string;
|
|
329
|
+
readonly signal: string | null;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** Post-step contact semantics, published for other runtimes of this core. */
|
|
333
|
+
export interface KinetixPhysicsContactContract {
|
|
334
|
+
readonly entry: readonly string[];
|
|
335
|
+
readonly ordering: string;
|
|
336
|
+
readonly authority: {
|
|
337
|
+
readonly solid: 'cpp-telemetry';
|
|
338
|
+
readonly triggerOrSensor: 'js-detect-contacts';
|
|
339
|
+
readonly note: string;
|
|
340
|
+
};
|
|
341
|
+
readonly warmStart: string;
|
|
342
|
+
readonly budgets: {
|
|
343
|
+
readonly contactTelemetry: KinetixPhysicsContactBudget;
|
|
344
|
+
readonly broadphasePairs: KinetixPhysicsContactBudget;
|
|
345
|
+
};
|
|
346
|
+
readonly errors: readonly string[];
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
export const PHYSICS_CONTACT_CONTRACT: KinetixPhysicsContactContract;
|
|
350
|
+
|
|
303
351
|
export function initPhysics(): Promise<void>;
|
|
304
352
|
export function physicsReady(): boolean;
|
|
305
353
|
export const physicsWasmMetadata: Readonly<{
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// Declares the exact portable C++ closure consumed by the standalone mobile
|
|
2
|
+
// probe. This script is intentionally a manifest builder, not a native build
|
|
3
|
+
// system: Xcode/CMake owns platform linking, while this list keeps both
|
|
4
|
+
// bindings on the same reviewed source bytes.
|
|
5
|
+
import { createHash } from 'node:crypto';
|
|
6
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
7
|
+
import { dirname, join, relative, resolve } from 'node:path';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
|
|
10
|
+
const packageRoot = resolve(fileURLToPath(new URL('../..', import.meta.url)));
|
|
11
|
+
const nativeRoot = join(packageRoot, 'core', 'native');
|
|
12
|
+
|
|
13
|
+
export const MOBILE_NATIVE_FILES = Object.freeze([
|
|
14
|
+
'mobile/syncplay_native_probe_abi.h',
|
|
15
|
+
'mobile/syncplay_native_probe.cpp',
|
|
16
|
+
'mobile/box3d_probe_scenario.hpp',
|
|
17
|
+
'mobile/box3d_probe_scenario.cpp',
|
|
18
|
+
'physics/physics.hpp',
|
|
19
|
+
'physics/physics.cpp',
|
|
20
|
+
'physics/abi.hpp',
|
|
21
|
+
'physics/abi.cpp',
|
|
22
|
+
'deterministic_math.hpp',
|
|
23
|
+
'deterministic_math.cpp',
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
const sourceExtensions = new Set(['.cpp', '.hpp', '.h']);
|
|
27
|
+
|
|
28
|
+
function fileDigest(bytes) {
|
|
29
|
+
return createHash('sha256').update(bytes).digest('hex');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function buildManifest() {
|
|
33
|
+
const files = MOBILE_NATIVE_FILES.map((relativePath) => {
|
|
34
|
+
if (relativePath.includes('..') || relativePath.startsWith('/')) {
|
|
35
|
+
throw new Error(`SYNCPLAY_MOBILE_NATIVE_PATH_INVALID ${relativePath}`);
|
|
36
|
+
}
|
|
37
|
+
const absolutePath = join(nativeRoot, relativePath);
|
|
38
|
+
if (!existsSync(absolutePath)) throw new Error(`SYNCPLAY_MOBILE_NATIVE_FILE_MISSING ${relativePath}`);
|
|
39
|
+
const bytes = readFileSync(absolutePath);
|
|
40
|
+
const extension = relativePath.slice(relativePath.lastIndexOf('.'));
|
|
41
|
+
if (!sourceExtensions.has(extension)) throw new Error(`SYNCPLAY_MOBILE_NATIVE_FILE_TYPE_INVALID ${relativePath}`);
|
|
42
|
+
return {
|
|
43
|
+
path: `core/native/${relativePath}`,
|
|
44
|
+
byteLength: bytes.byteLength,
|
|
45
|
+
sha256: fileDigest(bytes),
|
|
46
|
+
};
|
|
47
|
+
});
|
|
48
|
+
const hash = createHash('sha256');
|
|
49
|
+
for (const file of files) {
|
|
50
|
+
hash.update(file.path);
|
|
51
|
+
hash.update('\0');
|
|
52
|
+
hash.update(file.sha256);
|
|
53
|
+
hash.update('\0');
|
|
54
|
+
}
|
|
55
|
+
return {
|
|
56
|
+
schemaVersion: 1,
|
|
57
|
+
package: '@series-inc/rundot-syncplay',
|
|
58
|
+
sourceRoot: 'core/native',
|
|
59
|
+
files,
|
|
60
|
+
sourceDigest: hash.digest('hex'),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function getMobileNativeSourceManifest() {
|
|
65
|
+
return buildManifest();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function getMobileNativeSourcePaths() {
|
|
69
|
+
return MOBILE_NATIVE_FILES.map((relativePath) => join(nativeRoot, relativePath));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function writeMobileNativeSourceManifest(outputPath) {
|
|
73
|
+
const manifest = buildManifest();
|
|
74
|
+
mkdirSync(dirname(outputPath), { recursive: true });
|
|
75
|
+
writeFileSync(outputPath, `${JSON.stringify(manifest, null, 2)}\n`);
|
|
76
|
+
return manifest;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function argumentValue(name) {
|
|
80
|
+
const index = process.argv.indexOf(name);
|
|
81
|
+
return index === -1 ? undefined : process.argv[index + 1];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function invokedDirectly() {
|
|
85
|
+
const entrypoint = process.argv[1];
|
|
86
|
+
if (!entrypoint) return false;
|
|
87
|
+
return resolve(entrypoint) === resolve(fileURLToPath(import.meta.url));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (invokedDirectly()) {
|
|
91
|
+
const manifest = buildManifest();
|
|
92
|
+
const outputPath = argumentValue('--manifest');
|
|
93
|
+
if (outputPath) writeMobileNativeSourceManifest(resolve(outputPath));
|
|
94
|
+
if (process.argv.includes('--check')) {
|
|
95
|
+
console.log(`SYNCPLAY_MOBILE_NATIVE_SOURCE_OK digest=${manifest.sourceDigest}`);
|
|
96
|
+
} else {
|
|
97
|
+
console.log(JSON.stringify(manifest, null, 2));
|
|
98
|
+
}
|
|
99
|
+
}
|
|
@@ -14,11 +14,25 @@ const SOURCES = [
|
|
|
14
14
|
'kinetix-render-projection.cpp',
|
|
15
15
|
'component_runtime.cpp',
|
|
16
16
|
'deterministic_math.cpp',
|
|
17
|
+
'fixed_math.cpp',
|
|
17
18
|
'physics/physics.cpp',
|
|
18
19
|
'physics/abi.cpp',
|
|
20
|
+
'mobile/box3d_probe_scenario.cpp',
|
|
19
21
|
'kinetix-native-main.cpp',
|
|
20
22
|
];
|
|
21
23
|
|
|
24
|
+
// Headers are compiled into the binary too, but were never fingerprinted: a
|
|
25
|
+
// header-only edit reused a stale cached binary, so the parity gates below
|
|
26
|
+
// would have tested the wrong thing.
|
|
27
|
+
const FINGERPRINT_FILES = [
|
|
28
|
+
...SOURCES,
|
|
29
|
+
'mobile/box3d_probe_scenario.hpp',
|
|
30
|
+
'fixed_math.hpp',
|
|
31
|
+
'deterministic_math.hpp',
|
|
32
|
+
'physics/physics.hpp',
|
|
33
|
+
'physics/abi.hpp',
|
|
34
|
+
];
|
|
35
|
+
|
|
22
36
|
// -ffp-contract=off is load-bearing, not stylistic: it forbids the compiler from
|
|
23
37
|
// fusing a*b+c into an FMA, which would change f64 results and break JS<->C++
|
|
24
38
|
// determinism. Do not drop it when adding a compiler.
|
|
@@ -48,7 +62,7 @@ function buildFingerprint(compiler, version) {
|
|
|
48
62
|
hash.update(version);
|
|
49
63
|
hash.update('\0');
|
|
50
64
|
hash.update(FLAGS.join(' '));
|
|
51
|
-
for (const source of
|
|
65
|
+
for (const source of FINGERPRINT_FILES) {
|
|
52
66
|
hash.update('\0');
|
|
53
67
|
hash.update(source);
|
|
54
68
|
hash.update(readFileSync(join(root, 'native', source)));
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// Mirrors build-native.mjs: probe, pin flags, fingerprint inputs, cache under
|
|
2
|
+
// core/build/. Nothing is committed, so there is no drift gate to maintain.
|
|
3
|
+
import { spawnSync } from 'node:child_process';
|
|
4
|
+
import { createHash } from 'node:crypto';
|
|
5
|
+
import { existsSync, mkdirSync, readFileSync } from 'node:fs';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
|
|
8
|
+
const root = join(import.meta.dirname, '..');
|
|
9
|
+
const outDir = join(root, 'build');
|
|
10
|
+
|
|
11
|
+
const SOURCES = [
|
|
12
|
+
join(root, 'bench', 'fixed-math-wasm.cpp'),
|
|
13
|
+
join(root, 'native', 'fixed_math.cpp'),
|
|
14
|
+
];
|
|
15
|
+
const FINGERPRINT_FILES = [...SOURCES, join(root, 'native', 'fixed_math.hpp')];
|
|
16
|
+
|
|
17
|
+
// -ffp-contract=off is load-bearing for determinism. -flto is load-bearing for
|
|
18
|
+
// speed: without it the kernel cannot inline across the shim boundary and the
|
|
19
|
+
// lane measures roughly 2x slower for no semantic reason.
|
|
20
|
+
const FLAGS = ['-std=c++20', '-O3', '-flto', '-ffp-contract=off'];
|
|
21
|
+
|
|
22
|
+
function emccVersion() {
|
|
23
|
+
const probe = spawnSync('emcc', ['--version'], { encoding: 'utf8' });
|
|
24
|
+
return probe.status === 0 ? (probe.stdout ?? '') : null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function fixedMathWasmAvailable() {
|
|
28
|
+
return emccVersion() !== null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function buildFixedMathWasm() {
|
|
32
|
+
const version = emccVersion();
|
|
33
|
+
if (version === null) throw new Error('KINETIX_BENCH_NO_EMCC');
|
|
34
|
+
|
|
35
|
+
const hash = createHash('sha256');
|
|
36
|
+
hash.update(FLAGS.join(' '));
|
|
37
|
+
hash.update('\0');
|
|
38
|
+
hash.update(version);
|
|
39
|
+
for (const file of FINGERPRINT_FILES) {
|
|
40
|
+
hash.update('\0');
|
|
41
|
+
hash.update(readFileSync(file));
|
|
42
|
+
}
|
|
43
|
+
const output = join(outDir, `fixed-math-${hash.digest('hex').slice(0, 16)}.wasm`);
|
|
44
|
+
if (existsSync(output)) return output;
|
|
45
|
+
|
|
46
|
+
mkdirSync(outDir, { recursive: true });
|
|
47
|
+
const build = spawnSync('emcc', [
|
|
48
|
+
...FLAGS,
|
|
49
|
+
`-I${join(root, 'native')}`,
|
|
50
|
+
...SOURCES,
|
|
51
|
+
'--no-entry',
|
|
52
|
+
'-sSTANDALONE_WASM=1',
|
|
53
|
+
'-sEXPORTED_FUNCTIONS=["_bench_fixed_kernel"]',
|
|
54
|
+
'-o', output,
|
|
55
|
+
], { encoding: 'utf8' });
|
|
56
|
+
if (build.status !== 0) throw new Error(`KINETIX_BENCH_WASM_BUILD_FAILED ${build.stderr}`);
|
|
57
|
+
return output;
|
|
58
|
+
}
|
|
@@ -7,6 +7,14 @@ import { performance } from 'node:perf_hooks';
|
|
|
7
7
|
import { buildNative } from './build-native.mjs';
|
|
8
8
|
import { createPhysicsWorld, initPhysics, stepPhysicsWorld } from '../src/physics/index.mjs';
|
|
9
9
|
import { physicsWasmModuleBase64 } from '../src/physics/physics-wasm-module.generated.mjs';
|
|
10
|
+
import {
|
|
11
|
+
benchmarkBodyOrder as probeBenchmarkBodyOrder,
|
|
12
|
+
bridgeBodyFields as probeBridgeBodyFields,
|
|
13
|
+
BOX3D_PROBE_STEP_OPTIONS,
|
|
14
|
+
commonBodyFields as probeCommonBodyFields,
|
|
15
|
+
hashFloat32Values as probeHashFloat32Values,
|
|
16
|
+
makeBodies as probeMakeBodies,
|
|
17
|
+
} from '../src/physics/box3d-probe.mjs';
|
|
10
18
|
|
|
11
19
|
const packageRoot = resolve(import.meta.dirname, '..');
|
|
12
20
|
const benchmarkSource = join(packageRoot, 'bench', 'box3d-benchmark.c');
|
|
@@ -88,15 +96,7 @@ function hashFloat64Values(values) {
|
|
|
88
96
|
}
|
|
89
97
|
|
|
90
98
|
function hashFloat32Values(values) {
|
|
91
|
-
|
|
92
|
-
let hash = 1469598103934665603n;
|
|
93
|
-
const prime = 1099511628211n;
|
|
94
|
-
for (const value of values) {
|
|
95
|
-
view.setFloat32(0, value, true);
|
|
96
|
-
hash ^= BigInt(view.getUint32(0, true));
|
|
97
|
-
hash = BigInt.asUintN(64, hash * prime);
|
|
98
|
-
}
|
|
99
|
-
return hash.toString(16).padStart(16, '0');
|
|
99
|
+
return probeHashFloat32Values(values);
|
|
100
100
|
}
|
|
101
101
|
|
|
102
102
|
function parseBenchmarkOutput(output, label) {
|
|
@@ -134,99 +134,19 @@ function summarizeSamples(samples, label) {
|
|
|
134
134
|
}
|
|
135
135
|
|
|
136
136
|
function makeBodies(bodyCount) {
|
|
137
|
-
|
|
138
|
-
id: 'floor',
|
|
139
|
-
kind: 'static',
|
|
140
|
-
x: 0,
|
|
141
|
-
y: -32.5,
|
|
142
|
-
z: 0,
|
|
143
|
-
vx: 0,
|
|
144
|
-
vy: 0,
|
|
145
|
-
vz: 0,
|
|
146
|
-
linearDamping: 1,
|
|
147
|
-
angularDamping: 1,
|
|
148
|
-
shape: { type: 'sphere', radius: 32.5 },
|
|
149
|
-
layer: 1,
|
|
150
|
-
mask: 1,
|
|
151
|
-
}];
|
|
152
|
-
for (let index = 1; index < bodyCount; index += 1) {
|
|
153
|
-
const column = (index - 1) % 16;
|
|
154
|
-
const row = Math.floor((index - 1) / 16);
|
|
155
|
-
bodies.push({
|
|
156
|
-
id: `body-${String(index).padStart(6, '0')}`,
|
|
157
|
-
kind: 'dynamic',
|
|
158
|
-
x: column * 1.05 - 7.875,
|
|
159
|
-
y: 0.5 + row * 1.05,
|
|
160
|
-
z: 0,
|
|
161
|
-
vx: column % 2 === 0 ? 0.1 : -0.1,
|
|
162
|
-
vy: 0,
|
|
163
|
-
vz: 0,
|
|
164
|
-
linearDamping: 1,
|
|
165
|
-
angularDamping: 1,
|
|
166
|
-
// Match the Box3D harness (density=1.0, solid sphere r=0.5): mass = pi/6,
|
|
167
|
-
// isotropic inertia = 0.1*mass. Math.PI/6 rounds to the identical double as
|
|
168
|
-
// the native scene's pi/6, so all Kinetix lanes stay bit-identical.
|
|
169
|
-
mass: Math.PI / 6,
|
|
170
|
-
inertia: { xx: 0.1 * (Math.PI / 6), yy: 0.1 * (Math.PI / 6), zz: 0.1 * (Math.PI / 6) },
|
|
171
|
-
shape: { type: 'sphere', radius: 0.5 },
|
|
172
|
-
layer: 1,
|
|
173
|
-
mask: 1,
|
|
174
|
-
});
|
|
175
|
-
}
|
|
176
|
-
return bodies;
|
|
137
|
+
return probeMakeBodies(bodyCount);
|
|
177
138
|
}
|
|
178
139
|
|
|
179
140
|
function bridgeBodyFields(body) {
|
|
180
|
-
|
|
181
|
-
fields[0] = body.x;
|
|
182
|
-
fields[1] = body.y;
|
|
183
|
-
fields[2] = body.z;
|
|
184
|
-
fields[3] = body.vx;
|
|
185
|
-
fields[4] = body.vy;
|
|
186
|
-
fields[5] = body.vz;
|
|
187
|
-
fields[6] = body.shape.radius;
|
|
188
|
-
fields[7] = 0;
|
|
189
|
-
fields[8] = 0;
|
|
190
|
-
fields[9] = body.kind === 'dynamic' ? 1 : 0;
|
|
191
|
-
fields[10] = 1;
|
|
192
|
-
fields[11] = body.layer;
|
|
193
|
-
fields[12] = body.mask;
|
|
194
|
-
fields[13] = body.gravityScale ?? 1;
|
|
195
|
-
fields[14] = body.linearDamping ?? 1;
|
|
196
|
-
fields[18] = body.mass ?? 1;
|
|
197
|
-
fields[25] = 1;
|
|
198
|
-
fields[26] = body.angularDamping ?? 1;
|
|
199
|
-
fields[27] = body.friction ?? 0.6;
|
|
200
|
-
fields[31] = body.inertia?.xx ?? 1;
|
|
201
|
-
fields[32] = body.inertia?.yy ?? 1;
|
|
202
|
-
fields[33] = body.inertia?.zz ?? 1;
|
|
203
|
-
return fields;
|
|
141
|
+
return probeBridgeBodyFields(body);
|
|
204
142
|
}
|
|
205
143
|
|
|
206
144
|
function commonBodyFields(body) {
|
|
207
|
-
return
|
|
208
|
-
body.x,
|
|
209
|
-
body.y,
|
|
210
|
-
body.z,
|
|
211
|
-
body.vx,
|
|
212
|
-
body.vy,
|
|
213
|
-
body.vz,
|
|
214
|
-
body.angularVel?.x ?? 0,
|
|
215
|
-
body.angularVel?.y ?? 0,
|
|
216
|
-
body.angularVel?.z ?? 0,
|
|
217
|
-
body.orientation?.x ?? 0,
|
|
218
|
-
body.orientation?.y ?? 0,
|
|
219
|
-
body.orientation?.z ?? 0,
|
|
220
|
-
body.orientation?.w ?? 1,
|
|
221
|
-
];
|
|
145
|
+
return probeCommonBodyFields(body);
|
|
222
146
|
}
|
|
223
147
|
|
|
224
148
|
function benchmarkBodyOrder(bodies) {
|
|
225
|
-
return
|
|
226
|
-
if (left.id === 'floor') return -1;
|
|
227
|
-
if (right.id === 'floor') return 1;
|
|
228
|
-
return left.id < right.id ? -1 : left.id > right.id ? 1 : 0;
|
|
229
|
-
});
|
|
149
|
+
return probeBenchmarkBodyOrder(bodies);
|
|
230
150
|
}
|
|
231
151
|
|
|
232
152
|
async function runWasm(bodyCount, frameCount) {
|
|
@@ -236,10 +156,7 @@ async function runWasm(bodyCount, frameCount) {
|
|
|
236
156
|
const started = performance.now();
|
|
237
157
|
for (let frame = 0; frame < frameCount; frame += 1) {
|
|
238
158
|
world = stepPhysicsWorld(world, {
|
|
239
|
-
|
|
240
|
-
dtTicks: 1 / 60,
|
|
241
|
-
substeps: 4,
|
|
242
|
-
velocityIterations: 4,
|
|
159
|
+
...BOX3D_PROBE_STEP_OPTIONS,
|
|
243
160
|
computeChecksum: false,
|
|
244
161
|
}).world;
|
|
245
162
|
}
|
|
@@ -264,29 +181,10 @@ async function runWasmCore(bodyCount, frameCount) {
|
|
|
264
181
|
const bodies = makeBodies(bodyCount);
|
|
265
182
|
bodies.forEach((body, index) => {
|
|
266
183
|
const offset = index * 44;
|
|
267
|
-
const
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
data[offset + 3] = body.vx;
|
|
272
|
-
data[offset + 4] = body.vy;
|
|
273
|
-
data[offset + 5] = body.vz;
|
|
274
|
-
data[offset + 6] = shape.radius;
|
|
275
|
-
data[offset + 7] = 0;
|
|
276
|
-
data[offset + 8] = 0;
|
|
277
|
-
data[offset + 9] = body.kind === 'dynamic' ? 1 : 0;
|
|
278
|
-
data[offset + 10] = 1;
|
|
279
|
-
data[offset + 11] = body.layer;
|
|
280
|
-
data[offset + 12] = body.mask;
|
|
281
|
-
data[offset + 13] = 1;
|
|
282
|
-
data[offset + 14] = 1;
|
|
283
|
-
data[offset + 18] = body.mass ?? 1;
|
|
284
|
-
data[offset + 25] = 1;
|
|
285
|
-
data[offset + 26] = 1;
|
|
286
|
-
data[offset + 27] = 0.6;
|
|
287
|
-
data[offset + 31] = body.inertia?.xx ?? 1;
|
|
288
|
-
data[offset + 32] = body.inertia?.yy ?? 1;
|
|
289
|
-
data[offset + 33] = body.inertia?.zz ?? 1;
|
|
184
|
+
const fields = bridgeBodyFields(body);
|
|
185
|
+
for (let field = 0; field < fields.length; field += 1) {
|
|
186
|
+
data[offset + field] = fields[field];
|
|
187
|
+
}
|
|
290
188
|
});
|
|
291
189
|
exports.rundot_kinetix_physics_set_geometry(0, 0);
|
|
292
190
|
const started = performance.now();
|
|
@@ -294,10 +192,10 @@ async function runWasmCore(bodyCount, frameCount) {
|
|
|
294
192
|
const status = exports.rundot_kinetix_physics_step(
|
|
295
193
|
arenaOffset,
|
|
296
194
|
bodyCount,
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
195
|
+
BOX3D_PROBE_STEP_OPTIONS.gravityY,
|
|
196
|
+
BOX3D_PROBE_STEP_OPTIONS.dtTicks,
|
|
197
|
+
BOX3D_PROBE_STEP_OPTIONS.substeps,
|
|
198
|
+
BOX3D_PROBE_STEP_OPTIONS.velocityIterations,
|
|
301
199
|
);
|
|
302
200
|
if (status !== 0) throw new Error(`KINETIX_WASM_CORE_BENCHMARK_STEP_FAILED status=${status}`);
|
|
303
201
|
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { physicsWasmModuleBase64 } from './physics-wasm-module.generated.mjs';
|
|
2
|
+
|
|
3
|
+
export const BOX3D_PROBE_ABI_VERSION = 1;
|
|
4
|
+
export const BOX3D_PROBE_SCENARIO_ID = 1;
|
|
5
|
+
export const BOX3D_PROBE_BODY_COUNT = 128;
|
|
6
|
+
export const BOX3D_PROBE_SMOKE_FRAMES = 120;
|
|
7
|
+
export const BOX3D_PROBE_PERF_FRAMES = 1200;
|
|
8
|
+
export const BOX3D_PROBE_BODY_STRIDE = 44;
|
|
9
|
+
|
|
10
|
+
export const BOX3D_PROBE_STEP_OPTIONS = Object.freeze({
|
|
11
|
+
gravityY: -9.8,
|
|
12
|
+
dtTicks: 1 / 60,
|
|
13
|
+
substeps: 4,
|
|
14
|
+
velocityIterations: 4,
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
export function makeBodies(bodyCount = BOX3D_PROBE_BODY_COUNT) {
|
|
18
|
+
if (!Number.isSafeInteger(bodyCount) || bodyCount < 2 || bodyCount > 100_000) {
|
|
19
|
+
throw new Error('SYNCPLAY_BOX3D_BODY_COUNT_INVALID');
|
|
20
|
+
}
|
|
21
|
+
const bodies = [{
|
|
22
|
+
id: 'floor',
|
|
23
|
+
kind: 'static',
|
|
24
|
+
x: 0,
|
|
25
|
+
y: -32.5,
|
|
26
|
+
z: 0,
|
|
27
|
+
vx: 0,
|
|
28
|
+
vy: 0,
|
|
29
|
+
vz: 0,
|
|
30
|
+
linearDamping: 1,
|
|
31
|
+
angularDamping: 1,
|
|
32
|
+
shape: { type: 'sphere', radius: 32.5 },
|
|
33
|
+
layer: 1,
|
|
34
|
+
mask: 1,
|
|
35
|
+
}];
|
|
36
|
+
for (let index = 1; index < bodyCount; index += 1) {
|
|
37
|
+
const column = (index - 1) % 16;
|
|
38
|
+
const row = Math.floor((index - 1) / 16);
|
|
39
|
+
const sphereMass = Math.PI / 6;
|
|
40
|
+
bodies.push({
|
|
41
|
+
id: `body-${String(index).padStart(6, '0')}`,
|
|
42
|
+
kind: 'dynamic',
|
|
43
|
+
x: column * 1.05 - 7.875,
|
|
44
|
+
y: 0.5 + row * 1.05,
|
|
45
|
+
z: 0,
|
|
46
|
+
vx: column % 2 === 0 ? 0.1 : -0.1,
|
|
47
|
+
vy: 0,
|
|
48
|
+
vz: 0,
|
|
49
|
+
linearDamping: 1,
|
|
50
|
+
angularDamping: 1,
|
|
51
|
+
mass: sphereMass,
|
|
52
|
+
inertia: { xx: 0.1 * sphereMass, yy: 0.1 * sphereMass, zz: 0.1 * sphereMass },
|
|
53
|
+
shape: { type: 'sphere', radius: 0.5 },
|
|
54
|
+
layer: 1,
|
|
55
|
+
mask: 1,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
return bodies;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function bridgeBodyFields(body) {
|
|
62
|
+
const fields = new Array(BOX3D_PROBE_BODY_STRIDE).fill(0);
|
|
63
|
+
fields[0] = body.x;
|
|
64
|
+
fields[1] = body.y;
|
|
65
|
+
fields[2] = body.z;
|
|
66
|
+
fields[3] = body.vx;
|
|
67
|
+
fields[4] = body.vy;
|
|
68
|
+
fields[5] = body.vz;
|
|
69
|
+
fields[6] = body.shape.radius;
|
|
70
|
+
fields[9] = body.kind === 'dynamic' ? 1 : 0;
|
|
71
|
+
fields[10] = 1;
|
|
72
|
+
fields[11] = body.layer;
|
|
73
|
+
fields[12] = body.mask;
|
|
74
|
+
fields[13] = body.gravityScale ?? 1;
|
|
75
|
+
fields[14] = body.linearDamping ?? 1;
|
|
76
|
+
fields[18] = body.mass ?? 1;
|
|
77
|
+
fields[25] = 1;
|
|
78
|
+
fields[26] = body.angularDamping ?? 1;
|
|
79
|
+
fields[27] = body.friction ?? 0.6;
|
|
80
|
+
fields[31] = body.inertia?.xx ?? 1;
|
|
81
|
+
fields[32] = body.inertia?.yy ?? 1;
|
|
82
|
+
fields[33] = body.inertia?.zz ?? 1;
|
|
83
|
+
return fields;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function commonBodyFields(body) {
|
|
87
|
+
return [
|
|
88
|
+
body.x,
|
|
89
|
+
body.y,
|
|
90
|
+
body.z,
|
|
91
|
+
body.vx,
|
|
92
|
+
body.vy,
|
|
93
|
+
body.vz,
|
|
94
|
+
body.angularVel?.x ?? 0,
|
|
95
|
+
body.angularVel?.y ?? 0,
|
|
96
|
+
body.angularVel?.z ?? 0,
|
|
97
|
+
body.orientation?.x ?? 0,
|
|
98
|
+
body.orientation?.y ?? 0,
|
|
99
|
+
body.orientation?.z ?? 0,
|
|
100
|
+
body.orientation?.w ?? 1,
|
|
101
|
+
];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function benchmarkBodyOrder(bodies) {
|
|
105
|
+
return [...bodies].sort((left, right) => {
|
|
106
|
+
if (left.id === 'floor') return -1;
|
|
107
|
+
if (right.id === 'floor') return 1;
|
|
108
|
+
return left.id < right.id ? -1 : left.id > right.id ? 1 : 0;
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function hashFloat32Values(values) {
|
|
113
|
+
const view = new DataView(new ArrayBuffer(Float32Array.BYTES_PER_ELEMENT));
|
|
114
|
+
let hash = 1469598103934665603n;
|
|
115
|
+
const prime = 1099511628211n;
|
|
116
|
+
for (const value of values) {
|
|
117
|
+
view.setFloat32(0, value, true);
|
|
118
|
+
hash ^= BigInt(view.getUint32(0, true));
|
|
119
|
+
hash = BigInt.asUintN(64, hash * prime);
|
|
120
|
+
}
|
|
121
|
+
return hash.toString(16).padStart(16, '0');
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export async function computeBox3dOracleChecksum(frameCount) {
|
|
125
|
+
if (!Number.isSafeInteger(frameCount) || frameCount < 1 || frameCount > BOX3D_PROBE_PERF_FRAMES) {
|
|
126
|
+
throw new Error('SYNCPLAY_BOX3D_FRAME_COUNT_INVALID');
|
|
127
|
+
}
|
|
128
|
+
const decodeBase64 = (value) => {
|
|
129
|
+
if (typeof atob === 'function') {
|
|
130
|
+
const decoded = atob(value);
|
|
131
|
+
const bytes = new Uint8Array(decoded.length);
|
|
132
|
+
for (let index = 0; index < decoded.length; index += 1) bytes[index] = decoded.charCodeAt(index);
|
|
133
|
+
return bytes;
|
|
134
|
+
}
|
|
135
|
+
if (typeof Buffer !== 'undefined') return Uint8Array.from(Buffer.from(value, 'base64'));
|
|
136
|
+
throw new Error('SYNCPLAY_BOX3D_ORACLE_BASE64_UNAVAILABLE');
|
|
137
|
+
};
|
|
138
|
+
const instantiated = await WebAssembly.instantiate(decodeBase64(physicsWasmModuleBase64), {});
|
|
139
|
+
const exports = instantiated instanceof WebAssembly.Instance ? instantiated.exports : instantiated.instance.exports;
|
|
140
|
+
const bodyCount = BOX3D_PROBE_BODY_COUNT;
|
|
141
|
+
const bodyBytes = bodyCount * BOX3D_PROBE_BODY_STRIDE * Float64Array.BYTES_PER_ELEMENT;
|
|
142
|
+
const arenaOffset = Math.ceil((exports.emscripten_stack_get_current() + 64) / 16) * 16;
|
|
143
|
+
if (arenaOffset + bodyBytes > exports.memory.buffer.byteLength) {
|
|
144
|
+
exports.memory.grow(Math.ceil((arenaOffset + bodyBytes - exports.memory.buffer.byteLength) / 65_536));
|
|
145
|
+
}
|
|
146
|
+
const data = new Float64Array(exports.memory.buffer, arenaOffset, bodyCount * BOX3D_PROBE_BODY_STRIDE);
|
|
147
|
+
makeBodies().forEach((body, index) => {
|
|
148
|
+
const fields = bridgeBodyFields(body);
|
|
149
|
+
for (let field = 0; field < fields.length; field += 1) {
|
|
150
|
+
data[index * BOX3D_PROBE_BODY_STRIDE + field] = fields[field];
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
exports.rundot_kinetix_physics_set_geometry(0, 0);
|
|
154
|
+
for (let frame = 0; frame < frameCount; frame += 1) {
|
|
155
|
+
const status = exports.rundot_kinetix_physics_step(
|
|
156
|
+
arenaOffset,
|
|
157
|
+
bodyCount,
|
|
158
|
+
BOX3D_PROBE_STEP_OPTIONS.gravityY,
|
|
159
|
+
BOX3D_PROBE_STEP_OPTIONS.dtTicks,
|
|
160
|
+
BOX3D_PROBE_STEP_OPTIONS.substeps,
|
|
161
|
+
BOX3D_PROBE_STEP_OPTIONS.velocityIterations,
|
|
162
|
+
);
|
|
163
|
+
if (status !== 0) throw new Error(`SYNCPLAY_BOX3D_ORACLE_STEP_FAILED ${status}`);
|
|
164
|
+
}
|
|
165
|
+
const fields = [];
|
|
166
|
+
for (let bodyIndex = 1; bodyIndex < bodyCount; bodyIndex += 1) {
|
|
167
|
+
const offset = bodyIndex * BOX3D_PROBE_BODY_STRIDE;
|
|
168
|
+
fields.push(...[0, 1, 2, 3, 4, 5, 19, 20, 21, 22, 23, 24, 25].map((field) => data[offset + field]));
|
|
169
|
+
}
|
|
170
|
+
return hashFloat32Values(fields);
|
|
171
|
+
}
|