API

Module: High-Precision Matrix (HPM)

Package paths: packages/babylon-lite/src/math/_matrix-allocator.ts, packages/babylon-lite/src/math/_mat4-storage-f64.ts, packages/babylon-lite/src/math/pack-mat4-into-f32.ts

Purpose

High-Precision Matrix (HPM) is the optional Float64 backing for Mat4. When the engine is created with useHighPrecisionMatrix: true, every matrix allocation on the page returns Float64Array(16) instead of Float32Array(16). CPU-side matrix composition (parent-chain world matrices, lookAt, inverse) is then done in F64, which preserves sub-unit precision at large coordinates (~1e5+ from origin) where F32 quantization becomes visible. The single F64→F32 down-cast happens at one explicit boundary — packMat4IntoF32 — when the matrix is written into a GPU uniform buffer.

HPM is the substrate that Large World Rendering (35-large-world-rendering.md) builds on: floating-origin subtracts the eye position from the world translation in F64 before the F32 store, recovering the small remainder at full precision.

Public API Surface

Engine option (engine/engine.ts)

export interface EngineOptions {
/** When true, world matrices are computed using Float64 intermediate
* precision and downcast to Float32 at GPU upload time. Defaults to false. */
useHighPrecisionMatrix?: boolean;
// ...
}

Process-global allocator (math/_matrix-allocator.ts)

/** Allocate a fresh zero-initialized 16-element `Mat4`. Returns an F32 array
* by default, or F64 if any engine on the page was created with
* `useHighPrecisionMatrix: true`. */
export function allocateMat4(): Mat4;
/** @internal Install the HPM (F64) allocator. Called once by `createEngine`
* when `useHighPrecisionMatrix: true`. Subsequent calls overwrite. */
export function _setHpmAllocator(allocate: () => Mat4): void;
/** @internal Reset to F32 default — test-only. */
export function _resetMatrixAllocatorForTests(): void;

F64 backing module (math/_mat4-storage-f64.ts)

/** @internal F64-backed Mat4 allocator. Dynamic-imported by createEngine
* inside `if (options.useHighPrecisionMatrix)`. Tree-shaken out of HPM-off
* bundles. This module is the ONLY place in the package that names
* `new Float64Array(16)`. */
export function allocateF64Mat4(): Mat4;
/** @internal Build-time tag string asserted absent from HPM-off bundles by
* `tests/bundle-content-no-f64.test.ts`. */
export const MAT4_STORAGE_F64_BUILD_TAG = "@@MAT4_STORAGE_F64@@";

GPU packing boundary (math/pack-mat4-into-f32.ts)

/** @internal Pack one Mat4 into a Float32Array upload view at the given float
* offset. Source storage may be F32 or F64; this is the only place in the
* package where F64→F32 downcast happens for GPU upload. Does not allocate.
*
* When `srcOffsetFloats` is provided, the helper reads 16 floats starting at
* `src[srcOffsetFloats]` instead of `src[0]` — used by thin-instance and
* similar packed-slab uploaders.
*
* When `offsetXYZ` is provided, the floating-origin offset is subtracted from
* the translation column `[12..14]` during pack — see LWR (doc 27).
* Subtraction happens in JavaScript number precision (F64) BEFORE the
* implicit F32 store, so `large - large = small` is computed at full F64 and
* a single F32 store rounds the remainder with ample headroom. */
export function packMat4IntoF32(
view: Float32Array,
mat: Mat4 | Float32Array | Float64Array,
offsetFloats?: number,
srcOffsetFloats?: number,
offsetXYZ?: readonly [number, number, number]
): void;

Internal architecture

Single allocator singleton (process-global)

The allocator is process-global — a single let _allocate: () => Mat4 module-level binding in _matrix-allocator.ts. The default returns Float32Array(16); calling _setHpmAllocator(allocateF64Mat4) from createEngine swaps it to the F64 backing for the rest of the process lifetime.

This violates the original M0 design (which had engine._matrixPolicy as a per-engine field) for the sake of bundle size: passing a per-engine allocator through every entity factory, scene cache, and loader scratch added ~300-500 bytes per bundle. The singleton's lazy-init form (let _allocate = () => … is a function-expression assignment, not initialization) keeps the module tree-shakable per GUIDANCE pillar 4 line 35.

