API

Module: Right-Handed Coordinate System

Package paths:

  • packages/babylon-lite/src/engine/coordinate-system.ts (default variant, left-handed)
  • packages/babylon-lite/src/engine/coordinate-system-rh.ts (babylon-lite-rh variant)

Purpose

Let an application render in Babylon.js's right-handed system (scene.useRightHandedSystem = true) instead of Lite's default left-handed one, without making left-handed applications pay a byte or a runtime test (with a minifier that inlines imported constants, as Rolldown does; see Why a build-time choice).

User guide, with the setup for each bundler: Right-Handed Coordinate System.

Choosing the handedness

Engine modules read one flag, _rightHanded (true or null), from the package import #coordinate-system. The package imports map resolves it by bundler resolve condition:

ConditionVariantHandedness
(none)
coordinate-system.ts
left-handed
babylon-lite-rh
coordinate-system-rh.ts
right-handed
// vite.config.ts — the application is right-handed; it keeps calling createEngine.
export default defineConfig({ resolve: { conditions: [...defaultClientConditions, "babylon-lite-rh"] } });

Other bundlers use their own condition option: webpack resolve.conditionNames, esbuild --conditions, Rollup @rollup/plugin-node-resolve exportConditions. Vite's resolve.conditions replaces the defaults, hence defaultClientConditions.

Handedness is fixed for the whole bundle: every engine it creates has the same handedness.

The prebundled CDN file (dist/index.js) is built with the default variant and is left-handed only.

Why a build-time choice

Both variants export _rightHanded as a const (null or true). The scene bundler does not fold an imported constant across modules: the bundle still contains _rightHanded ? … : …, and only the per-chunk minifier, which sees the value, drops the dead branch. So the dead branch disappears, but anything it imports has already been linked into the bundle by then. A left-handed bundle is byte-identical to one built before this feature only because right-handed branches import nothing of their own (see Writing handedness-dependent code).

That holds for the lab's bundler, Rolldown, whose minifier inlines the imported constant; it is checked scene by scene. esbuild's minifier (0.28) does not inline it: its left-handed bundles keep the _rightHanded tests and the small same-module right-handed helpers (_toRightHandedCameraWorld…). They behave the same, they are just not byte-identical.

The published lib/ modules import the bare specifier #coordinate-system, so they need a bundler that reads package.json imports; native browser ESM cannot resolve it. The prebundled dist/index.js has no such import.

A runtime flag cannot get that: in a code-split bundle the flag lives in one chunk and is imported by the lazy ones (PBR pipeline, background ground…), and no bundler can prove an imported let is never reassigned. Measured on 18 lab scenes, a runtime flag cost 0–0.4 KB raw (≤ 0.1 KB gzip) per scene, so the choice is left to the application's bundler.

Model

