Colossum PhysicsBox3D / WebGPU runtime
Docs for @colossum/physics 0.1.0
Colossum technical field manual / API reference

Colossum Physics

A typed contract for Box3D simulation, deterministic runtime selection, fixed-capacity worlds, and one contiguous GPU pose upload per frame.

TypeScript APIBox3D WASMSIMD + pthreadGPU pose bridge

Installation

Install the public @colossum/physics package from npm:

sh
npm install @colossum/physics

Copy the verified Emscripten modules, WebAssembly binaries, worker code, and manifest into your application’s public directory:

sh
npx colossum-physics-assets public/colossum-physics

The command creates /colossum-physics/manifest.json, the default runtime location used by the npm build. Commit the copied directory or run the command as part of your application’s build. For a framework with a different static directory, pass that directory followed by colossum-physics as the destination.

ts
import { createPhysicsRuntime, PhysicsRuntimeError } from "@colossum/physics";

const runtime = await createPhysicsRuntime({ variant: "auto" });

Hosting and browser requirements

Serve the application page and /colossum-physics/ from the same origin. Pthread variants use Web Workers and shared WebAssembly memory, so the application response must include:

text
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp

The shipped runtimes require WebAssembly SIMD. Basic simulation does not require WebGPU, but syncPosesForFrame() and the renderer contract do. Pthread selection additionally requires crossOriginIsolated === true, SharedArrayBuffer, and enough reported logical processors for the selected worker count. Without those pthread prerequisites, variant: "auto" falls back to the single-thread SIMD build. A browser without WebAssembly SIMD receives NO_COMPATIBLE_VARIANT.

Keep the manifest, module JavaScript, worker entry, and WASM on the application origin. Models, textures, and other non-runtime assets may use any CDN or asset host—including infrastructure provided through Colossum Wavefront—when its CORS and CORP headers permit the game, page, or simulation origin.

Runtime creation

ts
const runtime = await createPhysicsRuntime({
  variant: "auto",
  allowFallback: true,
  maxWorlds: 16,
  device, // optional default device for pose uploads
});

The npm build defaults to /colossum-physics/manifest.json. Use manifestUrl only when the copied runtime directory has another public URL. A manifest selects executable module JavaScript, so load it only from an origin you trust. Schema, build-identity, and ABI validation reject malformed or incompatible metadata; they do not make an arbitrary manifest or module URL trustworthy.

The shipped manifest contains these variants:

Variant Automatic policy Requirements
simd-pthread-4 First automatic preference SIMD, isolation, SharedArrayBuffer, and at least 4 logical processors
simd-single Automatic portability fallback SIMD
simd-pthread-8 Explicit scaling option SIMD, isolation, SharedArrayBuffer, and at least 8 logical processors

Request an exact id with variant, or put ids first with selectionOrder. allowFallback defaults to true; when enabled, an unavailable or failed preferred module leaves a diagnostic reason and the loader tries the next compatible candidate. Set it to false when selecting an exact artifact is more important than recovery. simd-pthread-8 follows the normal production candidates during fallback, but request or prioritize it explicitly when eight-worker scaling is the intended policy.

manifest, fetch, capabilities, moduleFactories, and moduleLoader are advanced integration and testing seams. Emscripten module objects and underscored exports remain sealed behind the typed adapter.

maxWorlds defaults to 16 and is capped at Box3D’s pinned 128-world limit. Threaded runtimes still apply the stricter one-live-world pool rule described below.

runtime.diagnostics() returns the requested/selected backend, worker counts, feature state, fallback reasons, heap and build identities, and active-world count.

World lifecycle

ts
const world = runtime.createWorld({
  capacity: 2_001,
  visualCapacity: 2_001,
  staticBodyCapacity: 32,
  contactCapacity: 16_008,
  convexHullCapacity: 8,
  gravity: [0, -9.81, 0],
  enableSleep: false,
});

World and renderer capacities are fixed. Creation validates the entire descriptor and either returns a live world or fails without leaking a native world.

Option Default and purpose
capacity Required body-slot capacity; maximum 65,534
visualCapacity capacity; maximum 65,535 renderer slots
staticBodyCapacity min(capacity, max(1, ceil(capacity / 64))); expected static-body allocation
contactCapacity min(131072, max(256, capacity * 8)); fixed contact allocation
convexHullCapacity 0; fixed reusable hull-asset slots, maximum 4,096
gravity [0, -9.81, 0]
enableSleep / enableContinuous true; world-wide sleeping and continuous collision behavior

Advanced Box3D tuning fields retain stable adapter defaults: restitutionThreshold: 1, hitEventThreshold: 1, contactHertz: 30, contactDampingRatio: 10, maximumContactPushSpeed: 3, and maximumLinearSpeed: 400. Capacity settings reserve native resources; the runtime never silently resizes a live world.

Worker count is fixed by the selected artifact, not mutable per world. Pthread artifacts use one strict preallocated Box3D worker pool and therefore allow one live world at a time. Destroy that world before creating its replacement. Single-thread runtimes may use the configured maxWorlds slots concurrently.

