Module: Loader HDR
Package path:
packages/babylon-lite/src/loader-hdr/
Purpose
Loads Radiance .hdr (RGBE) equirectangular panorama files and produces GPU-ready IBL (Image-Based Lighting) textures identical to Babylon.js HDRCubeTexture. The pipeline runs in five stages: RGBE parsing (CPU), spherical harmonics extraction (CPU), equirect→cubemap conversion (GPU compute), importance-sampled GGX cubemap prefiltering (GPU compute), and BRDF split-sum LUT generation (GPU compute).
Public API Surface
Functions
// load-hdr.tsexport async function loadHdrEnvironment(scene: SceneContext, url: string, options?: HdrLoadOptions): Promise<EnvironmentTextures>;Types
export interface HdrLoadOptions { faceSize?: number; // cubemap face size in pixels (default 256) useCubemapSkybox?: boolean; // render HDR cubemap as skybox background skipGround?: boolean; // skip ground plane renderable skyboxSize?: number; // skybox mesh size (matches BJS createDefaultEnvironment)}// hdr-parser.tsexport interface HdrImage { width: number; height: number; data: Float32Array; // Float32 RGB (3 floats per pixel, row-major)}
export function parseRGBE(buffer: ArrayBuffer): HdrImage;
export function computeSHFromEquirect(data: Float32Array, width: number, height: number): Float32Array; // 27 floats: SphericalPolynomial coefficients// hdr-ibl-pipeline.tsexport function equirectToCubemapGPU(device: GPUDevice, hdr: HdrImage, faceSize: number): GPUTexture;export function prefilterCubemapGPU(device: GPUDevice, srcCube: GPUTexture, faceSize: number, mipCount: number): GPUTexture;export function generateBrdfLut(device: GPUDevice): GPUTexture;Internal Architecture
Stage 1: RGBE Parsing — parseRGBE()
Decodes Radiance .hdr files:
- Header parsing: Read
#?signature line, then key-value pairs until empty line. ExtractFORMAT=32-bit_rle_rgbe. - Resolution: Parse
-Y height +X widthline. - Scanline decoding: For each row, call
decodeScanline():- New-style RLE (if
width ∈ [8, 0x7FFF]and first 4 bytes are[2, 2, width_hi, width_lo]):- 4 channel passes (R, G, B, E), each RLE-compressed
- Run byte > 128 → repeat next byte
(run - 128)times - Run byte ≤ 128 → copy that many literal bytes
- Old-style flat: 4 bytes per pixel directly
- New-style RLE (if
- RGBE → Float:
rgbeToFloat(r, g, b, e):Note: The exponent bias is 136 (= 128 + 8), matching Radiance convention.if e == 0: RGB = (0, 0, 0)else: scale = 2^(e - 136); RGB = (r * scale, g * scale, b * scale)
Stage 2: Spherical Harmonics — computeSHFromEquirect()
Computes 2nd-order (L=2) spherical harmonics from equirectangular panorama, matching BJS SphericalPolynomial.FromHarmonics().
Algorithm:
- For each pixel
(px, py):φ = (py + 0.5) / height * π(polar angle)θ = (2(px + 0.5) / width - 1) * π(azimuthal angle)- Direction:
(x, y, z) = (sinφ sinθ, cosφ, sinφ cosθ) - Solid angle:
dΩ = sinφ * (π/height) * (2π/width) - Clamp extreme values: if
max(r,g,b) > 4096, scale down to cap at 4096
- Accumulate 9 SH basis functions per color channel (27 total):
Y₀₀ = 0.282094791773878Y₁₋₁ = 0.48860251190292 * y,Y₁₀ = ... * z,Y₁₁ = ... * xY₂₋₂ = 1.092548430592079 * x*y, etc.
- Normalize by
4π / totalWeight - Apply irradiance + Lambertian scaling:
L0 *= 1, L1 *= 2/3, L2 *= 1/4 - Convert SH → SphericalPolynomial (BJS
FromHarmonicsconvention):poly[x] = L₁₁ * 1.02333 / πpoly[y] = L₁₋₁ * 1.02333 / πpoly[z] = L₁₀ * 1.02333 / πpoly[xx] = (L₀₀ * 0.886227 - L₂₀ * 0.247708 + L₂₂ * 0.429043) / πpoly[yy] = (L₀₀ * 0.886227 - L₂₀ * 0.247708 - L₂₂ * 0.429043) / πpoly[zz] = (L₀₀ * 0.886227 + L₂₀ * 0.495417) / πpoly[yz] = L₂₋₁ * 0.858086 / πpoly[zx] = L₂₁ * 0.858086 / πpoly[xy] = L₂₋₂ * 0.858086 / π
Output: Float32Array(27) — 9 polynomial coefficients × 3 color channels (RGB interleaved per coefficient).
Stage 3: Equirect → Cubemap — equirectToCubemapGPU()
GPU compute shader converts equirectangular panorama to 6-face cubemap.
Input: rgba32float 2D texture (equirect, with RGB→RGBA expansion on CPU)
Output: rgba16float 2D-array texture [faceSize, faceSize, 6]
WGSL Compute Shader (EQUIRECT_TO_CUBE_WGSL):
- Workgroup size:
(8, 8, 1), dispatchedceil(faceSize/8) × ceil(faceSize/8) × 6 - Face corner lookup: 24 pre-computed
vec3<f32>corners (4 per face), matching BJSpanoramaToCubemap.tslayout:- Layer 0: FACE_RIGHT, Layer 1: FACE_LEFT, Layer 2: FACE_UP, Layer 3: FACE_DOWN, Layer 4: FACE_FRONT, Layer 5: FACE_BACK
- Direction: bilinear interpolation of face corners using
u = x/size,v = y/size - Equirect UV:
eu = atan2(z, x) / π * 0.5 + 0.5,ev = acos(y) / π - Applies BJS
invertYconvention:py = height - py_raw - 1
Resources created and destroyed:
- Creates
equirectTex(rgba32float),paramBuf(16B uniform) — both destroyed after dispatch cubeTexreturned to caller
Stage 4: Cubemap Prefiltering — prefilterCubemapGPU()
Importance-sampled GGX prefiltering for IBL specular cubemap, matching BJS HDRFiltering.
Input: Source cubemap from Stage 3
Output: rgba16float cube texture with mipCount mip levels
Algorithm per mip level:
- LOD 0: Exact texel copy (no bilinear resampling) via
copyTextureToTexture— matches BJS behavior - LODs 1+: For each texel:
- Compute direction
Nfrom face corners (same parameterization as Stage 3) alphaG = 2^(mipLevel / 0.8) / srcSize— roughness parameter for this mip- Build tangent frame from
N - 1024 importance samples using Hammersley sequence:
ξ₀ = i / 1024,ξ₁ = radicalInverseVdC(i)- GGX half-vector:
H = importanceSampleGGX(ξ₀, ξ₁, alphaG) - Reflect to get light direction:
L = 2(N·H)H - N - PDF-based LOD:
sampleLod = 0.5 * log2(omegaS / omegaP) + 1.0 - Accumulate:
result += textureSampleLevel(srcCube, L, sampleLod).rgb * NdotL
- Normalize by total NdotL weight
- Compute direction
One GPU submit per mip level to ensure params buffer is consumed before next writeBuffer.
Resources: Source cubemap destroyed after prefiltering. Params buffer destroyed.
Stage 5: BRDF LUT — generateBrdfLut()
Generates 256×256 rgba16float BRDF split-sum lookup table.
WGSL Compute Shader (BRDF_LUT_WGSL):
- Workgroup:
(8, 8), dispatch(32, 32) - For each texel
(x, y):NdotV = max((x + 0.5) / 256, 0.001)roughness = max((y + 0.5) / 256, 0.04)a = roughness²,a2 = a²- 1024 importance samples per texel
- Smith-GGX height-correlated visibility:
V_Vis = 0.5 / (GGXV + GGXL) * NdotL * (4 * VdotH / NdotH) - Schlick Fresnel split:
A += (1 - Fc) * V_Vis,B += Fc * V_Vis
- Output layout:
vec4(B/N, (A+B)/N, 0, 1)— matches BJS BRDF LUT convention
Pipeline caching: _brdfPipeline is cached module-level (first call creates, subsequent reuse).
Full Pipeline Orchestration — loadHdrEnvironment()
fetch(url) → ArrayBuffer │ ▼parseRGBE() → HdrImage { width, height, data: Float32Array } │ ▼computeSHFromEquirect() → Float32Array(27) irradiance SH │ ▼equirectToCubemapGPU() → GPUTexture (rgba16float cube, mip 0 only) │ ▼prefilterCubemapGPU() → GPUTexture (rgba16float cube, all mips, GGX-filtered) │ ▼generateBrdfLut() → GPUTexture (rgba16float 256×256) │ ▼assembleEnvironmentTextures() → EnvironmentTextures │ ▼Set scene._envTextures, scene._irradianceSHSet imageProcessing: toneMappingEnabled=false, exposure=0.8, contrast=1.2Register deferred builder for background renderables (skybox + ground)Post-load cleanup:
- Specular cube and BRDF LUT are ref-counted via
acquireGPUTexture()/releaseGPUTexture() - Disposables registered on
scene._disposablesfor cleanup on scene destroy
Pipeline Configuration
Compute Pipeline: Equirect → Cubemap
- Layout:
"auto" - Bind group 0:
- binding 0:
equirect—texture_2d<f32>(input panorama) - binding 1:
cubeFaces—texture_storage_2d_array<rgba16float, write>(output) - binding 2:
params— uniform buffer (faceSize, equirectWidth, equirectHeight)
- binding 0:
Compute Pipeline: Cubemap Prefilter
- Layout:
"auto" - Bind group 0:
- binding 0:
srcCube—texture_cube<f32>(input cubemap) - binding 1:
srcSampler—sampler(linear filtering) - binding 2:
dstFaces—texture_storage_2d_array<rgba16float, write>(output mip) - binding 3:
params— uniform buffer (faceSize, mipLevel, totalMips, srcSize)
- binding 0:
Compute Pipeline: BRDF LUT
- Layout:
"auto" - Bind group 0:
- binding 0:
outputTex—texture_storage_2d<rgba16float, write>
- binding 0:
Shader Logic
See Stage 3, 4, 5 above for complete WGSL pseudocode and math.
Key mathematical functions:
- radicalInverseVdC: Van der Corput sequence for quasi-random sampling (bit reversal)
- importanceSampleGGX: Generates half-vectors distributed according to GGX NDF
- D_GGX:
D = a² / (π * ((N·H)²(a²-1)+1)²)— GGX normal distribution - integrateBRDF: Smith-GGX visibility × Schlick Fresnel split-sum integration
State Machine / Lifecycle
The loader is a one-shot async function. No persistent state beyond:
_brdfPipeline: Module-level cached compute pipeline (created once per device lifetime)- Deferred builders: Registered on
scene._deferredBuildersfor background renderables
Babylon.js Equivalence Map
| Babylon.js | Babylon Lite |
|---|---|
HDRCubeTexture | loadHdrEnvironment() |
HDRTools.RGBE_ReadPixels | parseRGBE() → decodeScanline() |
CubeMapToSphericalPolynomialTools | computeSHFromEquirect() |
panoramaToCubemap.ts face corners | CORNERS const array in WGSL |
HDRFiltering (importance-sampled GGX) | prefilterCubemapGPU() compute shader |
BRDFTextureTools.GetBRDFTexture | generateBrdfLut() compute shader |
EnvironmentTextureTools | assembleEnvironmentTextures() |
Dependencies
../loader-env/load-env.js—EnvironmentTexturestype../loader-env/env-helpers.js—assembleEnvironmentTextures()../resource/gpu-pool.js—acquireGPUTexture,releaseGPUTexture,getOrCreateSampler../scene/scene.js—SceneContext,SceneContextInternal../engine/engine.js—EngineInternal(for device access)../material/pbr/background-hdr-skybox.js— dynamically imported for HDR skybox../material/pbr/background-renderable.js— dynamically imported for solid skybox/ground
Test Specification
- RGBE parsing: Verify correct width/height extraction and pixel values for known .hdr files
- RLE decoding: Verify both new-style (4-channel RLE) and old-style (flat) scanline decoding
- RGBE→Float conversion: Verify
2^(e-136)scaling produces correct HDR values - Spherical harmonics: Verify SH coefficients against BJS reference for a known panorama
- Equirect→cubemap: Verify face directions match BJS corner conventions; verify UV mapping
- GGX prefiltering: Verify LOD 0 is exact copy; verify higher LODs are increasingly blurred
- BRDF LUT: Verify 256×256 output matches BJS split-sum format
- Pipeline integration: Verify
loadHdrEnvironment()produces validEnvironmentTextures - Resource cleanup: Verify intermediate textures (equirect, source cube) are destroyed
File Manifest
| File | Purpose |
|---|---|
load-hdr.ts | Public API: orchestrates full HDR→IBL pipeline, sets up scene environment and deferred builders |
hdr-parser.ts | CPU-side RGBE parsing and spherical harmonics extraction from equirectangular panorama |
hdr-ibl-pipeline.ts | GPU compute shaders: equirect→cubemap, importance-sampled GGX prefiltering, BRDF LUT generation |