A right-handed engine renders the world exactly as a left-handed engine renders the Z-mirrored world. With Sz = diag(1, 1, -1):

  • View — getViewMatrix returns Sz·V_RH (third row negated). The projection stays the left-handed one; since Babylon.js's right-handed projections are the left-handed ones times Sz, clip space equals Babylon.js's P_RH·V_RH. Every shader keeps seeing the usual left-handed view space (+X right, +Y up, +Z forward).

  • Camera world matrix — arc-rotate, free and geospatial cameras build their left-handed look-at matrix, then negate its X and Z basis columns in place: W_RH = W_LH·diag(-1, 1, -1), so the camera looks down its local -Z with +X to the right, like Babylon.js. Camera inputs, gizmos and XR read the camera's forward as local -Z accordingly.

  • Winding — the mirrored view reverses triangle winding on screen, so camera-pass pipelines flip frontFace: "cw" for ordinary meshes (Babylon.js's right-handed rule), and the mirrored-mesh rule in std-mirrored-support.ts is inverted. glTF meshes, which keep glTF's winding (see below), stay "ccw": the primitive feature marks each of them with mesh-feature bit 11 (_primitiveFeatures), and every material path reads that per-mesh mark — PBR through its per-mesh winding, Standard through bit 11 of its mesh-feature key, Node through _pipelineForCcwMesh (its renderables never group marked and unmarked meshes, even when they share one geometry), and shader materials by splitting glTF and procedural meshes into separately wound pipelines (|ccw variant key). The mark lives on the mesh, not on its shared GPU geometry: a negative-scale node instancing the same glTF mesh is not marked and stays "cw", and a device-loss rebuild of the geometry keeps it. Whenever a PBR or Standard renderable is built, the enableMirroredMeshes() opt-in clears that bit in a left-handed build; right-handed, it rewrites it from the live determinant instead, so a glTF mesh moved from PBR or Standard to a Node or shader material keeps its "ccw". Node and shader materials build without that encoder, so right-handed the opt-in also refreshes the mark at install and whenever its watcher sees a world matrix change (before the same frame's material swaps), and rebuilds a mesh whose mark was stale. Without the opt-in they read the load-time mark, as in a left-handed build. Depth and shadow passes use the same winding as the colour pass, since light views mirror like the camera's.

  • glTF — the loader keeps glTF's native right-handed space: the __root__ node has no X mirror, computeNodeWorldMatrix drops the root conversion, the skeleton and animation-mixer root transforms (skeleton-pose.ts, skeleton-updater.ts, weighted-gltf-mixer.ts) are the identity to match, the glTF camera fixup keeps +X, and the primitive feature module (per-mesh winding) is always loaded. Mesh._authoredSign stays -1: it is relative to the left-handed rule, which the right-handed winding in std-mirrored-support.ts already inverts. Gaussian splats from KHR_gaussian_splatting replace the left-handed rotation.z = π (which, combined with the root mirror, equals a Y mirror) with scaling.y = -1, since there is no root mirror to combine with.

  • Environment — rotateY in the PBR IBL helpers (and the mesh-LoD copy) negates Z after the rotation, like Babylon.js's REFLECTIONMAP_OPPOSITEZ, for radiance, irradiance, clearcoat and local cubemaps.

  • Normal mapping without tangents — the PBR cotangent frames (base normal map, clearcoat normal map, bent normal map, mesh-LoD copy) reverse both their tangent and bitangent, like Babylon.js's glTF loader, which ties invertNormalMapX/Y to the handedness. Standard and node materials keep their frames: Babylon.js flips those only on explicit options.

  • Everything else — each Babylon.js useRightHandedSystem branch reachable from a Lite feature has its counterpart: light views of shadow maps and cascades, the linear-depth pass, Standard skybox and reflections (REFLECTIONMAP_OPPOSITEZ, spherical mapping), the PBR cubemap skybox (including the IBL skybox mode, IBL_SKYBOX_CALCULATION) and background ground, shader-material system uniforms (view, viewProjection, … are handed to user shaders as Babylon.js's right-handed matrices), the derivative normal view, node-material View/Projection inputs and PBR helpers, billboard and billboard-picking texture U, camera inputs, gizmos, the geospatial camera, .babylon cameras, glTF cameras, interactivity camera paths, the procedural sky, CSG2, Havok mesh shapes, USD roots and normal maps, the screen-space raymarch normal reconstruction behind contact shadows and SSGI (its cross operands swap, like the derivative normal view behind cavity), the mesh-blending inverse view and projection (its depth decode negates the projection's Z column, so View and Screen depths reconstruct the same engine view point), and WebXR (poses, pointer, teleport, hand meshes, controller models), which stays in WebXR's native right-handed space instead of being Z-mirrored.

  • Geometry-buffer depth — VIEW_DEPTH is signed like Babylon.js's right-handed view z (negative in front of the camera), but NORMALIZED_VIEW_DEPTH stays in [0, 1] in both builds: the engine's view z is the positive distance, like the positive near/far bounds it is normalized against. (Babylon.js right-handed returns negative normalized depths; consumers expect [0, 1].) The library's own VIEW_DEPTH consumers turn the signed depth back into a distance: mesh blending through its projection, and the circle of confusion behind depth of field (depthNotNormalized) by negating it.

  • Not ported: EXT_lights_image_based — envYawFromQuaternion returns the left-handed yaw −A in both builds, so a right-handed build samples the environment along Sz·Ry(−A)·d. Babylon.js's loader inverts the extension's rotation only when left-handed, i.e. samples Sz·Ry(A)·d when right-handed: the two agree only for A = 0° or 180°. Flipping the sign alone did not reproduce Babylon.js's right-handed render of scene 265 either (the irradiance still differs), so the port stays open, and twin 342 (A = 90°) is compared with scene 265's left-handed reference (BJS_RH_BROKEN).

Files whose handedness-sensitive code needs no seam are listed, with the reason, in HANDEDNESS_EXEMPT of tests/lite/unit/handedness-guard.test.ts (orthographic projection, text, glTF primitive winding, node and Standard bump frames, the PBR anisotropy direction — both tangent axes would reverse and the lobe is quadratic in them — and the flat normal, which is re-oriented toward the viewer).

Writing handedness-dependent code

  • Import the flag from #coordinate-system, never by relative path.
  • Prefer _rightHanded ? a : b, ...(_rightHanded && { … }) and if (_rightHanded !== null) { … }.
  • A right-handed branch must not import anything the file's left-handed code does not already use. The minifier removes the dead branch only after the bundler has linked its imports: an imported helper survives in the left-handed bundle (as an export of a shared chunk when caller and helper land in different chunks). Write the right-handed code inline, or as a function of the same module. right-handed-drift.test.ts fails on such an import.
  • Inside a wgsl-tagged template, the tag minifies the static text only: write interpolated WGSL variants already minified, or the left-handed shader text changes.
  • Check a change with the filtered scene bundles: every left-handed scene must keep master's rawBytes in lab/public/bundle/master-manifest.json.
  • Add a pair test to right-handed-pairs.test.ts for the new branch (or a twin scene); the coverage guard below fails otherwise.

Tests

Four guards keep the right-handed variant from rotting while every other test, scene and golden exercises the left-handed one:

  • Drift — tests/lite/unit/right-handed-drift.test.ts reduces every _rightHanded decision to the token difference between its right-handed and left-handed code and compares it with a committed snapshot. Editing both branches the same way keeps it green; editing only the left-handed one fails and names the site. An intended change to the right-handed port is reviewed and accepted with vitest -u. The same file holds the leak guard: no right-handed branch imports a symbol that the left-handed code of its file does not use.
  • Behavior pairs — tests/lite/unit/right-handed-pairs.test.ts runs the same input through a left-handed and a right-handed build of the engine (right-handed-builds.ts swaps the #coordinate-system module) and asserts the documented relation between the two results: mirrored camera paths after the same drags, the same gizmo rotation, shader text that differs by exactly the ported expression, skeleton worlds mirrored in X. A left-handed change not carried to the right-handed path breaks the relation.
  • Coverage — tests/lite/unit/handedness-guard.test.ts fails when an engine file holds handedness-sensitive code (frontFace, a left-handed matrix builder, the glTF root mirror, a derivative-based tangent frame) without importing #coordinate-system or being exempt, and when a file importing it is neither imported by a right-handed-*.test.ts, nor rendered by a twin scene listed in RH_TWIN_COVERAGE, nor declared as a known gap in RH_DRIFT_ONLY.
  • Right-handed twin scenes ("rightHanded": true in scene-config.json, 321–347) — parity against Babylon.js with useRightHandedSystem = true. Their Lite wrapper and Babylon.js reference only import the source scene (the reference through lab/lite/src/bjs/right-handed-twin.ts), so they follow every change to it, and CI's impact selection runs them with the source. The guard checks that they stay pure re-runs. A Babylon.js 9.28 right-handed bug is worked around on the reference side: right-handed-twin.ts replaces the zero refraction matrix of an unrotated CubeTexture (twin 334). Twin 342 keeps scene 265's left-handed reference (BJS_RH_BROKEN) because Lite does not port EXT_lights_image_based yet (see above). Like every parity spec, they compare against their committed babylon-ref-golden.png and never regenerate it during a run (RECAPTURE_GOLDEN is the explicit opt-in). The goldens were captured once from the right-handed Babylon.js reference on CI's BrowserStack macOS browser, the GPU the parity job renders on. Two twins keep their source scene's golden, as the source does: 336 (scene 159's reference writes 0.10, which some GPUs round to 26/255, Lite 25/255) and 342 (its reference is scene 265's, so its golden is a copy of 265's).
  • Two twins carry a looser maxMad than their source scene, set from measured runs: 323 (0.01 vs scene 13's 0.008) measured 0.0089, where Babylon.js right- vs left-handed differ by 0.779 and Lite by 0.782, i.e. noise; 328 (6.4 vs scene 250's 6.1) measured 6.21. Scene 250 has no skybox, but its PBR windows reflect the environment map, and scene 250's known Lite gap (over-bright window reflections, absent in Babylon.js) moves with the mirrored view onto the larger right tower: Lite right- vs left-handed differ by 7.5, Babylon.js by 5.3.

Also:

  • tests/lite/unit/right-handed-system.test.ts and right-handed-seams.test.ts — view matrices against Babylon.js LookAtRH, camera world matrices and per-seam values (shader-material system uniforms included). Front-face winding of glTF against procedural geometry on Standard, Node and shader materials is asserted by the "glTF winding on non-PBR materials" pairs in right-handed-pairs.test.ts.
  • tests/lite/unit/coordinate-system-variants.test.ts — both variants and the imports map.
  • The build test project bundles the published build/lib, where #coordinate-system stays a bare specifier. Bare Rollup does not read imports, so its harness resolves the specifier with packageImportsPlugin() (tests/lite/build/bundler-harness.ts), as @rollup/plugin-node-resolve does for a consumer; webpack reads the map natively.