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-rhvariant)
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:
| Condition | Variant | Handedness |
|---|---|---|
| (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 —
getViewMatrixreturnsSz·V_RH(third row negated). The projection stays the left-handed one; since Babylon.js's right-handed projections are the left-handed ones timesSz, clip space equals Babylon.js'sP_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 instd-mirrored-support.tsis 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 (|ccwvariant 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, theenableMirroredMeshes()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,computeNodeWorldMatrixdrops 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._authoredSignstays-1: it is relative to the left-handed rule, which the right-handed winding instd-mirrored-support.tsalready inverts. Gaussian splats fromKHR_gaussian_splattingreplace the left-handedrotation.z = π(which, combined with the root mirror, equals a Y mirror) withscaling.y = -1, since there is no root mirror to combine with. -
Environment —
rotateYin the PBR IBL helpers (and the mesh-LoD copy) negates Z after the rotation, like Babylon.js'sREFLECTIONMAP_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/Yto the handedness. Standard and node materials keep their frames: Babylon.js flips those only on explicit options. -
Everything else — each Babylon.js
useRightHandedSystembranch 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-materialView/Projectioninputs and PBR helpers, billboard and billboard-picking texture U, camera inputs, gizmos, the geospatial camera,.babyloncameras, 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 (itscrossoperands 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_DEPTHis signed like Babylon.js's right-handed view z (negative in front of the camera), butNORMALIZED_VIEW_DEPTHstays 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 ownVIEW_DEPTHconsumers 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—envYawFromQuaternionreturns the left-handed yaw −A in both builds, so a right-handed build samples the environment alongSz·Ry(−A)·d. Babylon.js's loader inverts the extension's rotation only when left-handed, i.e. samplesSz·Ry(A)·dwhen 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 && { … })andif (_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.tsfails 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
rawBytesinlab/public/bundle/master-manifest.json. - Add a pair test to
right-handed-pairs.test.tsfor 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.tsreduces every_rightHandeddecision 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 withvitest -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.tsruns the same input through a left-handed and a right-handed build of the engine (right-handed-builds.tsswaps the#coordinate-systemmodule) 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.tsfails 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-systemor being exempt, and when a file importing it is neither imported by aright-handed-*.test.ts, nor rendered by a twin scene listed inRH_TWIN_COVERAGE, nor declared as a known gap inRH_DRIFT_ONLY. - Right-handed twin scenes (
"rightHanded": trueinscene-config.json, 321–347) — parity against Babylon.js withuseRightHandedSystem = true. Their Lite wrapper and Babylon.js reference only import the source scene (the reference throughlab/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.tsreplaces the zero refraction matrix of an unrotatedCubeTexture(twin 334). Twin 342 keeps scene 265's left-handed reference (BJS_RH_BROKEN) because Lite does not portEXT_lights_image_basedyet (see above). Like every parity spec, they compare against their committedbabylon-ref-golden.pngand never regenerate it during a run (RECAPTURE_GOLDENis 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
maxMadthan 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.tsandright-handed-seams.test.ts— view matrices against Babylon.jsLookAtRH, 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 inright-handed-pairs.test.ts.tests/lite/unit/coordinate-system-variants.test.ts— both variants and theimportsmap.- The
buildtest project bundles the publishedbuild/lib, where#coordinate-systemstays a bare specifier. Bare Rollup does not readimports, so its harness resolves the specifier withpackageImportsPlugin()(tests/lite/build/bundler-harness.ts), as@rollup/plugin-node-resolvedoes for a consumer; webpack reads the map natively.