The runtime owns every world it creates. world.destroy() may be called explicitly and is idempotent; runtime.destroy() destroys any remaining worlds, shuts down the module, and is also idempotent. Pthread module shutdown terminates its preallocated worker pool; construction or initialization failures do the same before fallback. Shutdown is terminal, and using a destroyed runtime/world raises a typed error.

Batch body and shape creation

ts
const handles = world.createBodies({
  bodies: [
    {
      motionType: "dynamic",
      position: [0, 4, 0],
      orientation: [0, 0, 0, 1],
      shape: { kind: "box", halfExtents: [0.5, 0.5, 0.5] },
      density: 1,
      friction: 0.6,
    },
    {
      motionType: "dynamic",
      position: [1.2, 5, 0],
      shape: { kind: "sphere", radius: 0.45 },
    },
  ],
  visualSlots: [10, 11],
});

The batch is packed into one wasm descriptor span and sent through one native creation call. Sphere, box, capsule, finite-cylinder, and reusable convex-hull shapes are supported. Capsules and cylinders are aligned to the local Y axis. Cylinder sides accept 3 through 32 and default to 16.

position and shape are required. Bodies otherwise default to dynamic motion, identity orientation, zero linear/angular velocity, density 1, friction 0.6, restitution 0, damping 0, gravity scale 1, and an awake, enabled, sleep-capable state. Input quaternions are normalized. bullet, fixedRotation, and enableFastRotation opt into Box3D’s fast-motion or rotation policies; linearDamping, angularDamping, rollingResistance, and sleepThreshold tune behavior without changing the shape.

Visual slots are stable, unique indirections. Set BodyDescriptor.visualSlot or the batch-level visualSlots array when render-instance order must be explicit. Otherwise the world prefers the body’s native pose slot and then the next free visual slot.

Capacity and explicit-slot validation happen before native mutation. Native creation is also validation-first. A failed batch never exposes a partial result.

Reusable convex hulls

Reserve hull slots with convexHullCapacity, cook a point cloud once, and reuse its opaque handle across body instances:

ts
const hull = world.createConvexHull({
  points: new Float32Array([
     0,  1,  0,
    -1, -1, -1,
     1, -1, -1,
     0, -1,  1,
  ]),
  maxVertices: 44,
});

const [body] = world.createBodies([{
  position: [0, 4, 0],
  shape: { kind: "convexHull", hull, scale: [1, 2, 1] },
}]);

points is a flat xyz array containing 4 through 4,096 finite points. Box3D builds a convex hull and retains at most maxVertices, which defaults to and cannot exceed 44. Instance scale defaults to [1, 1, 1] and must be positive on every axis.

Hull handles belong to the world that created them and are generation-safe. Use destroyConvexHull() or destroyConvexHulls() when the template is no longer needed for new body creation; already-created bodies retain their instantiated shapes. World destruction releases any remaining hull assets.

Height fields

ts
const terrain = world.createHeightField({
  columns,
  rows,
  heights,                         // row-major f32-compatible samples
  cellSize: [1, 1],               // X/Z spacing
  heightScale: 1,
  origin: [-32, 0, -32],
  friction: 0.8,
});

Height-field creation performs one construction-time sample upload into the wasm heap. Box3D owns its compressed/cooked terrain afterward; no height data crosses the JS/WASM boundary during a frame. Height fields are capped at 4,194,304 samples before allocation so dimensions cannot wrap or consume the fixed heap pathologically. The returned handle obeys the same generation and visual-slot rules as other bodies.

columns and rows must each be at least 2, and heights must contain exactly columns * rows finite samples in row-major order. Sample (column, row) is positioned at origin + [column * cellSizeX, heights[row * columns + column] * heightScale, row * cellSizeZ]. origin defaults to [0, 0, 0], heightScale defaults to 1, friction defaults to 0.8, and restitution defaults to 0. Set clockwise: true only when the consuming coordinate/winding convention requires reversed height-field triangles.

A height field is one enabled static body. Disable or re-enable it through the normal handle API, and release it with world.destroyBody(terrain).

Handles, activation, and removal

BodyHandle is an opaque frozen object with readable bodySlot and visualSlot mapping fields. Its native generation token and world ownership are private. Do not manufacture look-alike objects.

ts
world.setBodyAwake(handle, true);
world.setBodyEnabled(handle, false);
world.setBodyEnabled(handle, true);
world.destroyBody(handle);

setBodyAwake changes the body’s sleep state without changing its identity. setBodyEnabled(false) removes the body from active simulation and collision participation until it is enabled again; enablement does not allocate a replacement handle. destroyBody permanently releases the native body and renderer mapping.

After destruction, the handle is stale forever. A new body may reuse its numeric pose/visual slot, but native and TypeScript generation/ownership validation rejects the old object. Passing a handle from another world is also a stale-handle error. Synchronize poses after activation, destruction, or creation before rendering the resulting state.

Renderer contract: stepping and pose synchronization

