Unofficial TypeScript/WebAssembly port of NVIDIA PhysX for browser-based game engines.
WASM-ABI release (0.1.0): the repository structure, public API surface and
ownership semantics of the foundation, plus a deterministic TypeScript↔native
ABI boundary (src/wasm/Abi.ts), a single-ArrayBuffer scene store
(SharedBodyStore) and the first real backend (MinimalIntegrationBackend:
genuine gravity/damping integration, nothing else).
No PhysX simulation is implemented — and nothing here pretends otherwise.
- PhysX concepts preserved —
PxFoundation→PxPhysics→PxScene→PxRigidDynamic/PxRigidStatic→PxShape→PxGeometry/PxMaterial, plusPxJoint*,PxController, scene queries and serialization. Same names, same ownership direction, same descriptor-driven flow as PhysX 5.x. - Public API separate from implementation — engine code imports only from
src/index.ts. Solver/broadphase/narrowphase live behindIPhysicsBackend(src/wasm/PxBackend.ts). Swapping in the WASM core later changes zero call sites. - Honest boundary, no fake physics — every solver/query method without an
implementation throws
PhysXNotImplementedErrorwith a feature key ("PxScene.simulate","PxScene.raycast", …). Validation errors throwPhysXValidationError; use-after-release throwsPhysXReleasedObjectError. - Typed-array data paths — API boundaries use plain structs (
PxVec3,PxQuat,PxTransform); hot paths use packedFloat32Array+ explicit offsets/strides, never per-body objects. The ABI is specified byte-exactly insrc/wasm/Abi.tsand owned as oneArrayBufferper scene. - Stable ABI boundary —
TypeScript API → IPhysicsBackend → WASM ABI → native core. The ABI (magic, version, header, SoA sections, handles, status machine) is versioned and negotiated; a compiled PhysX core replacesMinimalIntegrationBackendbehindIAbiSceneBackendwith zero public-API change.
src/
index.ts # public barrel (only import surface engines need)
core/ # Errors, Version, Ownership (PxRefCounted), PxMath
foundation/ # PxFoundation, PxErrorCallback
physics/ # PxPhysics factory hub
scene/ # PxScene, PxSceneDesc, broadphase/scene flags
actors/ # PxActor base, PxRigidStatic, PxRigidDynamic
shapes/ # PxGeometry union + factories, PxShape
materials/ # PxMaterial (friction/restitution)
queries/ # PxRaycastHit/Sweep/Overlap, filter data
constraints/ # PxJoint + joint types
controllers/ # PxController (character controllers)
serialization/ # descriptor snapshots (binary RepX explicitly unimplemented)
wasm/ # PxBackend, Abi (stable boundary), SharedBodyStore,
# MinimalIntegrationBackend, MemoryLayout strides
examples/basic-simulation.ts # intended API flow (default backend)
examples/minimal-backend-simulation.ts # real integration via the ABI backend
tests/ # vitest: api-flow, ownership, validation-math,
# wasm-abi (raw-buffer level), minimal-backend (API level)
PxFoundation ─owns──▶ PxPhysics ─owns──▶ PxScene ─owns──▶ PxActor ─refs──▶ PxShape ─refs──▶ PxMaterial
- Factories transfer ownership; callers
release()(or useusing, viaSymbol.dispose). release()is safe to call twice; any other use after release throws.- Releasing a parent does not cascade-release children (matches PhysX release ordering).
- Shapes/materials are ref-counted:
attachShaperetains,detachShape/actor-release drops.
| Owner | Holds | Lifetime |
|---|---|---|
| JS-owned | Descriptors, TS wrappers, actor→handle map, allocator free-list | Created/released with the TS objects |
| Shared | One ArrayBuffer per scene (64-byte header + SoA body sections) |
Bound at createScene, dead at destroyScene; TS writes descriptors pre-simulate, the core writes results pre-fetchResults — never both at once |
| Core-owned (opaque) | Solver internals, broadphase, cooked meshes, constraints | Referenced from TS only as u32 ids; the minimal backend owns none |
| Temporary transfer | Small scratch views for one-record marshal | Never retained across calls |
- Body handles are
(generation:u16 << 16) | index:u16.destroySceneinvalidates every handle; stale/double-free use throws, never aliases. - ABI major mismatch (or bad magic/length on
attach) throws with keyWasmAbi.version— a foreign core can never silently misread memory.
import { createFoundation, createPhysics, pxBoxGeometry } from 'physx-web';
using foundation = createFoundation();
using physics = createPhysics({ foundation });
using scene = physics.createScene({ gravity: { x: 0, y: -9.81, z: 0 } });
using material = physics.createMaterial(0.5, 0.5, 0.1);
using body = physics.createRigidDynamic(
{ p: { x: 0, y: 5, z: 0 }, q: { x: 0, y: 0, z: 0, w: 1 } },
1,
);
using shape = physics.createShape(pxBoxGeometry(0.5, 0.5, 0.5), [material]);
body.attachShape(shape);
scene.addActor(body);
scene.simulate(1 / 60); // default backend: throws PhysXNotImplementedError;
scene.fetchResults(true); // with MinimalIntegrationBackend: really integratesRun it: npm run example (flow + boundary report) or
npm run example:backend (real falling-body run through the ABI).
src/wasm/Abi.ts— magic'PXWB', ABI version1.0, 64-byte header (capacity, active count, sequence,Idle→Running→Complete→Destroyedstatus, last dt, shared gravity), SoA body sections (flags, generations, positions, quaternions, linear/angular velocity, inverse mass, diagonal inverse inertia, linear/angular damping),u16/u16handles, version negotiation.- Total buffer:
64 + 84 × capacitybytes, one contiguousArrayBuffer. - The minimal backend touches bodies only via this layout
(
SharedBodyStorecold paths, raw section views +i*3/i*4strides hot).SharedBodyStore.attach()validates foreign buffers — the futurewasm.memorypath.
native/include/physx_bridge.h+native/src/physx_bridge.cpp— a real C++ bridge over core PhysX (foundation/physics/scene/material/box actors, gravity,simulate/fetchResults, transform+velocity readback). Opaqueu32ids only, cascade destruction (actors → scene → materials → physics → foundation), last-error diagnostics. No extensions/cooking/PVD, no custom collision code — collision is performed by PhysX itself.src/wasm/NativePhysXBackend.ts—IAbiSceneBackendover the bridge: same scene-state protocol as the minimal backend (register/push/step/pull throughSharedBodyStore), explicitPhysXNativeErroron any failure, never a silent fallback. Creation-time box extents/materials ride theWasmBodyState.shapehint (buffer layout unchanged); anything beyond boxes is rejected with a feature key.- Build:
npm run build:native(Emscripten + CMake + PhysX SDK; fails with exit 2 naming the missing piece). Full toolchain/steps:native/README.md. - Tests:
npm run test:native— mock adapter-contract suite (always runs; echo semantics, proves plumbing only) + real-binary suite (fall → collide → rest on real ground, handle lifecycle), which skips with a diagnostic when the artifact is absent.
| Script | Command | Purpose |
|---|---|---|
npm run build |
tsc -p tsconfig.build.json |
Emit ESM + .d.ts to dist/ |
npm run typecheck |
tsc --noEmit |
Strict type check (incl. tests) |
npm test |
vitest run |
Unit tests |
npm run example |
tsx examples/basic-simulation.ts |
Intended API flow demo |
npm run example:backend |
tsx examples/minimal-backend-simulation.ts |
Real ABI integration demo |
npm run build:native |
node scripts/build-native.mjs |
Isolated native-bridge build (fails explicitly when toolchain/SDK missing) |
npm run test:native |
vitest run tests/native-physx.test.ts |
Mock adapter suite + real-binary suite (skips honestly without artifact) |
Strictness: strict, noUncheckedIndexedAccess, exactOptionalPropertyTypes,
noImplicitOverride/noImplicitReturns — all on.
- Depend on the
physx-webpackage root only; never deep-import modules. - Inject a backend once:
createPhysics({ foundation, backend })where backend isMinimalIntegrationBackendtoday and the compiled core tomorrow — call sites don't change (both implementIPhysicsBackend; ABI cores additionally implementIAbiSceneBackend). - Hot loops: use
readGlobalPoseInto/readVelocitiesInto/BodySoABuffersinstead of allocating structs per frame. - Catch
PhysXNotImplementedErrorto detect unavailable features at runtime (e.g. disable CCD UI until the backend supports it).
Genuinely implemented and tested:
| API | Status |
|---|---|
| Foundation/Physics/Material/Shape/Actor creation, poses, velocity seeds, damping, mass, gravity storage, attach/detach, descriptors, serialization snapshots | ✅ implemented |
| WASM ABI v1.0: header/sections/handles, version negotiation, foreign-buffer attach, status-machine sync | ✅ implemented |
MinimalIntegrationBackend: semi-implicit Euler (v += g·dt, v *= 1/(1+d·dt), p += v·dt in f32), static/kinematic skip, per-body gravity disable, scene stepping + result pull-back through the public API |
✅ implemented (tiny subset — no interaction between bodies) |
NativePhysXBackend over real PhysX SDK 5.1.3 compiled to wasm32: box actors, gravity, PxScene::simulate/fetchResults, transform+velocity readback through SharedBodyStore |
✅ implemented + executed (see status report) |
| Mass/inverse-mass sync; solid-box inverse inertia for single-box dynamics (textbook formula), zeroed otherwise | ✅ implemented |
Still unsupported — throws PhysXNotImplementedError, never faked:
| API | Status |
|---|---|
PxScene.simulate / fetchResults on the default backend; raycast/sweep/overlap on all backends (no queries in any backend yet) |
❌ PhysXNotImplementedError |
addForce/addTorque/setKinematicTarget/wakeUp/putToSleep, PxController.move, orientation integration, joints solving, CCD, sleeping |
❌ PhysXNotImplementedError |
| Kinematic actors, non-box or multi-shape actors on the native bridge | ❌ PhysXNotImplementedError (explicit phase scope) |
| Convex/triangle-mesh cooking, heightfields, binary RepX | ❌ PhysXNotImplementedError |
IMPLEMENTED (built, executed, all green 2026-09-20):
- Native C++ bridge (
native/) compiled to WebAssembly and executed:native/build/physx_web_bridge.{js,wasm}(2.34 MB wasm). NativePhysXBackend+loadEmscriptenBridgeModule+PhysXNativeError(mock suite 9/9; real suite 3/3 — see below).- Full suite: 70/70 tests pass, 0 skipped (was 67 + 3 skipped).
- Reproducible native path:
npm run build:physx(pinned SDK + reviewed 3-guard wasm32 patch) →npm run build:native→npm run test:native.
VERIFIED BY REAL EXECUTION (tests/native-physx.test.ts, real binary):
- Foundation, physics, scene, material, static ground box, dynamic box: all initialize through the bridge.
- Gravity transfer,
PxScene::simulate,fetchResults, ABI readback of transform + velocities: all execute for real. - Collision: the box falls from y=5, never tunnels (min y > 0.9 over the trajectory), rests at y≈1.0 (one half-extent above the ground top face) with near-zero velocity; the static ground never moves.
- Lifetime: release order, scene-death invalidation, double-free rejection, stale-generation rejection: all pass against the native backend.
UNSUPPORTED: queries on all backends; forces/torques/kinematic targets/ sleep API; joints, controllers, CCD, cooking, heightfields, binary RepX; non-box/multi-shape/kinematic actors on the native bridge.
REMAINING TOOLCHAIN LIMITATIONS:
- The native build was provisioned on Windows (CMake 4.4.3, Ninja 1.13.2,
Emscripten 6.0.9, Python 3.12.10, PhysX 5.1.3 @
93b6c25). Rebuilding needs the same:PHYSX_ROOT,PHYSX_LIB_DIR, activated emsdk (seenative/README.md). - Browser deployment of the threaded build additionally requires
COOP/COEP headers for
SharedArrayBuffer.
WASM ABI + first real backend✅ done.Native bridge + real PhysX execution✅ done (70/70, collision verified).- Broadphase (SAP) → narrowphase (box/sphere/capsule/plane) → queries.
- Constraints/joints solver, character controllers, sleeping/filtering.
- Mesh cooking, serialization parity, benchmarks vs native PhysX.