Module: Loaders (glTF + OpenUSD + .env + HDR + .babylon + Skybox + Splats)
Package paths:
packages/babylon-lite/src/loader-gltf/load-gltf.ts— GLB 2.0 loaderpackages/babylon-lite/src/loader-gltf/gltf-feature-camera.ts— glTFcameranode property (core spec, not an extension)packages/babylon-lite/src/loader-gltf/gltf-ext-basisu.ts— glTFKHR_texture_basisufeature modulepackages/babylon-lite/src/loader-gltf/gltf-feature-meshopt.ts+meshopt-decode.ts—EXT_meshopt_compressionfeature module + decoderpackages/babylon-lite/src/loader-gltf/gltf-ext-quantization.ts—KHR_mesh_quantizationfeature modulepackages/babylon-lite/src/loader-gltf/gltf-feature-xmp.ts—KHR_xmp_json_ldmetadata feature modulepackages/babylon-lite/src/loader-gltf/gltf-feature-extras.ts—ExtrasAsMetadatafeature modulepackages/babylon-lite/src/loader-gltf/gltf-interleave.ts— dynamic native interleaved-vertex-buffer support (de-strided CPU copies built lazily on demand)packages/babylon-lite/src/loader-gltf/gltf-share.ts— duplicate-primitive CPU/GPU geometry sharingpackages/babylon-lite/src/loader-usd/— OpenUSD command-buffer loader (see loader-usd.md)packages/babylon-lite/src/loader-env/load-env.ts— Babylon .env environment loaderpackages/babylon-lite/src/loader-env/load-dds-env.ts— DDS cubemap environment loaderpackages/babylon-lite/src/loader-env/env-helpers.ts— Shared environment assembly helperspackages/babylon-lite/src/loader-env/rgbd-decode.ts— Shared RGBD PNG/cubemap decode (GPU compute)packages/babylon-lite/src/loader-hdr/load-hdr.ts— HDR panorama environment loaderpackages/babylon-lite/src/loader-hdr/hdr-parser.ts— RGBE CPU parser + SH extractionpackages/babylon-lite/src/loader-hdr/hdr-ibl-pipeline.ts— GPU compute IBL pipelinepackages/babylon-lite/src/loader-babylon/load-babylon.ts— .babylon scene format loaderpackages/babylon-lite/src/loader-skybox/load-skybox.ts— Cube texture skybox loaderpackages/babylon-lite/src/loader-skybox/skybox-renderable.ts— Skybox renderable builderpackages/babylon-lite/src/loader-splat/— Gaussian splat loaders (.ply,.splat,.sog,.spz)
Purpose
The Loaders module provides asset loading pipelines plus dynamic glTF feature modules:
-
glTF Loader — Parses
.glb/.gltf2.0 files, dynamically imports feature modules based onextensionsUsedand material/primitive content, extracts mesh geometry (positions, normals, tangents, UVs, indices), resolves the node hierarchy to compute world matrices with RH→LH conversion, extracts PBR metallic-roughness material data (textures + factors), uploads everything to GPU buffers and textures with mipmaps. Optional features such asKHR_texture_basisulive in separate dynamic modules so assets that do not use them pay zero runtime bytes. -
Environment Loader (.env) — Parses Babylon.js
.envfiles, decodes RGBD-encoded specular cubemap faces torgba16float, decodes a pre-baked BRDF integration LUT from an RGBD-encoded PNG via GPU compute, extracts spherical harmonics irradiance coefficients, and uploads everything to GPU textures. -
DDS Environment Loader — Loads pre-filtered DDS cubemap environments (rgba16float). Uploads all mip levels directly, computes spherical harmonics from mip 0 face data, and decodes a pre-baked BRDF LUT from a PNG via GPU compute.
-
HDR Environment Loader — Loads Radiance
.hdr(RGBE) equirectangular panoramas. CPU-parses RGBE data, computes spherical harmonics, converts equirect→cubemap via GPU compute, prefilters with importance-sampled GGX via GPU compute, generates BRDF LUT via GPU compute. -
.babylon Format Loader — Parses Babylon.js
.babylonscene files. Supports standard materials (diffuse, bump, specular, ambient, lightmap, opacity, reflection textures), inline vertex data, point lights, scene clear color, and sub-mesh / multi-material handling. -
Skybox Loader — Loads 6-face cube texture skyboxes for StandardMaterial scenes. Registers a deferred builder that creates the pipeline at engine start time.
-
Gaussian Splat Loaders — Load
.ply,.splat,.sog, and.spzsplat assets intoGaussianSplattingMeshinstances. SOG handles ZIP-packed WebP payloads; SPZ handles gzip-wrapped binary streams. Transform baking helpers and material shader fragments are exposed separately so non-splat scenes pay zero runtime cost. -
OpenUSD Loader — Loads composed
.usd,.usda,.usdc, and.usdzstages through the same protocol-v5 OpenUSD WebAssembly extractor used by Babylon.js. The worker returns command/data buffers that are validated and materialized directly into Lite scene nodes, PBR materials, shared geometry, thin instances, skeletons, morph targets, and animation groups. See the dedicated USD loader architecture for the virtual-file and ownership contracts.
Public API Surface
asset-container.ts
/** Unified result returned by both loadGltf() and loadBabylon(). */export interface AssetContainer { /** * Scene entities with world transforms (meshes, transform nodes, lights). * - glTF: single-element [root TransformNode]; meshes live in its hierarchy. * - .babylon: root SceneNodes + LightBase objects in the file. */ entities: Array<SceneNode | LightBase>;
/** Animation groups from the file. addToScene() auto-ticks them each frame. */ animationGroups?: AnimationGroup[];
/** Scene clear color from the file. addToScene() applies it to ctx.clearColor. */ clearColor?: GPUColorDict;
/** Camera parsed from the file. addToScene() sets it as scene.camera when present. */ camera?: Camera;
/** Every camera declared by the glTF `cameras` array and referenced by a `node.camera` * index, in node-encounter order. See "glTF `camera` Node Property" below. Unlike * `camera`, addToScene() never auto-activates one of these — pick one explicitly and * assign it to `scene.camera`. */ cameras?: Camera[];
/** KHR_materials_variants data. Use selectVariant() / getVariantNames() to interact. */ materialVariants?: MaterialVariantData;
/** Bone-control handles (one per glTF skin). Populated only after enableBoneControl(); * drive bones via getBoneByName() + setBone*(). See module 13 (Skeleton). */ skeletons?: Skeleton[];}Compressed-geometry decoder base URLs (draco-decode.ts, meshopt-decode.ts)
/** Override where draco_decoder.js / draco_decoder.wasm are fetched (default: site root "/"). */export function setDracoBaseUrl(url: string): void;/** Override where meshopt_decoder.js is fetched (default: site root "/"). */export function setMeshoptBaseUrl(url: string): void;Both decoders lazy-load their glue/WASM via <script> injection on first use, so non-Draco /
non-meshopt scenes pay zero bytes. Call the setter before loading an asset that triggers the
codec to self-host the decoder (e.g. avoid a cross-origin CDN). Equivalent to KTX2's
setKtx2DecoderUrl.
load-gltf.ts
/** Parsed mesh data ready for GPU upload. */export interface GltfMeshData { positions: Float32Array; normals: Float32Array; tangents: Float32Array | null; uvs: Float32Array; indices: Uint16Array | Uint32Array; vertexCount: number; indexCount: number; worldMatrix: Mat4; material: GltfMaterialData;}
/** Parsed PBR material data. */export interface GltfMaterialData { baseColorFactor: [number, number, number, number]; metallicFactor: number; roughnessFactor: number; emissiveFactor: [number, number, number]; baseColorImage: ImageBitmap | null; metallicRoughnessImage: ImageBitmap | null; normalImage: ImageBitmap | null; occlusionImage: ImageBitmap | null; emissiveImage: ImageBitmap | null;}
/** Load a glTF/GLB asset from a URL, ArrayBuffer, or Blob; parse it, upload to GPU. Returns an AssetContainer. */export async function loadGltf(engine: EngineContext, source: string | ArrayBuffer | Blob): Promise<AssetContainer>;
/** Enable camera import for subsequent loadGltf calls. */export function enableGltfCameras(): void;Note:
loadGltftakes anEngine(notSceneContext) and returns anAssetContainer. The result'sentitiesarray contains root scene entities; glTF meshes usually hang off a rootTransformNodehierarchy. Pass the result toaddToScene(scene, result)— it will traverse the hierarchy, register animation ticks, and integrate everything into the scene. Meshes are the standardMeshtype with GPU data in the_gpufield and bounding box onMesh.boundMin/Mesh.boundMax. Renderable mesh names preserve source glTFmesh.namewhen present; parent transform names still preserve glTFnode.name.Local data:
sourcemay be a URLstring, or anArrayBuffer/Blobof an already-loaded asset (drag-and-drop, OPFS, afetchbody, etc.). GLB-vs-glTF is detected from the data's magic bytes, not the URL extension, so object URLs (blob:…) and extensionless sources load correctly.ArrayBuffer/Blobinputs and opaqueblob:/data:URL strings have no directory base, so they must be self-contained (a GLB, or a glTF whose buffers/images usedata:URIs); a glTF referencing external.bin/image files by relative path must be loaded from a URL with a resolvable base.
load-env.ts
/** GPU-resident environment textures. */export interface EnvironmentTextures { specularCube: GPUTexture; specularCubeView: GPUTextureView; brdfLut: GPUTexture; brdfLutView: GPUTextureView; cubeSampler: GPUSampler; brdfSampler: GPUSampler; irradianceSH: Float32Array; sphericalHarmonics: { l00: Float32Array; l1_1: Float32Array; l10: Float32Array; l11: Float32Array; l2_2: Float32Array; l2_1: Float32Array; l20: Float32Array; l21: Float32Array; l22: Float32Array; };}
/** Load a Babylon.js .env file, upload cubemap + BRDF LUT to GPU. */export async function loadEnvironment( scene: SceneContext, url: string, options: { brdfUrl: string; // Required: URL of pre-baked BRDF LUT PNG (RGBD-encoded) groundTextureUrl?: string; // Optional: URL of ground texture skipSkybox?: boolean; // Default: false — skip skybox renderable skipGround?: boolean; // Default: false — skip ground plane skyboxUrl?: string; // Override skybox texture URL skyboxSize?: number; // Default: 1000 — skybox cube half-size }): Promise<EnvironmentTextures>;procedural-sky-environment.ts
export interface ProceduralSkyEnvironmentOptions { readonly sunDirection: readonly [number, number, number]; readonly luminance: number; readonly turbidity: number; readonly rayleigh: number; readonly mieCoefficient: number; readonly mieDirectionalG: number;}
export interface ProceduralSkyEnvironmentLoadOptions extends ProceduralSkyEnvironmentOptions { readonly brdfUrl: string;}
export interface ProceduralSkyEnvironment { // GPU state is internal and removed from the public declaration.}
export function computeProceduralSkySunColor(options: ProceduralSkyEnvironmentOptions): [number, number, number];export function loadProceduralSkyEnvironment(scene: SceneContext, options: ProceduralSkyEnvironmentLoadOptions): Promise<ProceduralSkyEnvironment>;export function updateProceduralSkyEnvironment(environment: ProceduralSkyEnvironment, options: ProceduralSkyEnvironmentOptions): Promise<boolean>;This opt-in loader creates the scene's initial environment before registration and loads only the
BRDF LUT supplied by brdfUrl; it rejects existing environments and registered scenes rather than
silently replacing bound cube views. It renders Babylon.js SkyMaterial atmospheric radiance into
a 128×128 six-face rgba16float reflection-probe cube, generates ordinary face mipmaps, sets
lodGenerationScale = 0, and computes the probe spherical polynomial with the same cubemap
solid-angle integration, render-target row orientation, cosine convolution, Lambert normalization,
and polynomial conversion as Babylon.js. Probe pixels are converted to linear space before storage
because the reflection target has gammaSpace = false.
Updates preserve the environment, cube-view, and spherical-harmonics array identities. Irradiance
integration yields after fixed eight-row chunks and uses latest-wins revision cancellation; only a
completed current calculation atomically submits the cube update and publishes matching diffuse
coefficients. The boolean result is false when a newer update supersedes the call.
Loading reserves a scene-local generation before the first await; a concurrent second
load is rejected. The generation and scene state are checked after asynchronous
irradiance/BRDF work and after the lazy decoder import. Disposal, scene registration,
or a competing environment cancels the pending load. Its bitmap and locally allocated
textures/buffer are released, including a bitmap that arrives after another operation
has already failed. Failed loads remove their reservation so a live scene can retry.
The pending cleanup is registered before loading starts and becomes the committed
environment's cleanup without appending to an already-disposed scene. Cleanup marks
the returned handle disposed before releasing resources. Updates reject both disposed
handles and handles no longer owning the scene's environment, including disposal during
irradiance integration; cancellation by a newer update alone still returns false.
Each operation snapshots its options so its GPU parameters and irradiance describe the
same request even if caller-owned input is subsequently edited.
The Henyey-Greenstein evaluation clamps its directional parameter to [-0.999, 0.999] and its
cosine input to [-1, 1], keeping the public ±1 endpoints finite in both CPU and WGSL paths.
Internal Architecture
glTF Loader Pipeline
fetch(url) → ArrayBuffer ↓parseGlbContainer(buffer) ↓{ json, binChunk: DataView } ↓loadFeatureModules(json) // dynamic imports, e.g. KHR_texture_basisu ├── preMesh hooks // Draco, KTX2 strided FLOAT accessor decode, etc. └── material hooks // feature-owned texture/material/metadata overrides ↓extractAllMeshes(json, binChunk) // for each node with mesh ├── resolveAccessor() × N // positions, normals, tangents, UVs, indices ├── extractMaterial() // PBR factors + textures │ └── resolveImage() × 5 // parallel image decode └── computeNodeWorldMatrix() // recursive parent chain + RH→LH root ↓GltfMeshData[] ↓uploadMeshes(device, meshDatas) ├── repeated-primitive gate // dynamically imports gltf-share only when needed │ ├── canonicalize CPU geometry // repeated active nodes retain the same arrays │ └── primitive GPU cache // one MeshGPU upload retained by every active owner ├── shared mesh builders // identical instance assembly on normal/shared paths ├── uploadTexture() × 4 // → Texture2D objects (cached per bitmap + sRGB) ├── runMatExts() // feature-owned material overrides, e.g. KTX2 textures ├── createBufferFromData() × 5 // pos, norm, tan, uv, idx ├── computeWorldBounds() // world-space AABB └── assemble PbrMaterialProps // { baseColorTexture, normalTexture, ormTexture, emissiveTexture?, _buildGroup: pbrGroupBuilder } ↓Mesh[] + root TransformNode ↓createAnimationGroups(json, ...) // extract glTF animations → AnimationGroup[] ↓AssetContainer { entities: [root], animationGroups } → returned to caller; addToScene() dispatches entities + registers animation ticksTexture caching: Textures are cached per bitmap identity + sRGB flag to avoid duplicate GPU uploads. The hot-path cache uses a numeric key (bitmapId * 2 + +srgb) so plain-image glTF assets do not pay string-key overhead. Feature modules can maintain their own caches for extension-owned image sources.
Geometry sharing: A glTF mesh may be instantiated by multiple nodes. The loader creates a distinct Lite Mesh for every node/primitive pair so transforms, bounds, metadata, winding, skins, and morph state remain independent. Normal and repeated-primitive paths use the same internal mesh builders; the lazy gltf-share module only canonicalizes immutable geometry, manages ownership, and installs shared recovery. Instances reachable from the selected/default glTF scene share one MeshGPU and the same retained CPU attribute arrays when they reference the same immutable primitive. Nodes reachable only from inactive scenes are excluded from that shared ownership group, so they do not increment the active geometry's _refCount or pin its buffers. MeshGPU ownership is reference-counted so removing one active instance cannot dispose buffers still used by another; device-lost recovery rebuilds each shared geometry only once and preserves the ownership count.
Animation support: loadGltf extracts glTF animations, creates AnimationGroup[] via createAnimationGroups(), and returns them in AssetContainer.animationGroups. addToScene() registers playback with the scene-owned animation manager. Each group exposes currentTime (seconds), goToFrame() for frame-based seeking, and lightweight targetedAnimations metadata for inspecting affected node/path pairs.
glTF metadata: ExtrasAsMetadata promotes source node, mesh, primitive, and material extras payloads to metadata.gltf.extras on supported runtime objects. It is implemented as a glTF feature module so scenes without metadata do not pay for the metadata-copying code.
PBR materials: Each PbrMaterialProps created during upload includes _buildGroup: pbrGroupBuilder, imported from pbr-material.ts.
GLB Container Format
Offset 0: Header (12 bytes) [0..3] magic: 0x46546C67 ("glTF" LE) [4..7] version: 2 [8..11] total length
Offset 12: JSON Chunk [0..3] chunkLength [4..7] chunkType: 0x4E4F534A ("JSON" LE) [8..] UTF-8 JSON
Offset 12+8+jsonLength: BIN Chunk [0..3] chunkLength [4..7] chunkType: 0x004E4942 ("BIN\0" LE) [8..] Binary dataAccessor Resolution
Supports component types:
| Constant | Value | TypedArray |
|---|---|---|
FLOAT | 5126 | Float32Array |
UNSIGNED_SHORT | 5123 | Uint16Array |
UNSIGNED_INT | 5125 | Uint32Array |
UNSIGNED_BYTE | 5121 | Uint8Array |
Type → component count:
| Type | Components |
|---|---|
SCALAR | 1 |
VEC2 | 2 |
VEC3 | 3 |
VEC4 | 4 |
MAT4 | 16 |
Byte offset = bufferView.byteOffset + accessor.byteOffset (both default to 0).
RH→LH Coordinate Conversion
glTF uses right-handed coordinates. Babylon Lite uses left-handed. The conversion is done via a root world matrix pre-multiply (not by negating Z in vertex data):
// Root matrix: diag(-1, 1, 1, 1) — negates X axisconst RH_TO_LH_ROOT: Mat4 = [-1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];For top-level nodes: worldMatrix = RH_TO_LH_ROOT × localMatrix.
For child nodes: worldMatrix = parentWorldMatrix × localMatrix.
Local matrices are computed from glTF TRS: composeMat4(translation, rotation, scale), or directly from node.matrix if present.
Parent lookup is done by linear scan (findParent): iterates all nodes checking children arrays.
glTF camera Node Property (gltf-feature-camera.ts)
Core glTF 2.0 spec §5.20 (not an extension) — a node may reference cameras[i] via node.camera.
Feature id _camera, registered only when the caller invokes enableGltfCameras() and then
triggered when an asset declares cameras. The generic gltf-feature-hooks.ts seam lets the core
loader ask whether any explicitly enabled feature matches without knowing camera semantics; Rollup
folds the seam away when no enabler is imported.
Reproduces cx20 gltf-test's :warning: embedded camera gap for Babylon Lite: before this feature,
loadGltf silently dropped node.camera and exposed no way to select one of an asset's embedded
cameras. Every camera referenced by a node.camera index is instantiated and returned via
AssetContainer.cameras (node-encounter order), named from the glTF camera definition or
camera{index} when unnamed; AssetContainer.camera (singular) is untouched.
Per camera, for each node with node.camera !== undefined:
fixupNode— aTransformNode(createTransformNode) inserted between the source node and the camera (never mutating the shared source node — a sibling mesh on the same glTF node, if any, must keep its own winding/scale):- Handedness. The synthetic
__root__node'sscale.x = -1(RH→LH conversion, above) flips a camera's chirality — a mirrored (negative-determinant) camera world matrix renders an inside-out view. Babylon.js's own glTF loader hits the same issue and fixes it by settingscaling.x = -1on the camera's hostingTransformNode(glTFLoader.tsloadNodeAsync). fixupNode's scale is(-1/s, 1/s, 1/s), wheresis the accumulated static uniform rest-pose scale. This cancels inherited scale while preserving live translation/rotation.
- Handedness. The synthetic
- Parent.
fixupNode.parent = nodeMap[nodeIdx]when the node is reachable from a scene root (the common case — node TRS animation, classic channels orKHR_animation_pointer, then drives the camera every frame through the normal parent chain). Falls back tocreateSceneNodeFromMatrix(name, restWorld)when unreachable, mirroring theKHR_lights_punctualfallback for the same case. - Camera.
createFreeCamera({0,0,0}, {0,0,-1}), parented tofixupNode. glTF cameras look down their local -Z axis with +Y up;writeLookAtWorldMat4LHIntoBuffer's "+Z points from eye to target" convention reproduces exactly that local orientation for an eye at the origin looking toward(0,0,-1). The fixup also cancels static uniform ancestor scale so the shared rigid view inverse remains exact. Projection parameters stay in source glTF units, matching Babylon.js. Zero/non-uniform or animated ancestor scale is rejected/unsupported rather than rendered with a silently wrong view. - Projection.
perspective.yfov → fov,.znear → nearPlane,.zfar → farPlane(substituting a large sentinel,1e6, whenzfaris omitted — glTF's "infinite" convention). Orthographic cameras lazy-import()enableOrthographicCamera(only whendef.type === "orthographic", so a perspective-only asset never pays for it) and mapxmag/ymagto symmetric{ left: -xmag, right: xmag, bottom: -ymag, top: ymag }bounds.
Known limitation: once camera loading is enabled, the feature processes every camera declared
by the asset. An asset with an orthographic camera therefore pulls in camera/orthographic.js even
if the consumer selects a different perspective camera. glTF camera-property animation
(KHR_animation_pointer targeting /cameras/{}/perspective/yfov etc.) is not wired — only the
hosting NODE's transform animates; the camera's own intrinsics (fov/near/far/ortho bounds) are fixed
at load time.
Texture Upload
uploadTexture(device, bitmap, srgb, sampler) returns a Texture2D (with texture, view, sampler, width, height).
| Texture | sRGB | Format | Created when |
|---|---|---|---|
baseColor | Yes | rgba8unorm-srgb | Always (fallback 1×1 white) |
normal | No | rgba8unorm | Always (fallback 1×1 white) |
ORM | No | rgba8unorm | Always (fallback 1×1 white) |
emissive | Yes | rgba8unorm-srgb | Only if glTF has emissive image |
sRGB textures use rgba8unorm-srgb format so the GPU performs exact sRGB→linear conversion on sample. All textures get full mip chains via generateMipmaps().
ORM packing follows glTF convention:
- R = Ambient Occlusion
- G = Roughness
- B = Metallic
If only metallicRoughnessImage or occlusionImage is available, it's used for the ORM texture (they may be the same image in glTF).
KHR_texture_basisu / KTX2 Texture Sources
The KHR_texture_basisu implementation is a glTF feature module, not core loader logic:
extensionsUsed includes "KHR_texture_basisu" ↓dynamic import("./gltf-ext-basisu.js") ↓preMesh(json, binChunk, baseUrl) ├─ marks materials that reference KTX2 images ├─ strips KTX2 textureInfos before core image parsing └─ deinterleaves strided FLOAT vertex accessors when needed by the KTX2 asset ↓core material parse runs with non-KTX2 textureInfos only ↓applyMaterial(mat, ctx) ├─ fetches KTX2 bytes from image.uri or bufferView ├─ uploadKtx2Texture2D(ctx.engine, bytes, sRGB) ├─ composes ORM when metallic-roughness and occlusion are distinct KTX2 images └─ returns Partial<PbrMaterialProps> with feature-owned Texture2D valuesDesign constraints:
- No
KHR_texture_basisubranches in the core material parser or PBR renderer. ktx2-loader.tsis reached only throughgltf-ext-basisu.ts.- The Babylon KTX2 decoder script is loaded lazily after a KTX2 asset is encountered.
- Core texture cache keys remain image-bitmap based; KTX2 feature caches by glTF texture index and sRGB flag.
- Scene 112 (
FlightHelmetKTX) validates the path and keeps existing scene runtime bundle sizes unchanged.
Interleaved Vertex Buffers (gltf-interleave.ts)
glTF allows multiple vertex attributes to share one bufferView with a non-zero
byteStride (interleaved layout). Babylon Lite supports this at the GPU level
rather than rewriting the asset:
primitive has a strided (byteStride > 0), non-decoded accessor ↓dynamic import("./gltf-interleave.js") // never fetched by tight-only scenes ↓buildInterleavedPartial(json, binChunk, attrs) ├─ records each strided attribute's { bufferView slice, offset, stride } └─ resolves tight attributes directly ↓uploadMeshes binds the ONE raw bufferView slice to every attribute slot at theattribute's byte offset with pipeline arrayStride = byteStrideDesign constraints:
- No CPU de-interleave / asset rewrite. The raw interleaved bytes are uploaded once and bound to each slot — the GPU does the striding.
- De-strided CPU copies are lazy.
installLazyCpu()defines_cpuPositions/_cpuNormals/_cpuUvsas caching getters that de-stride on first access; a mesh that is never picked / CSG'd / navigated never materializes them. - Zero cost to non-interleaved scenes. The whole module is dynamic-imported and
only loaded when a genuinely-strided, non-decoded primitive is encountered. Decoded
paths (Draco,
KHR_texture_basisude-stride) bypass it. - Validated by Scene 210 (
XmpMetadataRoundedCube, genuinely interleaved).
Per-attribute correctness (each was a real parity bug — Scenes 246/247):
COLOR_0cannot be GPU-strided directly: glTF permits VEC3/VEC4 and/or normalizedUNSIGNED_BYTE/SHORT, but the pipeline binds a singlefloat32x4layout. Binding a ubyte/VEC3 source asfloat32x4reads adjacent bytes as floats (rainbow garbage).resolveColorVec4always de-strides + normalizes COLOR_0 to a tightfloat32x4(rgb modulates base color, a modulates fragment alpha — vertex-color alpha-clip/blend; a VEC3 source getsa = 1).- Absent
NORMALmust be synthesized, never zero-filled — a zero normal yieldsnormalize(0)= NaN → pure-black lit fragments.buildInterleavedPartialcalls the caller-suppliedcomputeSmoothNormals(lazily imported only when NORMAL is missing). The mesh is also tagged_flatNormalso the PBR shader flat-shades it via screen-spaceworldPosderivatives (glTF spec: no-NORMAL→ flat), matching BJS — seematerial/pbr/fragments/flat-normal-wgsl.ts(lazily loaded; zero bytes otherwise). JOINTS_0/WEIGHTS_0are read bygltf-feature-skeleton.ts, not this module, viaresolveAccessor— which assumes tight packing. Skinned rigs that interleave them with abyteStrideare de-strided there (resolveAttr), or half the joint indices/weights come from padding → exploded / mis-posed mesh.
EXT_meshopt_compression + KHR_mesh_quantization (gltf-feature-meshopt.ts, gltf-ext-quantization.ts)
EXT_meshopt_compression bufferViews are decoded by a dynamically-imported meshopt
decoder (meshopt-decode.ts) before accessor resolution; KHR_mesh_quantization
lets normalized/quantized attribute formats upload natively. Both are dynamic feature
modules, so non-meshopt scenes pay zero runtime bytes. Validated by Scene 211
(BrainStem glTF-Meshopt-EXT, skinned + animated).
Unnormalized quantized TEXCOORD_n/POSITION (Scene 220, Duck glTF-Quantized).
KHR_mesh_quantization allows TEXCOORD_n (VEC2) and POSITION (VEC3) to be
unnormalized unsigned-integer accessors (no normalized: true) — per the extension
spec, an unnormalized integer 2 means the literal value 2.0, not 2/65535; the asset
then rescales it back to real units via a node TRS (POSITION) or a KHR_texture_transform
on the material (TEXCOORD_n; gltfpack's standard quantized-UV output). The core loader's
tight/interleave UV and vertex paths always assume an unsigned-int source means "divide by
255/65535", so this class of accessor must never reach them unconverted.
The fix lives entirely inside gltf-ext-quantization.ts's preParse rewrite (not the core
UV/color decoders): the trigger predicate was widened from "unsigned non-normalized integer,
NOT VEC4, AND strided" to "unsigned non-normalized integer VEC2/VEC3, tight OR strided" —
per the extension's attribute table, unnormalized unsigned-int storage is only valid for
those two shapes, so SCALAR (indices) and VEC4 (JOINTS_n) stay correctly excluded
regardless. This is what catches the quantized Duck's TEXCOORD_0: a tight
UNSIGNED_SHORT VEC2 accessor (byteStride equal to its own tight size) that the old
stride-gated predicate let through unconverted. Because the rewrite happens in preParse
— before any accessor is read — every unnormalized integer TEXCOORD/POSITION is already
FLOAT by the time load-gltf.ts, gltf-color-normalize.ts, gltf-interleave.ts, and
gltf-uv-denorm.ts see it, so none of those core/shared modules need to know about
normalized at all: they keep their original "integer ⇒ normalized" assumption, which is
now always true for whatever integer data reaches them. This keeps the fix's entire byte
footprint inside the already dynamic-imported KHR_mesh_quantization feature — zero
bytes added to any module fetched by scenes that don't use the extension. Validated by
Scene 220 (Duck glTF-Quantized, non-normalized UNSIGNED_SHORT VEC2 TEXCOORD_0
combined with KHR_texture_transform) and by gltf-ext-quantization.test.ts, which
exercises the preParse hook directly for the tight-unnormalized, strided-unnormalized,
still-normalized, SCALAR-index, and VEC4-joints cases.
KHR_xmp_json_ld Metadata (gltf-feature-xmp.ts)
Pure metadata with no render effect: the feature's applyAsset hook surfaces the
document-level JSON-LD packets (and the asset-referenced packet) on
AssetContainer.xmpMetadata = { packets, assetPacket }. Dynamic-imported only when
extensionsUsed lists KHR_xmp_json_ld. Validated by Scene 210.
Bounding Box Computation
World-space AABB is computed by transforming every vertex position through the world matrix:
for each vertex (lx, ly, lz): wx = world[0]*lx + world[4]*ly + world[8]*lz + world[12] wy = world[1]*lx + world[5]*ly + world[9]*lz + world[13] wz = world[2]*lx + world[6]*ly + world[10]*lz + world[14] update min/maxShared Sampler
One sampler is created and shared across all Texture2D objects within a single uploadMeshes() call: magFilter: linear, minFilter: linear, mipmapFilter: linear, addressMode: repeat (both U and V). The sampler is stored inside each Texture2D.sampler.
Environment Loader Pipeline
fetch(url) → ArrayBuffer ↓parseEnvFile(buffer) ├── Validate 8-byte magic: [0x86, 0x16, 0x87, 0x96, 0xf6, 0xd6, 0x96, 0x36] ├── Parse JSON manifest (UTF-8, null-terminated after magic) ├── Extract irradiance SH (9 vec3 = 27 floats from manifest.irradiance) └── Extract face image blobs (mip0_face0..5, mip1_face0..5, ...) ↓{ faceBlobs[], irradianceSH, width, mipCount } ↓createImageBitmap() × N faces (parallel, premultiplyAlpha:'none', colorSpaceConversion:'none') ↓uploadCubemapRGBD(device, images, width, mipCount) ↓GPUTexture (rgba16float cubemap) ↓fetch(options.brdfUrl) + decodeBrdfPng(device, png) → 256×256 rgba16float BRDF LUT (GPU compute) ↓polynomialToPreScaledHarmonics(irradianceSH) → pre-scaled SH for shader ↓EnvironmentTextures → stored on scene._envTextures.env File Format
[0..7] Magic: 86 16 87 96 F6 D6 96 36[8..N] JSON manifest (UTF-8, null terminated)[N+1..] Binary image data (PNG/JPEG face images)JSON manifest fields:
width: base cubemap face sizeirradiance: object with keysx,y,z,xx,yy,zz,yz,zx,xy→ each is[r,g,b]specular.mipmaps: array of{ position, length }byte rangesimageType: MIME type (default"image/png")
RGBD Decoding
Each face image is RGBD-encoded. Decoding to linear HDR:
r_linear = pow(r_srgb, 2.2) / max(alpha, 1/255)g_linear = pow(g_srgb, 2.2) / max(alpha, 1/255)b_linear = pow(b_srgb, 2.2) / max(alpha, 1/255)a_out = 1.0The process uses GPU staging to avoid Canvas 2D premultiplied-alpha corruption:
- Upload
ImageBitmap→ temprgba8unormtexture - Copy texture → staging buffer (256-byte aligned rows)
- Map staging buffer for CPU read
- Decode RGBD on CPU with Y-flip (Babylon uploads with
invertY=true) - Upload decoded
float16data to finalrgba16floatcubemap layer
Float16 Conversion (floatToHalf)
IEEE 754 binary16 conversion via bit manipulation:
sign = (float32_bits >>> 16) & 0x8000exponent = ((float32_bits >>> 23) & 0xFF) - 127 + 15mantissa = (float32_bits >>> 13) & 0x03FFHandles denormalized numbers, overflow (→ infinity), and NaN.
BRDF LUT Generation
Note on
.envand DDS loaders: Environment loaders no longer CPU-compute the BRDF LUT. They decode a pre-baked BRDF LUT from an RGBD-encoded PNG provided viaoptions.brdfUrl, using GPU compute inrgbd-decode.ts. The CPU algorithm below applies to the HDR loader (hdr-ibl-pipeline.ts) only.
GPU compute split-sum integration (256×256, rgba16float, HDR path):
For each texel (x, y):
NdotV = max((x + 0.5) / 256, 0.001)roughness = max((y + 0.5) / 256, 0.04)[A, B] = integrateBRDF(NdotV, roughness, 1024 samples)Output convention (Babylon):
- R =
B(Fresnel bias) - G =
A + B(scale + bias) - Shader usage:
F0 × A + B = F0 × (brdf.g - brdf.r) + brdf.r
integrateBRDF Algorithm
Hammersley sequence + importance-sampled GGX:
for i in 0..1024: xi0 = i / sampleCount xi1 = radicalInverseVdC(i) // Van der Corput H = importanceSampleGGX(xi0, xi1, roughness⁴) VdotH = max(V·H, 0) Lz = 2 × VdotH × H.z - V.z // reflect(-V, H).z = NdotL NdotL = max(Lz, 0) NdotH = max(H.z, 0)
if NdotL > 0 and NdotH > 0: // Smith height-correlated visibility GGXV = NdotL × √(NdotV² × (1-a2) + a2) GGXL = NdotV × √(NdotL² × (1-a2) + a2) V_Vis = 0.5 / max(GGXV+GGXL, 1e-6) × NdotL × 4×VdotH/NdotH Fc = (1 - VdotH)⁵ A += (1 - Fc) × V_Vis B += Fc × V_Vis
return [A/1024, B/1024]importanceSampleGGX
phi = 2π × xi0cosTheta = √((1 - xi1) / (1 + (a2 - 1) × xi1))sinTheta = √(1 - cosTheta²)return [cos(phi) × sinTheta, sin(phi) × sinTheta, cosTheta]radicalInverseVdC
Van der Corput radical inverse (bit reversal):
bits = input >>> 0bits = ((bits << 16) | (bits >>> 16)) >>> 0bits = ((bits & 0x55555555) << 1) | ((bits & 0xAAAAAAAA) >>> 1) // swap odd/evenbits = ((bits & 0x33333333) << 2) | ((bits & 0xCCCCCCCC) >>> 2) // swap pairsbits = ((bits & 0x0F0F0F0F) << 4) | ((bits & 0xF0F0F0F0) >>> 4) // swap nibblesbits = ((bits & 0x00FF00FF) << 8) | ((bits & 0xFF00FF00) >>> 8) // swap bytesreturn bits × 2.3283064365386963e-10 // / 2^32Spherical Harmonics Conversion
Converts from Babylon.js polynomial representation (27 floats: x,y,z,xx,yy,zz,yz,zx,xy) to pre-scaled harmonics for shader use.
Step 1: FromPolynomial (matching Babylon.js SphericalHarmonics.FromPolynomial()):
K00 = 0.376127, K1 = 0.977204, K2 = 1.16538K20_zz = 1.34567, K20_xy = 0.672834
L00 = (xx×K00 + yy×K00 + zz×0.376126) × πL1_-1 = y × (-K1) × πL10 = z × K1 × πL11 = x × (-K1) × πL2_-2 = xy × K2 × πL2_-1 = yz × (-K2) × πL20 = (zz×K20_zz - xx×K20_xy - yy×K20_xy) × πL21 = zx × (-K2) × πL22 = (xx - yy) × K2 × πStep 2: preScaleForRendering (SH basis function coefficients):
B00 = √(1/(4π)), B1m = -√(3/(4π)), B1p = √(3/(4π))B2_2 = √(15/(4π)), B2_1 = -√(15/(4π)), B20 = √(5/(16π))B21 = -√(15/(4π)), B22 = √(15/(16π))
output_L00 = raw_L00 × B00output_L1_-1 = raw_L1_-1 × B1m...etcBabylon.js Equivalence Map
| Babylon Lite | Babylon.js |
|---|---|
addToScene(scene, await loadGltf(engine, url)) | BABYLON.SceneLoader.Append(url, scene) |
Mesh (with _gpu field) | Internal mesh representation |
RH_TO_LH_ROOT | Root node rotation [0,1,0,0] + scale [1,1,-1] |
loadEnvironment(scene, url, { brdfUrl }) | scene.environmentTexture = new BABYLON.CubeTexture.CreateFromPrefilteredData(url) |
.env file format | Babylon-proprietary environment file |
| RGBD decode | FromRGBD shader in Babylon |
generateBrdfLut() (GPU compute, RGBD PNG decode, in rgbd-decode.ts) | Babylon ships pre-baked BRDF LUT (also option for runtime) |
polynomialToPreScaledHarmonics() | SphericalHarmonics.FromPolynomial() + preScaleForRendering() |
uploadCubemapRGBD() | Internal cubemap processing in HDRCubeTexture |
KHR_texture_basisu feature + uploadKtx2Texture2D() | BJS KHR_texture_basisu loader + KTX2Decoder texture upload |
| Staging buffer RGBD decode | Avoids Canvas 2D premultiplication issue |
loadDdsEnvironment(scene, url, opts) | BABYLON.CubeTexture.CreateFromPrefilteredData(url) with DDS file |
computeSH() (from DDS mip 0) | BJS SphericalPolynomial.FromHarmonics on cubemap |
decodeBrdfPng() | BJS embedded environmentBRDFTexture (RGBD PNG) |
loadHdrEnvironment(scene, url, opts) | new BABYLON.HDRCubeTexture(url, scene) |
parseRGBE() | BJS HDRTools.GetCubeMapTextureData() |
computeSHFromEquirect() | BJS SphericalPolynomial.FromEquirectangular() |
equirectToCubemapGPU() | BJS panoramaToCubemap.ts CPU conversion |
prefilterCubemapGPU() | BJS hdrFiltering.ts GPU prefilter |
generateBrdfLut() (GPU compute, in hdr-ibl-pipeline.ts) | BJS compute-based BRDF LUT |
loadBabylon(engine, url) | BABYLON.SceneLoader.Load("", url, engine) |
createStandardMaterial() | new BABYLON.StandardMaterial("mat", scene) |
loadTexture2D() | new BABYLON.Texture(url, scene) |
createPointLight() | new BABYLON.PointLight("light", pos, scene) |
| SubMesh + multiMaterial | BABYLON.SubMesh + BABYLON.MultiMaterial |
loadSkybox(scene, baseUrl, ext, size) | new BABYLON.CubeTexture(url, scene) + skybox mesh |
buildSkyboxRenderable() | skyboxMaterial + skyboxMesh in BJS EnvironmentHelper |
Dependencies
load-gltf.tsimports:EngineContextfrom../engine/engine.js;Mat4from../math/types.js;composeMat4,multiplyMat4from../math/mat4.js;generateMipmaps,mipLevelCountfrom../texture/generate-mipmaps.js;Texture2Dfrom../texture/texture-2d.js;PbrMaterialProps,pbrGroupBuilderfrom../material/pbr/pbr-material.js;createAnimationGroupsfrom../animation/animation-group.js;AssetContainerfrom../asset-container.js; dynamic glTF feature imports includinggltf-ext-basisu.ts.gltf-ext-basisu.tsimports:decodeKtx2ImageBitmapFromBuffer,uploadKtx2Texture2Dfrom../texture/ktx2-loader.js;resolveAccessorfrom./gltf-parser.js; PBR and Texture2D types.load-env.tsimports:SceneContextfrom../scene/scene.js.load-dds-env.tsimports:SceneContext,SceneContextInternalfrom../scene/scene.js;EngineInternalfrom../engine/engine.js;EnvironmentTexturesfrom./load-env.js;acquireGPUTexture,releaseGPUTexturefrom../resource/gpu-pool.js;assembleEnvironmentTexturesfrom./env-helpers.js; dynamic import of./rgbd-decode.js.env-helpers.tsimports:EnvironmentTextures,polynomialToPreScaledHarmonicsfrom./load-env.js;getOrCreateSamplerfrom../resource/gpu-pool.js.rgbd-decode.tsimports:EngineContextInternalfrom../engine/engine.js.load-hdr.tsimports:EnvironmentTexturesfrom../loader-env/load-env.js;SceneContext,SceneContextInternalfrom../scene/scene.js;EngineInternalfrom../engine/engine.js;acquireGPUTexture,releaseGPUTexturefrom../resource/gpu-pool.js;assembleEnvironmentTexturesfrom../loader-env/env-helpers.js;parseRGBE,computeSHFromEquirectfrom./hdr-parser.js;equirectToCubemapGPU,prefilterCubemapGPU,generateBrdfLutfrom./hdr-ibl-pipeline.js; dynamic imports:../material/pbr/background-hdr-skybox.js,../material/pbr/background-renderable.js.hdr-parser.tsimports: None (standalone CPU code).hdr-ibl-pipeline.tsimports:HdrImagefrom./hdr-parser.js;getOrCreateSamplerfrom../resource/gpu-pool.js.load-babylon.tsimports:EngineContext,EngineInternalfrom../engine/engine.js;createStandardMaterial,StandardMaterialPropsfrom../material/standard/standard-material.js;uploadMeshToGPU,initMeshTransform,MeshInternalfrom../mesh/mesh.js;createPointLightfrom../light/point-light.js;loadTexture2Dfrom../texture/texture-2d.js;AssetContainerfrom../asset-container.js.load-skybox.tsimports:SceneContext,SceneContextInternalfrom../scene/scene.js;EngineInternalfrom../engine/engine.js;loadCubeTexturefrom../texture/cube-texture.js;createBoxDatafrom../mesh/create-box.js; dynamic import:./skybox-renderable.js.skybox-renderable.tsimports:SceneContextfrom../scene/scene.js;EngineInternalfrom../engine/engine.js;SkyboxDatafrom./load-skybox.js;Renderablefrom../render/renderable.js;buildSkyboxCubeMapGPUfrom../material/standard/skybox-cubemap.js.- Depended on by:
pbr-renderable.ts(consumesMesh),index.ts(type exports), scene setup files.
Test Specification
| Test | Description |
|---|---|
| glTF | |
parseGlbContainer validates magic | Non-GLB input throws |
parseGlbContainer extracts JSON + BIN | Verify correct chunk parsing |
resolveAccessor FLOAT | Returns Float32Array with correct count |
resolveAccessor UNSIGNED_SHORT | Returns Uint16Array |
RH_TO_LH_ROOT negates X | Verify diag(-1,1,1,1) |
computeNodeWorldMatrix top-level | Pre-multiplied by RH_TO_LH_ROOT |
computeNodeWorldMatrix child | Parent world × child local |
extractMaterial defaults | Missing material → baseColorFactor [1,1,1,1], metallic 1, roughness 1 |
uploadTexture sRGB format | baseColor uses rgba8unorm-srgb |
uploadTexture null fallback | 1×1 white texture |
computeWorldBounds | Known positions × identity matrix → correct AABB |
KHR_texture_basisu | Scene 112 FlightHelmetKTX loads KTX2 texture sources and matches Babylon.js within maxMad: 0.02 |
KHR_texture_basisu bundle isolation | Existing scenes have no positive runtime-loaded JS deltas when KTX2 support is present |
Interleaved vertex buffers | Strided accessors resolve to GPU offset/stride; gltf-interleave.test.ts covers strided detection + lazy de-stride |
KHR_xmp_json_ld | Scene 210 XmpMetadataRoundedCube (genuinely interleaved) matches Babylon.js within maxMad: 0.2; metadata surfaced on AssetContainer.xmpMetadata |
EXT_meshopt_compression + KHR_mesh_quantization | Scene 211 BrainStem (glTF-Meshopt-EXT) matches Babylon.js within maxMad: 0.2 |
glTF feature bundle isolation | Non-interleaved / non-meshopt / non-XMP scenes never load the corresponding dynamic chunk (verified via coverage:scene) |
glTF camera node property | gltf-feature-camera.test.ts — explicit opt-in, perspective/orthographic mapping, source naming, node-hierarchy parenting, handedness fix, inherited-scale fix, unreachable-node fallback. Scene 250 VirtualCity enables camera loading, selects camera6, and matches Babylon.js within maxMad: 6.1; would fail (badly warped or mirrored view) without the feature. |
| .env | |
.env magic validation | Bad magic → throws |
RGBD decode | Known RGBD values → correct linear HDR |
floatToHalf | 1.0 → 0x3C00, 0.0 → 0x0000 |
BRDF LUT dimensions | 256×256, rgba16float |
integrateBRDF NdotV=1 roughness=0.04 | Known approximate values |
radicalInverseVdC(0) | Returns 0 |
SH conversion roundtrip | Polynomial → harmonics matches Babylon reference values |
| DDS env | |
DDS header parsing | Correct width, height, mipCount, dataOffset extraction |
float16ToFloat32 | 0x3C00 → 1.0, 0x0000 → 0.0 |
computeSH from DDS | Known cubemap data → SH coefficients match BJS reference |
decodeBrdfPng RGBD | Known PNG RGBD values → correct rgba16float output |
| HDR | |
parseRGBE validates signature | Missing #? → throws |
parseRGBE unsupported format | Non-32-bit_rle_rgbe → throws |
parseRGBE resolution parsing | Correct width/height extraction |
rgbeToFloat e=0 | Returns (0,0,0) |
rgbeToFloat known values | [128, 128, 128, 136] → (128, 128, 128) |
computeSHFromEquirect | Known equirect data → SH matches reference |
equirectToCubemapGPU output format | rgba16float, faceSize × faceSize × 6 |
prefilterCubemapGPU mip count | floor(log2(faceSize)) + 1 mip levels |
generateBrdfLut dimensions | 256×256, rgba16float |
| .babylon | |
loadBabylon clearColor | Scene clearColor set from JSON |
loadBabylon materials | Standard material properties extracted correctly |
loadBabylon textures | Texture URLs resolved relative to base URL |
loadBabylon multiMaterial | SubMesh materialIndex maps to correct sub-material |
loadBabylon point lights | Position, intensity, diffuse, specular, range |
loadBabylon mesh transform | Position/rotation/scaling applied via initMeshTransform |
loadBabylon maxMeshes | Respects mesh count limit |
loadBabylon invisible mesh | isVisible=false skipped |
| Skybox | |
loadSkybox registers SkyboxData | scene._skybox populated |
loadSkybox deferred builder | Builder re-enqueues when UBO not ready |
buildSkyboxRenderable order 0 | Renders behind everything |
File Manifest
| File | Size | Purpose |
|---|---|---|
src/loader-gltf/load-gltf.ts | ~413 lines | GLB parsing, mesh extraction, texture upload, world matrix computation |
src/loader-gltf/gltf-feature-camera.ts | ~110 lines | glTF camera node property — perspective/orthographic import, handedness + scale fixup |
src/loader-env/load-env.ts | ~470 lines | .env parsing, RGBD decode, BRDF LUT upload, SH conversion |
src/loader-env/load-dds-env.ts | ~286 lines | DDS cubemap loader, float16 SH extraction, BRDF PNG decode orchestration |
src/loader-env/env-helpers.ts | ~34 lines | Shared sampler creation, EnvironmentTextures assembly |
src/loader-env/rgbd-decode.ts | ~125 lines | Shared GPU compute RGBD PNG/cubemap → rgba16float decode |
src/loader-hdr/load-hdr.ts | ~102 lines | HDR environment loader orchestrator, deferred background builder |
src/loader-hdr/hdr-parser.ts | ~218 lines | RGBE CPU parser, RLE scanline decoder, equirect SH computation |
src/loader-hdr/hdr-ibl-pipeline.ts | ~400 lines | GPU compute: equirect→cubemap, GGX prefilter, BRDF LUT generation |
src/loader-babylon/load-babylon.ts | ~428 lines | .babylon JSON parser, standard materials, lights, mesh upload |
src/loader-skybox/load-skybox.ts | ~96 lines | Cube texture loader + deferred skybox registration |
src/loader-skybox/skybox-renderable.ts | ~32 lines | Skybox renderable builder wrapping skybox-cubemap material |
src/texture/generate-mipmaps.ts | ~141 lines | GPU mipmap blit (shared utility) |