Constraint: pages that mix HPM and non-HPM engines on the same page are unsupported. The second engine silently inherits the first's precision; meshes and cameras created against it will use the wrong storage. There is no runtime check — violating the rule produces silently incorrect results. This is the trade we accepted for the bundle-size savings of the singleton over the original per-engine _matrixPolicy field.

Dynamic-import gate for the F64 backing

createEngine dynamic-imports _mat4-storage-f64.ts only inside if (useHpm). With useHighPrecisionMatrix left at its default false, Vite's bundler proves the import target unreachable and tree-shakes the entire module from HPM-off bundles. tests/bundle-content-no-f64.test.ts asserts this two ways: (1) the build-tag string @@MAT4_STORAGE_F64@@ does not appear anywhere in any HPM-off bundle, and (2) lab/public/bundle/manifest.json never lists _mat4-storage-f64 as a runtime chunk for an HPM-off scene.

Mat4 type vs. Mat4Storage

The public Mat4 interface is opaque, read-only, and branded so users cannot fabricate or accidentally write to matrices vended by the engine. Internal kernels (multiplyMat4, invertMat4, packMat4IntoF32, allocators) operate on the raw Mat4Storage = Float32Array | Float64Array union, which is writable and brand-free. The two types describe the same memory; you cross between them via as unknown as Mat4Storage / as unknown as Mat4 at the trust boundary. See 21-core-math.md for full type details.

Per-load glTF scratch

The glTF loader uses three reusable scratch matrices (tmpLocal, tmpAnim, tmpInstance) in loader-gltf/_loader-scratch.ts, exposed via module-level lazy getters:

let _tmpLocal: Mat4 | null = null;
export function getLoaderTmpLocal(): Mat4 {
return (_tmpLocal ??= allocateMat4());
}

Lazy-init via allocateMat4() means the scratch picks up whatever precision the process-global allocator was installed with. Safe to share across concurrent loadGltf calls because all parser paths that touch scratch are synchronous between scratch reads/writes (only awaits are around fetch and image decode, neither touches matrix scratch). JS single-threadedness guarantees no other parse can interleave through a scratch use-site mid-computation.

Allocation boundary inventory

Every Mat4 the engine vends must come from allocateMat4(). A direct new F32(16) pins that matrix to F32 however the engine was configured, and nothing downstream can recover the lost precision. The allocating sites:

  • math/compose-mat4.ts, create-mat4-from-quat.ts, create-identity-mat4.ts, invert-mat4.ts, create-look-at-mat4-lh.ts, multiply-mat4.ts, create-perspective-mat4-lh.ts, create-scaling-mat4.ts — the eight factories that return a fresh matrix
  • scene/world-matrix-state.ts — each node's _ownedWorld
  • scene/scene-node.ts — each TRS node's lazily allocated reusable local matrix
  • camera/arc-rotate.ts, camera/free-camera.ts, camera/geospatial-camera.ts — _localMat, _viewCache, _projCache, _vpCache
  • light/directional-light.ts, light/hemispheric.ts, light/point-light.ts, light/spot-light.ts — each light's _localMatrix
  • loader-gltf/_loader-scratch.ts — the three per-load scratch matrices
  • particle/node/blocks/update-flow-map-block.ts — the flow-map matrix

_matrix-allocator.ts also exports allocateMat4Storage(), a thin wrapper that returns allocateMat4() already cast to Mat4Storage (the index-addressable view kernels write through) — like allocateMat4(), it is internal-only, not re-exported from the package entry point. It centralizes the allocateMat4() as unknown as Mat4Storage cast, but deliberately only at create-look-at-mat4-lh.ts, the one call site with no existing traffic (no scene imports createLookAtMat4LH, so the wrapper tree-shakes away with the factory itself). The as unknown as Mat4Storage cast is erased at compile time and costs nothing, but a real wrapper function is not: routing the other seven already-hot factories through it was measured to add ~35-39 raw bytes to every scene that composes a transform (i.e. nearly all of them) — the minifier keeps the indirection as a real call rather than inlining it, so the wrapper's own definition becomes a permanent per-scene tax. scene7 had only 141-176 B of ceiling headroom at measurement time, so that tax alone risked tripping it. The seven pre-existing sites therefore keep the direct allocateMat4() as unknown as Mat4Storage cast; only the new eighth site (already dead code in every current scene) uses the helper.

