Module: Shader Composition
Package path:
packages/babylon-lite/src/shader/
Purpose
Provides a declarative, fragment-based shader composition system. Individual rendering features (IBL, clearcoat, skeleton skinning, thin instances, normal mapping, etc.) are encapsulated as ShaderFragment objects that declare their WGSL code, bindings, UBO fields, vertex attributes, and varyings. The ShaderComposer (composeShader()) assembles fragments into final WGSL source code and GPU pipeline descriptors via topological sort, slot injection, and bind group layout merging.
Key design principles:
- Zero global state — no module-level registries; fragments are passed as arrays
- Full tree-shaking — unused fragments add zero bytes to bundles
- Materials own shaders — the composer is generic; materials select which fragments to include
- No WGSL parsing — structured data (UBO fields, bindings) uses typed interfaces, never regex on emitted WGSL
Public API Surface
Constants
// Shader stage visibility flags (numeric for Node.js compatibility — no GPUShaderStage in Node)const STAGE_VERTEX = 0x1; // GPUShaderStage.VERTEXconst STAGE_FRAGMENT = 0x2; // GPUShaderStage.FRAGMENTTypes — fragment-types.ts
// ── WGSL scalar/vector types ──export type WgslScalarType = "f32" | "u32" | "i32" | "vec2<f32>" | "vec3<f32>" | "vec4<f32>" | "mat4x4<f32>";
// ── Vertex Attributes ──export interface VertexAttribute { readonly name: string; // WGSL variable name (e.g. "position", "world0") readonly type: string; // WGSL type (e.g. "vec3<f32>") readonly gpuFormat: GPUVertexFormat; readonly arrayStride: number; readonly stepMode?: GPUVertexStepMode; // default "vertex" readonly bufferGroup?: string; // shared buffer key (e.g. "ti-matrix") readonly offset?: number; // byte offset within buffer (default 0)}
// ── Varyings ──export interface Varying { readonly name: string; // WGSL variable name readonly type: string; // WGSL type}
// ── UBO Fields ──export interface UboField { readonly name: string; // WGSL field name readonly type: WgslScalarType;}
// ── Binding Declarations ──export type BindingKind = | { readonly kind: "uniform-buffer" } | { readonly kind: "texture"; readonly textureType: "texture_2d<f32>" | "texture_cube<f32>" | "texture_depth_2d" | "texture_2d<u32>"; readonly sampleType?: "float" | "unfilterable-float" | "depth" | "sint" | "uint"; } | { readonly kind: "sampler"; readonly samplerType: "sampler" | "sampler_comparison" } | { readonly kind: "storage-texture"; readonly access: "read" | "write" | "read_write"; readonly gpuAccess: GPUStorageTextureAccess; readonly format: string; };
export interface BindingDecl { readonly name: string; // WGSL variable name readonly type: BindingKind; readonly group?: "mesh" | "shadow"; // default "mesh" → group(1); "shadow" → group(2) readonly visibility: GPUShaderStageFlags;}
// ── Fragment Slot Markers ──export type FragmentSlot = "HF" | "SV" | "AT" | "AC" | "MF" | "BL" | "AD" | "AI" | "NI" | "BC" | "BA";export type VertexSlot = "VR" | "VW" | "VB";ShaderFragment Interface
export interface ShaderFragment { readonly id: string; // unique ID for dedup + dependency resolution readonly dependencies?: readonly string[]; // fragment IDs that must compose before this one
// ── Vertex stage ── readonly vertexAttributes?: readonly VertexAttribute[]; readonly varyings?: readonly Varying[]; readonly vertexBindings?: readonly BindingDecl[]; readonly vertexSlots?: Partial<Record<VertexSlot, string>>; readonly pipelineVertexBuffers?: (nextLoc: number) => { buffers: GPUVertexBufferLayout[]; nextLoc: number }; readonly vertexBuiltins?: readonly { readonly name: string; readonly builtin: string; readonly type: string }[]; readonly vertexHelperFunctions?: string;
// ── Fragment stage ── readonly uboFields?: readonly UboField[]; readonly bindings?: readonly BindingDecl[]; readonly helperFunctions?: string; readonly fragmentSlots?: Partial<Record<FragmentSlot, string>>;}ShaderTemplate Interface
export interface ShaderTemplate { readonly vertexTemplate: string; // WGSL with slot markers readonly fragmentTemplate: string; // WGSL with slot markers readonly baseMeshUboFields: readonly UboField[]; readonly baseVertexAttributes: readonly VertexAttribute[]; readonly baseVaryings: readonly Varying[]; readonly baseBindings?: readonly BindingDecl[]; readonly baseVertexBindings?: readonly BindingDecl[]; readonly baseMaterialUboFields?: readonly UboField[];}Composed Output
export interface UboSpec { readonly totalBytes: number; // aligned to 16 bytes readonly offsets: ReadonlyMap<string, number>; // field name → byte offset readonly structBody: string; // WGSL struct body (fields only)}
export interface ComposedShader { readonly vertexWGSL: string; readonly fragmentWGSL: string; readonly meshBGLDescriptor: GPUBindGroupLayoutDescriptor; // group(1) readonly shadowBGLDescriptor: GPUBindGroupLayoutDescriptor | null; // group(2) readonly vertexBufferLayouts: GPUVertexBufferLayout[]; readonly meshUboSpec: UboSpec; readonly sceneUboSpec: UboSpec; readonly fragmentKey: string; // sorted IDs joined with "|" — pipeline cache key}Main Function
export function composeShader(template: ShaderTemplate, fragments: readonly ShaderFragment[]): ComposedShader;UBO Layout
export function computeUboLayout(fields: readonly UboField[]): UboSpec;Internal Architecture
Topological Sort — topoSort()
Fragments declare dependencies via dependencies: string[]. The composer:
- Builds a map of
id → ShaderFragment - Computes in-degrees from dependency edges
- Performs Kahn's algorithm with deterministic alphabetical ordering of zero-degree nodes
- Throws on duplicate IDs, unknown dependencies, or cycles
The sorted order determines:
- Code injection order (fragments contribute to slots in dependency order)
- Binding index assignment (deterministic binding numbers)
- UBO field ordering
Slot Injection — injectSlots()
Templates contain comment markers in the format /*SLOT_NAME*/ (e.g., /*AI*/, /*VW*/).
The SLOT_RE = /\/\*([A-Z_0-9]+)\*\//g regex finds all markers. For each marker, the composer:
- Iterates sorted fragments
- Collects any contributions to that slot name from
fragmentSlotsorvertexSlots - Joins contributions with
\n - Replaces the marker with the concatenated code
Fragment slot markers (fragment shader):
| Slot | Purpose |
|---|---|
HF | Helper functions |
SV | Shader variables initialization |
AT | Alpha/texture modifications |
AC | Alpha cutoff |
MF | Material function overrides |
BL | Before lighting variables |
AD | After direct lighting |
AI | Ambient/IBL integration |
NI | Normal injection |
BC | Before color output |
BA | Before alpha output |
Vertex slot markers:
| Slot | Purpose |
|---|---|
VR | Before main body (morph pre-skinning) |
VW | Compute finalWorld (skeleton skinning, thin-instance) |
VB | After world transform (varying passthrough) |
Template Markers (non-slot)
Fixed markers replaced once (not iterated over fragments):
| Marker | Replacement |
|---|---|
/*SU*/ | struct SceneUniforms { ... } |
/*MU*/ | struct MeshUniforms { ... } |
/*VI*/ | struct VertexInput { ... } |
/*VO*/ | struct VertexOutput { ... } |
/*VD*/ | Vertex binding declarations |
/*VP*/ | Vertex function parameters (builtins + inputs) |
/*VH*/ | Vertex helper functions |
/*FI*/ | struct FragmentInput { ... } |
/*HF*/ | Fragment helper functions |
/*FB*/ | Fragment binding declarations |
Bind Group Layout Construction
The composer emits material-owned bind groups after the frame-graph scene group:
- Group 0: external frame-graph scene group, not owned by the composer. Binding 0 is the per-pass
SceneUniformsUBO and binding 1 is the scene-ownedLightsUniformsUBO. - Group 1 ("mesh"): Mesh UBO (binding 0, always present), optional Material UBO (binding 1 when
baseMaterialUboFieldsis present), and fragment bindings after that - Group 2 ("shadow"): Shadow-specific bindings (optional)
Binding assignment order:
template.baseVertexBindings(vertex-stage bindings)- Each sorted fragment's
vertexBindings template.baseBindings(fragment-stage bindings)- Each sorted fragment's
bindingswheregroup === "mesh"or default - Each sorted fragment's
bindingswheregroup === "shadow"
The composer's addBinding() handles each BindingKind once, producing both its
GPUBindGroupLayoutEntry and WGSL declaration before routing the declaration to
the vertex and/or fragment stage according to visibility. Binding order and
the independent mesh/shadow counters remain unchanged.
Storage-texture declarations retain WGSL access spellings (read, write,
read_write), while their WebGPU descriptors use the corresponding read-only,
write-only, and read-write values. Non-filtering samplers likewise use
non-filtering in the descriptor but plain sampler in WGSL.
The generated vertex-input text is also reused by the input structure and vertex
entry-point parameters.
Vertex Buffer Layout Construction
- Template
baseVertexAttributes+ fragmentvertexAttributesare deduplicated by name - Each attribute without a
bufferGroupgets its ownGPUVertexBufferLayout - Attributes sharing a
bufferGroupare packed into a single interleaved buffer layout (e.g., thin-instanceworld0–world3share"ti-matrix"with stride 64) - Fragments with
pipelineVertexBufferscallbacks append additional layouts (e.g., skeleton joints/weights)
UBO Layout — ubo-layout.ts
computeUboLayout() follows WGSL uniform buffer alignment rules (std140-like):
| Type | Align | Size |
|---|---|---|
f32 | 4 | 4 |
u32 / i32 | 4 | 4 |
vec2<f32> | 8 | 8 |
vec3<f32> | 16 | 12 |
vec4<f32> | 16 | 16 |
vec4<u32> | 16 | 16 |
mat4x4<f32> | 16 | 64 |
array<vec4<u32>, N> | 16 | 16 × N |
Array type parsing accepts optional whitespace after the comma, so both array<vec4<u32>, 4> and array<vec4<u32>,4> are valid field type strings. This matters for production bundles because inline WGSL minification may remove spaces.
Algorithm:
- Walk fields in order, align cursor to field alignment
- Record byte offset for each field name
- Generate WGSL struct body (
name: type,per field) - Round total size to 16-byte boundary
Composed shaders generate material-owned UBO specs only:
- Mesh UBO: template
baseMeshUboFields(group 1, binding 0) - Material UBO: template
baseMaterialUboFields+ fragmentuboFields(group 1, binding 1) whenbaseMaterialUboFieldsis present; otherwise fragmentuboFieldsare appended to the mesh UBO
The canonical scene UBO is not composed from fragments. Material templates prepend SCENE_UBO_WGSL through the /*SU*/ marker, and RenderTask writes the group-0 scene bind group per pass.
Deduplication — dedup()
Template base arrays and fragment contributions are merged with name-based deduplication. First occurrence wins. Applied to vertex attributes and varyings.
Pipeline Configuration
N/A — The composer generates pipeline descriptors but doesn't create GPU pipelines. Pipeline creation is the responsibility of the material system.
Shader Logic — wgsl-helpers.ts
Shared WGSL snippets (pure function strings, no bindings):
WGSL_PERTURB_NORMAL
Cotangent-frame bump mapping. Requires bumpTex, bumpSampler in scope.
fn perturbNormal(vNormalW, positionW, uv, bumpScale) → vec3<f32> Sample normal map, construct cotangent frame from screen-space derivatives, transform normal sample into world space.WGSL_SHADOW_ESM
Exponential shadow map sampling. Requires shadowTex, shadowSampler in scope.
fn computeFallOff(value, clipSpace, frustumEdgeFalloff) → f32fn computeShadowWithESM(posFromLight, depthMetric, darkness, depthScale, frustumEdgeFalloff) → f32 Projects shadow coordinates, samples depth, applies ESM with edge falloff.WGSL_FOG
Linear/exp/exp² fog. Requires scene.vFogInfos (vec4: mode, start, end, density).
fn calcFogFactor(fogDistance: vec3<f32>) → f32WGSL_IMAGE_PROCESSING
Exposure → Reinhard tonemap → gamma → contrast. Requires scene.exposureLinear, scene.contrast.
fn applyImageProcessing(result: vec4<f32>) → vec4<f32> rgb *= exposureLinear rgb = 1 - exp2(-1.590579 * rgb) // tonemapping rgb = pow(rgb, 1/2.2) // gamma contrast interpolation (below 1: mix with 0.5; above 1: mix with S-curve)WGSL_DITHER
Noise-based dithering. Pure math, no UBO dependency.
fn dither(seed: vec2<f32>, varianceAmount: f32) → f32 fract(sin(dot(seed, [12.9898, 78.233])) * 43758.5453) Mix ±normVariance where normVariance = varianceAmount / 255Canonical SceneUniforms
All runtime material shaders use the canonical SceneUniforms declaration from packages/babylon-lite/shaders/scene-uniforms.wgsl, imported through src/shader/scene-uniforms.ts. The struct is fixed-size (SCENE_UBO_BYTES = 352) and contains:
viewProjection,view, andvEyePosition- environment rotation, SH irradiance, exposure/contrast/LOD image-processing fields
- fog info/color
Light data is not appended to SceneUniforms; Standard and PBR use the separate render/lights-ubo.ts buffer when direct lighting is active. Frame-graph RenderTask owns one scene UBO/bind group per pass so offscreen passes can write target-specific projection state (including Y-flip) without mutating global scene state.
State Machine / Lifecycle
The composer is a pure function — no lifecycle or state. Call composeShader(template, fragments) and receive a ComposedShader.
Materials cache composed shaders by fragmentKey (sorted fragment IDs joined with "|").
Babylon.js Equivalence Map
| Babylon.js | Babylon Lite |
|---|---|
ShaderMaterial + Effect system | ShaderTemplate + ShaderFragment[] + composeShader() |
#define preprocessor macros | Slot injection (/*AI*/, /*VW*/, etc.) |
UniformBuffer layout | computeUboLayout() with UboField[] |
MaterialPluginBase | ShaderFragment interface |
Effect.ShadersStore (global) | Fragment modules (tree-shakable imports) |
Engine._caps feature detection | Fragment dependencies (explicit) |
PBRMaterial.customShaderNameResolve | Fragment slot contributions |
Dependencies
- No external dependencies — pure TypeScript, zero npm imports
- Internal:
fragment-types.ts(types),ubo-layout.ts(UBO computation)
Test Specification
- Topological sort: Verify correct ordering with diamond dependencies (A→B,C→D)
- Cycle detection: Verify error thrown for A→B→A
- Duplicate fragment ID: Verify error thrown
- Unknown dependency: Verify error thrown
- Slot injection: Verify
/*AI*/replaced with concatenated fragment contributions in dependency order - Binding assignment: Verify mesh bindings get sequential indices starting at 1
- UBO layout alignment: Verify vec3 gets 16-byte alignment, struct total rounds to 16
- Vertex buffer grouping: Verify
bufferGroupattributes merge into single layout - Deduplication: Verify same-name attributes/varyings not duplicated
- Fragment key: Verify deterministic key generation for pipeline caching
File Manifest
| File | Purpose |
|---|---|
fragment-types.ts | All type definitions: ShaderFragment, ShaderTemplate, ComposedShader, UboSpec, slot types, binding types |
shader-composer.ts | composeShader() — topological sort, slot injection, bind group layout construction, WGSL assembly |
ubo-layout.ts | computeUboLayout() — WGSL std140-like alignment computation for UBO structs |
wgsl-helpers.ts | Shared WGSL snippets: perturbNormal, ESM shadows, fog, image processing, dither |
fragments/thin-instance-fragment.ts | Example fragment: thin-instance world matrix + optional instance color |