The rendering boundary deliberately separates simulation from presentation. world.step() advances Box3D state, while world.syncPosesForFrame() publishes the latest body transforms to world-owned WebGPU buffers and returns the RendererContract needed to bind them. This keeps per-body transform queries out of the JavaScript render loop.

ts
const step = world.step(1 / 60, { substeps: 4 });
const contract = world.syncPosesForFrame(device);

step makes one synchronous Box3D call for the supplied interval. substeps controls Box3D’s internal subdivision of that interval; it does not add JavaScript-to-WASM calls. The returned StepResult reports the step serial, effective dt, substep count, and measured step time.

Call syncPosesForFrame after the simulation or lifecycle mutation whose state the renderer should consume. It packs poses at most once for that native mutation generation, moves the old current pose buffer into previous history, and performs at most one contiguous upload for the new current poses. Repeating synchronization without another step, topology change, or activation change performs no pose work and returns uploadedThisSync: false. Render-only frames can keep using the existing GPU buffers.

The returned contract exposes the complete GPU binding agreement:

The buffers are borrowed, world-owned resources: bind and read them, but do not write, resize, or destroy them. The world updates visualToBody when its topology mapping changes.

Creation, destruction, and slot reuse re-prime history by uploading current first and copying it to previous. This deliberately collapses interpolation for one generation so a new body never blends from an unrelated body’s old transform.

The first call needs a GPUDevice unless one was provided when creating the runtime. Later calls may pass that device’s GPUQueue. Passing another device deliberately replaces all world-owned GPU resources and increments resourceEpoch; a queue from another device is rejected.

rendererContract() returns the most recent synchronized contract without performing work. It fails before the first successful synchronization.

Device loss

The world observes device.lost, marks its renderer contract unavailable, destroys the world-owned GPU buffers, and increments the resource epoch. Box3D simulation state remains alive. Synchronizing with a replacement GPUDevice creates and primes new buffers; the renderer must rebuild its bind group for the new epoch. The old RendererContract and its buffers must not be reused.

rendererContract() and attempts to resynchronize with the lost device fail with GPU_DEVICE_LOST. Acquire a replacement device, call syncPosesForFrame(replacementDevice), and bind the newly returned contract. Simulation may continue while renderer resources are unavailable.

Diagnostics and performance

ts
const runtimeInfo = runtime.diagnostics();
const worldInfo = world.diagnostics();

Runtime diagnostics report requested and selected variants, worker counts, browser capability probes, fallback reasons, fixed heap size, build identity, and active-world/disposal state. World diagnostics add body and hull capacity/usage, pose and submission generations, renderer resource epoch, device-loss state, and decoded native Box3D counters.

worldInfo.timings separates the most recent synchronous step, native pose pack, pthread staging copy, and GPUQueue.writeBuffer time. stagedBytes is nonzero when a shared WASM heap required a non-shared upload view; uploadedBytes is the current contiguous pose span. lastQueueDrainMs is observed asynchronously with onSubmittedWorkDone() and remains null until an observation completes.

Native counters refresh when a changed world is packed by syncPosesForFrame; reading diagnostics alone does not force a pack or mutate simulation state. Use these measurements for workload and variant comparisons, not as fixed performance guarantees across browsers or devices.

Typed failures

ts
try {
  world.step(1 / 60);
} catch (error) {
  if (error instanceof PhysicsRuntimeError) {
    console.error(error.code, error.message, error.details);
  }
}

PhysicsRuntimeError.code is one of:

Code Meaning or recovery
INVALID_ARGUMENT Correct the caller-supplied option, descriptor, handle batch, or GPU queue
INVALID_MANIFEST Serve a supported, well-formed manifest and pinned build identity
NO_COMPATIBLE_VARIANT Meet SIMD/thread prerequisites or choose an available variant
MODULE_LOAD_FAILED Check asset URLs, response policy, module initialization, and fallback reasons
ABI_MISMATCH Deploy matching package, manifest, module JavaScript, and WASM artifacts
NATIVE_ERROR A native Box3D operation failed; inspect details and the chained cause
CAPACITY_EXHAUSTED Release resources or create a replacement world with larger fixed capacities
STALE_BODY_HANDLE Stop using a destroyed, replaced, manufactured, or foreign body handle
STALE_CONVEX_HULL_HANDLE Stop using a destroyed, replaced, manufactured, or foreign hull handle
WORLD_DESTROYED / RUNTIME_DESTROYED Create a new live owner; shutdown is terminal
GPU_DEVICE_REQUIRED Initialize renderer resources with a GPUDevice, not only a queue
GPU_DEVICE_LOST Acquire a replacement device and synchronize again
RENDERER_NOT_SYNCHRONIZED Call syncPosesForFrame(device) before reading the contract
HEIGHTFIELD_UNAVAILABLE Use a runtime artifact that exports height-field support

details supplies structured context when available, and cause retains a lower-level failure when one exists. A queue from a different device is an INVALID_ARGUMENT; it does not silently replace renderer resources.

Errors do not trigger a hidden resize, module switch after world creation, or partial lifecycle operation.