The *Into / *ToRef kernels (composeMat4IntoBuffer, multiplyMat4IntoBuffer, writePerspectiveMat4LHIntoBuffer, writeLookAtMat4LHIntoBuffer, …) do not allocate — they write into storage the caller already owns and inherit its precision. Only the returning factories choose storage, which is why they are the boundary.

Root nodes make local allocation load-bearing. createWorldMatrixState allocates _ownedWorld through the allocator, but uses it only when the node has a parent. For a root, getWorldMatrix() returns the local matrix unchanged (_cachedWorld = local). SceneNode's reusable local storage is lazily allocated through allocateMat4() and filled by composeTrsLocalMatrixIntoBuffer (identity fill or composeMat4IntoBuffer); other providers may return their own allocated local storage or use the fresh composeTrsLocalMatrix factory. A local allocation that bypasses the allocator therefore costs root-level objects their F64 world transforms under HPM — exactly the case HPM exists for — while parented nodes look correct and mask the fault. Local/world caching retains the storage precision chosen by the allocator; it does not introduce a downcast.

GPU upload boundary inventory

Every mat4 → GPU buffer write goes through packMat4IntoF32. The exhaustive list:

  • frame-graph/render-task.ts — view, projection, view-projection uploads for the scene UBO
  • material/standard/standard-renderable.ts — mesh-world matrix in the standard renderable's mesh UBO (initial + per-frame update)
  • material/pbr/pbr-renderable.ts — same for PBR renderables
  • material/node/node-renderable.ts — same for node-material renderables
  • mesh/thin-instance-gpu.ts — thin-instance world matrix slabs (subarray pack via srcOffsetFloats)
  • shadow/esm-directional-shadow-generator.ts, shadow/pcf-shadow-task-hooks.ts, shadow/shadow-base.ts — shadow _lightMatrix packs

The 6 mesh-world callsites pass _foOffset as the 5th argument (the scene's _floatingOriginOffset reference). The non-mesh callsites omit the offset and get precision-only packing (a bit-identical copy when storage is F32).

One exception: material/shader/shader-renderable.ts and its cached counterpart enable-shader-material-uniform-caching.ts serialize ShaderMaterial's system-uniform block themselves (data.set(world, …) / multiplyMat4IntoBuffer(data, …)) rather than calling packMat4IntoF32. They cannot reuse it — packMat4IntoF32WithOffset lives in the LWR-only bundle that this module must not statically import, and pulling it in would defeat the tree-shaking gate above. The eye-relative subtraction is instead done by _shaderWorldMatrix(mesh, camera, out?) in shader-renderable.ts, using the same large - large = small ordering: the subtraction runs in JS number precision before the F32 store, so an F64-backed mesh matrix keeps its small remainder. Any new mat4 → GPU write outside these two files belongs in the list above, not here.

Validation

  • Unit: tests/unit/engine-matrix-policy.test.ts covers allocateMat4() returning F32 by default, F64 after _setHpmAllocator, fresh instances per call, and _resetMatrixAllocatorForTests reverting. It also holds the allocation-boundary guard: each of the eight factories is asserted to honour the installed allocator (named individually, so a regression reports which one), plus a behavioural pair showing a root node keeps sub-metre precision at 4.6e6 under HPM and loses it under the F32 default.
  • Unit: tests/unit/pack-mat4-into-f32.test.ts covers F32→F32 bit-identity, F64→F32 down-cast, source/dst offsets, and the LWR offset-subtraction path.
  • Bundle: tests/bundle-content-no-f64.test.ts enforces F64 tree-shaking from HPM-off bundles (string-tag absence + manifest disjointness).
  • Bundle: scene-config ceilings — bundle-size tests confirm HPM-off scenes stay within their fixed ceilings; the F64 chunk only ships when reachable.

Files / size

FilePurpose
math/_matrix-allocator.ts (~40 lines)
Process-global lazy-init allocator + install hook
math/_mat4-storage-f64.ts (~25 lines)
F64 allocator function + build tag
math/pack-mat4-into-f32.ts (~60 lines)
Single GPU upload boundary with optional FO offset
math/types.ts (Mat4, Mat4Storage)
Opaque branded Mat4 + raw Mat4Storage union