API

Module: Shader Material

Package path: packages/babylon-lite/src/material/shader/

Purpose

The ShaderMaterial module provides Lite's WGSL-only equivalent of Babylon.js ShaderMaterial: user-authored vertex and fragment shaders, explicit vertex attribute lists, typed custom uniforms, texture samplers, video external textures, compile-time defines, and render-state hints such as alpha blending.

This module is intentionally not a GLSL compatibility layer. Babylon.js documentation and playgrounds remain useful as reference scenes and API concepts, but Lite accepts WGSL source only. There is no GLSL parser, no GLSL-to-WGSL transpiler, and no Effect.ShadersStore global registry in core.

The design follows the Lite material contract:

  • A ShaderMaterial is plain data with a material-owned _buildGroup.
  • The scene never knows about shader-specific details.
  • The renderer only binds group 0 and asks renderables to draw.
  • The material owns shader source, bind group layouts, pipelines, bind groups, and resource lifetime.
  • Structured GPU layout comes from typed options, never by parsing emitted WGSL.

Public API Surface

Factory

export function createShaderMaterial(options: ShaderMaterialOptions): ShaderMaterial;
export function enableShaderMaterialInstanceWorld(material: ShaderMaterial): void;
export function enableShaderMaterialFinalColor(material: ShaderMaterial): void;
export function enableShaderMaterialPipelineSharing(): void;
export function setShaderAttributeFormats(material: ShaderMaterial, formats: ShaderAttributeFormats): void;

createShaderMaterial is synchronous and accepts already-resolved WGSL source strings.

enableShaderMaterialPipelineSharing() is the zero-argument, package-wide opt-in for sharing equivalent ShaderMaterial layouts, modules and pipelines. Call it before pipeline preparation. It installs one optional feeder call in the pipeline-bindings path and allocates no cache until the first enabled lookup. Materials and views created later participate automatically. Equivalent materials on the same GPUDevice share GPU pipeline state while retaining independent uniform values, textures, custom UBOs and bind groups. Repeated enable calls replace the seam with the same behavior and are safe. Applications that do not call it retain the existing per-material behavior; explicit multi-material build-group sharing through enableShaderPipelineCache is unchanged.

export interface ShaderMaterialOptions {
readonly name?: string;
readonly vertexSource: string;
readonly fragmentSource: string;
readonly attributes: readonly ShaderAttributeName[];
readonly uniforms?: readonly ShaderUniformOption[];
readonly samplers?: readonly ShaderSamplerOption[];
readonly externalTextures?: readonly string[];
readonly defines?: ShaderDefineMap;
/** Bind/inject the mesh's optional thin-instance RGBA stream for this material. Default true. */
readonly useThinInstanceColors?: boolean;
readonly needAlphaBlending?: boolean;
readonly blendMode?: "alpha" | "additive";
readonly blend?: GPUBlendState;
readonly needAlphaTesting?: boolean;
readonly backFaceCulling?: boolean;
readonly depthWrite?: boolean;
readonly depthCompare?: GPUCompareFunction;
readonly topology?: "point-list" | "line-list" | "triangle-list";
}

Supported Babylon route forms:

Babylon route formLite phase 1 handling
{ vertexSource, fragmentSource }
Supported, but source strings must be WGSL.
{ vertex, fragment } with Effect.ShadersStore
Not supported in core; global shader stores violate Lite's no-side-effect rule.
{ vertexElement, fragmentElement }
Not supported in core. Callers may read DOM text and pass WGSL strings explicitly.
"./COMMON_NAME" external .fx files
Not supported in core. A future helper may fetch WGSL explicitly, but the material factory stays synchronous.

Material type

export interface ShaderMaterial extends Material {
readonly name?: string;
readonly vertexSource: string;
readonly fragmentSource: string;
readonly attributes: readonly ShaderAttributeName[];
readonly uniformDecls: readonly ShaderUniformDecl[];
readonly samplerDecls: readonly ShaderSamplerDecl[];
readonly _externalTextureDecls?: readonly string[];
readonly defines: readonly ShaderDefine[];
readonly needAlphaBlending: boolean;
readonly blendMode: "alpha" | "additive";
readonly blend?: GPUBlendState;
readonly needAlphaTesting: boolean;
readonly backFaceCulling: boolean;
readonly depthWrite: boolean;
readonly depthCompare: GPUCompareFunction;
_uniformValues: Map<string, ShaderUniformSlot>;
_textureSlots: Map<string, ShaderTextureSlot>;
_externalTextureSlots?: Map<string, ShaderExternalTextureSlot>;
_uniformVersion: number;
_resourceVersion: number;
}

External texture layout, WGSL, validation, and binding behavior lives in the tree-shakeable shader-external-texture module exported with setShaderExternalTexture / getShaderExternalTexture. Core material creation snapshots the declaration names, so caller mutation cannot desynchronize slots. The first binding API call hydrates and validates a material created before a code-split binding module loads and explicitly installs the optional pipeline and renderable resolver seams. Importing the module has no side effects. Pipeline preparation rejects an external material before those seams are installed rather than caching an incomplete layout. The optional renderable resolver identifies materials needing direct draws and per-frame group-1 refreshes. Refreshes rebuild only the bind group, without acquiring or releasing ordinary textures. ShaderMaterials without that API do not pull video binding or sampler-cache code.

External slots belong to the underlying source material. Hydrating a material view first must populate the source rather than shadowing its slots. Binding through either a source or a view updates that same source map and resource version, so other views keep seeing subsequent video replacements.

_uboVersion from the base Material mirrors _uniformVersion for compatibility with existing dirty tracking. _resourceVersion is separate because texture/sampler changes require bind group rebuilds, not just UBO writes.

blend is an explicit color-target blend-state override. When present, it replaces the state derived from blendMode, implies needAlphaBlending unless explicitly overridden, defaults depthWrite to false, and participates in the cross-material pipeline-cache key.

topology defaults to triangle-list. It is fixed when the material is created and participates in pipeline selection; callers supplying line-list geometry can use "line-list" for diagnostic wireframe rendering without accessing internal pipeline state. Indexed strip topologies are rejected because WebGPU requires a stripIndexFormat matching each mesh's index buffer, while ShaderMaterial pipeline grouping is material-based.

Attributes

export type ShaderAttributeName = "position" | "normal" | "uv" | "uv2" | "tangent" | "color" | "joints" | "weights" | "joints1" | "weights1";
export type ShaderAttributeFormats = Partial<Record<ShaderAttributeName, GPUVertexFormat>>;

The order in options.attributes is the vertex buffer binding order and the WGSL @location order. Unsupported names throw during material creation. Missing optional mesh buffers use zero-filled buffers. When enableShaderMaterialFinalColor() is enabled, its missing color fallback is instead white so color multiplication does not black out meshes without vertex colors. position is required for normal mesh rendering.

setShaderAttributeFormats changes the material-owned vertex signature before registration or pipeline preparation: the declared GPUVertexFormat selects both the generated WGSL input type and the tight default stride. Mesh-owned MeshGPU._vbLayout supplies per-attribute stride/offset packing independently. The same material can draw ordinary tight geometry and storage-backed slabs only when their physical formats are compatible. Canonical CPU/glTF geometry retains its canonical encodings (for example, XYZ positions use float32x3); declaring float32x4 does not repack those buffers. Such mismatches are rejected during renderable construction. Noncanonical formats are supported on matching storage-backed streams; their offset alignment and byte extent must fit the declared stride. Format declarations are snapshotted by the setter, and storage-backed constant-zero streams remain valid for all supported formats. Packed variants participate in sync, async, cross-material, depth/normal-view, and thin-instance pipeline keys. Storage-backed draws preserve the mesh's _baseVertex, and absent optional slab streams use the shared zero-stride default buffer. Mesh-specific layouts reuse the binding's resolved formats and change only stride/offset, rather than rebuilding the material signature for every mesh.

The storage/format opt-in installs one synchronous support record in shader-vb-support.ts. That small module owns shared canonical format/type/stride metadata and the attribute layout helper, while the renderable passes its authored-stream lookup to the optional support callbacks. Importing setShaderAttributeFormats or constructing storage geometry therefore does not statically pull the full lazy shader-pipeline.ts / shader-renderable.ts implementation into the entry module. Direct draws pass baseVertex to WebGPU without a forwarding wrapper. Thin-instance paths resolve their combined vertex layouts once during construction and share them across binding and async preparation; indirect argument encoding remains in mesh-indexed-indirect.ts.

The interleaved glTF loader installs the same resolver when it creates a strided mesh. PBR, Standard, and picking consume that mesh packing directly; installing the resolver also makes plain and thin-instance ShaderMaterial draws use the recorded stride and per-attribute offsets. The loader's normalized COLOR_0 stream remains a separate tight float32x4 buffer, while attributes that stay interleaved retain their authored packing. Vertex-format support is needed only when preparing layouts and grouping packets; renderable draw closures do not retain it. Missing-buffer allocation calls the engine-owned seam directly. With the final-color helper enabled, missing storage-backed color inputs use one mesh-owned white float32 RGBA record with zero stride. Its physical fallback format is canonical float32x4, including when the authored color declaration uses a normalized format, so the neutral value remains white. The buffer is released with the last geometry owner; ordinary missing streams still use their existing zero defaults. Validation computes each mesh's missing-stream mask once before packet allocation. Packets retain that mask for grouping and layout resolution, avoiding repeated stream scans. Opaque and transparent renderables share target-binding construction, while retaining their distinct ordering and update behavior. Default GPU picking supports compatible float32 components only. A material's incompatible position or requested discard-data format produces an explicit picking error; callers may exclude that mesh with mesh.pickable = false, a filter, or an ignore entry.

Thin instances and GPU culling

A ShaderMaterial mesh can be hardware-instanced via the standard thin-instance API (setThinInstances, setThinInstanceColors, enableThinInstanceGpuCulling — see 12-thin-instances.md). No new ShaderMaterial option is required: when a mesh has thinInstances, the renderer builds a per-mesh instance pipeline variant and auto-injects extra attributes into the generated VertexInput struct, appended after the declared attributes (so at @location(attributes.length) onward):

@location(N) world0: vec4<f32>, // instance world matrix columns
@location(N+1) world1: vec4<f32>,
@location(N+2) world2: vec4<f32>,
@location(N+3) world3: vec4<f32>,
@location(N+4) instanceColor: vec4<f32>, // only when setThinInstanceColors() was called

useThinInstanceColors controls whether this particular ShaderMaterial draw consumes the mesh's optional RGBA stream. It defaults to true, preserving the automatic behaviour above. When set to false, the instanced variant still injects and binds world0..world3, but it does not inject instanceColor, bind or GPU-cull a color buffer, or synchronize colors for that draw. The mesh's ThinInstanceData.colors remains intact, so another material rendering the same mesh can still consume it.

The option is specifically valid for sampler-free depth/material overrides: a visible material may consume per-instance tint while its _shadowCasterMaterial uses { useThinInstanceColors: false }. Both draws then share the mesh's one matrix buffer, while the caster avoids an unused color vertex stream. The override WGSL must not reference input.instanceColor. The option is ignored for non-instanced meshes.

Call enableShaderMaterialInstanceWorld(material) before registerScene() to opt one material into a generated getFinalWorld(input: VertexInput) helper. The helper has one stable signature for both regular and thin-instanced meshes:

let finalWorld = getFinalWorld(input);
out.position = shaderSystem.viewProjection * finalWorld * vec4<f32>(input.position, 1.0);
// out.vColor = input.instanceColor; // when instance colors are present

For a regular mesh, getFinalWorld returns shaderSystem.world. For a thin-instanced mesh, it returns shaderSystem.world * mat4x4<f32>(input.world0, input.world1, input.world2, input.world3). This lets one vertex source serve both mesh types without referencing instance-only attributes on the regular-mesh variant. The "world" system uniform must be present in ShaderMaterialOptions.uniforms; the enabler throws otherwise. Materials that do not call the enabler retain the original generated prelude and pull in none of the helper WGSL or material-tracking implementation.

The world system uniform stays the mesh world matrix. The baked worldViewProjection / worldView system uniforms are not instance-aware — shared regular/instanced shaders must use viewProjection and getFinalWorld(input).

Call enableShaderMaterialFinalColor(material) before registerScene() to opt one material into a generated getFinalColor(input: VertexInput) helper:

out.vColor = getFinalColor(input);

The generated implementation returns white when the material declares no color attribute and the pipeline has no instance-color stream, input.color for vertex color only, input.instanceColor for instance color only, and input.color * input.instanceColor when both are present. input.color remains the ordinary mesh per-vertex attribute requested through attributes: ["color"]; setThinInstanceColors() supplies the separate instance-rate input.instanceColor. When a material declares color but a mesh has no vertex-color buffer, input.color uses a mesh-owned neutral white fallback, so an available instance color passes through unchanged. The fallback participates in normal shared-geometry disposal, resize retirement, and device recovery.

Like getFinalWorld, the final-color helper is emitted only for materials that opt in. The instance-color specialization is selected from the bound vertex-buffer layout rather than from a pipeline-key naming convention or by parsing generated WGSL.

Implementation notes (bundle discipline):

  • The instance vertex-buffer layouts, the prelude attribute lines, and the per-mesh instanced renderable live in material/shader/shader-thin-instance.ts, dynamically imported via shader-group-builder.ts → buildShaderGroup only when meshes.some(m => m.thinInstances). Non-instanced ShaderMaterial scenes route through the unchanged synchronous buildShaderMaterialRenderables.
  • The expensive bindings (group1BGL, systemSpec, customSpec) are shared between the non-instanced and instanced variants — instancing is vertex data, not bind groups. Only the vertex buffer layouts and the VertexInput struct differ, so getOrCreateShaderPipeline() keys instanced pipelines on a compact non-empty variant suffix (0 or 1). The color bit is 1 only when the mesh has colors and the material did not opt out through useThinInstanceColors.
  • Instanced ShaderMaterial meshes render as one _direct renderable per mesh (not merged), so per-mesh instance buffers are re-bound fresh each frame (avoiding stale render-bundle references when instance capacity grows).
  • Opt-in GPU frustum culling is wired via the shared mesh/thin-instance-cull-binding.ts helper (same as Standard/PBR): when enableThinInstanceGpuCulling(mesh) is set, the compute cull pass runs in the binding update() and the draw becomes drawIndexedIndirect. Opaque instanced ShaderMaterial only; transparent instanced meshes use the normal (non-culled) instanced draw.

Uniform declarations

export type ShaderUniformType = "f32" | "u32" | "i32" | "vec2<f32>" | "vec3<f32>" | "vec4<f32>" | "mat4x4<f32>";
export type ShaderSystemUniformName = "world" | "view" | "projection" | "viewProjection" | "worldView" | "worldViewProjection" | "cameraPosition" | "screenSize" | "alphaCutoff";
export type ShaderUniformOption = ShaderSystemUniformName | ShaderUniformDecl;
export interface ShaderUniformDecl {
readonly name: string;
readonly type: ShaderUniformType;
readonly defaultValue?: number | readonly number[];
}

String uniforms are only accepted for known Babylon-style system uniforms. Custom uniforms must include a type. This keeps the Babylon uniforms: ["worldViewProjection", "time"] concept where safe, while rejecting ambiguous custom strings like "time" unless the caller provides { name: "time", type: "f32" }.

Sampler declarations

export type ShaderSamplerOption = string | ShaderSamplerDecl;
export interface ShaderSamplerDecl {
readonly name: string;
readonly sampleType?: "float" | "unfilterable-float" | "depth";
}

Each sampler name maps to a pair of WGSL bindings:

@group(1) @binding(N) var textureSampler: texture_2d<f32>;
@group(1) @binding(N + 1) var textureSamplerSampler: sampler;

Depth samplers use texture_depth_2d and a filtering sampler is not assumed. Public APIs accept Texture2D only, never raw GPU handles.

External texture declarations

export interface ExternalTexture {
readonly video: HTMLVideoElement;
}
export function createExternalTexture(video: HTMLVideoElement): ExternalTexture;
export function isExternalTextureReady(texture: ExternalTexture): boolean;
export function setShaderExternalTexture(material: ShaderMaterial, name: string, texture: ExternalTexture | null): void;
export function getShaderExternalTexture(material: ShaderMaterial, name: string): ExternalTexture | null;

options.externalTextures is a list of Babylon-style binding names. Each name maps to an external texture and its required filtering sampler:

@group(1) @binding(N) var videoSampler: texture_external;
@group(1) @binding(N + 1) var videoSamplerSampler: sampler;

External textures are sampled with textureSampleBaseClampToEdge(videoSampler, videoSamplerSampler, uv). Lite binds a device-cached default nearest clamp-to-edge sampler, matching Babylon.js ExternalTexture; raw GPU samplers remain internal. A resource name may not appear in both samplers and externalTextures; all declarations and generated <name>Sampler identifiers share the generated group-1 namespace.

The wrapper is pure state around a caller-owned HTMLVideoElement. It owns no browser or GPU resources and has no disposal API. The application remains responsible for video playback, media-stream tracks, object URLs, and element lifetime. isExternalTextureReady reports whether the video has current frame data.

WebGPU external textures are ephemeral. Babylon Lite calls GPUDevice.importExternalTexture({ source: texture.video }) whenever a ShaderMaterial bind group containing an external texture is refreshed for rendering, including every frame even when wrapper identity is unchanged. This per-frame import does not reacquire, release, or otherwise change ordinary Texture2D ownership. Public APIs never expose GPUExternalTexture.

Defines

export type ShaderDefineValue = boolean | number;
export type ShaderDefineMap = Readonly<Record<string, ShaderDefineValue>>;
export interface ShaderDefine {
readonly name: string;
readonly value: ShaderDefineValue;
}

WGSL has no preprocessor. Lite converts defines to const declarations in the generated prelude:

const MyDefine: bool = true;
const Scale: f32 = 2.0;

The normalized define set is part of the pipeline cache key. Callers write ordinary WGSL if (MyDefine) { ... }; the WGSL compiler can constant-fold the branch. #define, #ifdef, and string macro replacement are not supported.

Setters

export type ShaderUniformValue = number | readonly number[] | Float32Array;
export function setShaderUniform(material: ShaderMaterial, name: string, value: ShaderUniformValue): void;
export function setShaderTexture(material: ShaderMaterial, name: string, texture: Texture2D | null): void;
export function setShaderExternalTexture(material: ShaderMaterial, name: string, texture: ExternalTexture | null): void;
export function enableShaderMaterialUniformCaching(): void;
export function enableShaderUniformRangeUpdates(scene: SceneContext, material: ShaderMaterial): void;

setShaderUniform validates that the name exists, the declared type is custom or settable, and the supplied float count matches the declaration. It increments _uniformVersion and _uboVersion.

enableShaderMaterialUniformCaching is a process-wide opt-in for scenes with many ShaderMaterials. It caches each material's system/custom UBO layout and typed-array views, and serializes only custom uniform slots whose setter version changed. Call it before scene registration. Scenes that do not opt in retain the compact default serializer and do not include the caching implementation in their bundle.

enableShaderUniformRangeUpdates is an opt-in for materials with large custom UBOs and one or a few animated values. After the custom UBO has been packed once, each changed custom value is written directly into the material's retained packed ArrayBuffer. The opt-in updater widens one pending byte range across all changes made before the next frame, then uploads only that 4-byte-aligned range through queue.writeBuffer from a scene before-render callback. The first upload and every packed-buffer recreation remain whole-buffer writes. System uniforms have no custom offset and therefore produce no custom-UBO upload. Enabling is idempotent per scene, and the same material may be registered with multiple scenes. Materials that do not opt in keep the original renderable-owned whole-buffer path and pull in zero range-update implementation bytes.

setShaderTexture validates that the sampler exists and tracks both the Texture2D | null identity and the view/sampler captured by the bind group. It increments _resourceVersion when either the facade or those resources change. This keeps ordinary repeated sets allocation-free while allowing a surface RTT resize callback to pass the same stable facade again and rebuild against its replacement attachment. The renderable rebuilds the group-1 bind group when the resource version changes.

setShaderExternalTexture validates that the external declaration exists and increments _resourceVersion only when wrapper identity changes. Renderables still rebuild group 1 every frame while the material has external declarations because imported external textures expire independently of material state.

Convenience wrappers may be added if they stay small and tree-shakable:

export function setShaderFloat(material: ShaderMaterial, name: string, value: number): void;
export function setShaderVector3(material: ShaderMaterial, name: string, value: readonly [number, number, number]): void;
export function setShaderMatrix(material: ShaderMaterial, name: string, value: Float32Array): void;

The core implementation should route all wrappers through setShaderUniform.

WGSL Authoring Contract

User WGSL must define complete vertex and fragment entry points. Lite does not rewrite entry point bodies.

Recommended entry point names are mainVertex and mainFragment, but options may later expose entry point names if needed. Phase 1 can require:

@vertex
fn mainVertex(input: VertexInput) -> VertexOutput { ... }
@fragment
fn mainFragment(input: VertexOutput) -> @location(0) vec4<f32> { ... }

Lite prepends a generated prelude before user source:

  1. SceneUniforms from the shared scene group (@group(0) @binding(0)).
  2. ShaderSystemUniforms for requested per-mesh system values (@group(1) @binding(0)).
  3. Optional ShaderUniforms for custom uniforms (@group(1) @binding(1)).
  4. Texture/sampler declarations for options.samplers.
  5. External texture/sampler declarations for options.externalTextures.
  6. WGSL const declarations for options.defines.
  7. VertexInput generated from options.attributes.
  8. Opt-in getFinalWorld(input) and getFinalColor(input) helpers, specialized for the active pipeline variant.

User WGSL must not declare:

  • @group(0) bindings.
  • @group(1) bindings using names generated by the material.
  • struct VertexInput unless an option explicitly opts out of generated input.
  • Duplicate uniform, sampler, external texture, or define identifiers.

Generated names intentionally match the names listed in the options where possible:

  • System matrix fields are available as shaderSystem.world, shaderSystem.worldViewProjection, etc.
  • Custom uniforms are available as shaderUniforms.time, shaderUniforms.direction, etc.
  • Texture samplers are available as <name> and <name>Sampler.
  • External textures are available as their declared names with generated <name>Sampler filtering samplers and are sampled with textureSampleBaseClampToEdge.
  • Scene fields remain available through scene.viewProjection, scene.view, scene.vEyePosition, etc.

Internal Architecture

File manifest

packages/babylon-lite/src/material/shader/
shader-material.ts Public types, factory, setters, validation.
shader-material-view-gpu.ts Terminal private view-UBO retirement.
enable-shader-material-instance-world.ts Opt-in regular/thin-instance final-world helper.
enable-shader-material-final-color.ts Opt-in effective vertex/instance color helper.
shader-group-builder.ts MeshGroupBuilder entry point and lazy renderable import.
shader-renderable.ts Per-scene/per-mesh renderables, UBO writes, bind groups.
shader-pipeline.ts Generated prelude, BGL creation, pipeline lookup.
shader-pipeline-cache.ts Lazy cross-material bindings, modules, and pipeline cache.
enable-shader-material-pipeline-sharing.ts Opt-in global cache attachment and layout-identity memo.
enable-async-shader-pipeline-compilation.ts Real-device async descriptor preparation.
shader-vb-support.ts Tiny opt-in seam and canonical attribute layouts.
shader-vb.ts Declared formats, per-mesh packing, grouping, bounded defaults.

Group builder

Every material returned by createShaderMaterial sets _buildGroup to shaderGroupBuilder.

export const shaderGroupBuilder: MeshGroupBuilder = async (scene, meshes) => {
const { buildShaderMaterialRenderables } = await import("./shader-renderable.js");
const result = buildShaderMaterialRenderables(scene, meshes);
shaderGroupBuilder._rebuildSingle = result.rebuildSingle;
return result;
};

The group builder has no module-level registry and imports renderable code only when a scene actually uses ShaderMaterial.

Per-material grouping

buildShaderMaterialRenderables(scene, meshes) groups meshes by ShaderMaterial instance. Each material instance owns:

  • Normalized source strings.
  • Normalized attributes.
  • Normalized uniform/sampler/external-texture/define declarations.
  • Pipeline variant cache for target signatures.
  • One custom UBO per material if custom uniforms exist.
  • Per-texture and per-external-texture slots plus resource version.

Opaque ShaderMaterials may batch multiple meshes under one renderable if they share one material instance and target pipeline. Transparent ShaderMaterials should emit one renderable per mesh so frame-graph sorting can use each mesh world center.

A merged opaque renderable has no single source mesh for the frame graph to visibility-filter. Its render-bundle recording loop must therefore skip each packet whose mesh has visible === false; steady-state per-frame updates remain unchanged.

Pipeline cache

Without the global enabler, isolated materials retain their per-material bindings/modules/pipelines, and multi-material build groups may still install a scoped shared cache with enableShaderPipelineCache(engine, meshes). The global enabler reuses the same cache implementation. shader-pipeline.ts owns only a nullable (device, material) => void seam and invokes it once, with optional chaining, at the start of getOrCreateShaderPipelineBindings. The seam implementation in enable-shader-material-pipeline-sharing.ts selects the current device cache and assigns it only when the material's current cache differs. This keeps all opt-in semantics behind the enabler while ensuring an already prepared material retargets when its engine's real GPUDevice changes. The cache owner has no runtime import of the pipeline owner or global enabler; its pipeline protocol dependencies are type-only. The root entry re-exports the public enabler from its separate extension, never through the scoped cache module.

/** @internal */
export function _setSharedShaderPipelineCache(seam: ((device: GPUDevice, material: ShaderMaterial) => void) | null): void;
/** @internal */
export function retargetShaderPipelineCache(material: ShaderMaterial, device: GPUDevice): void;
/** @internal */
export function _getShaderDeviceCache(device: GPUDevice): ShaderPipelineCache;
/** @internal */
export function _setShaderVertexBuffersKey(resolve: ((layouts: readonly GPUVertexBufferLayout[]) => string) | null): void;
/** @internal */
export function _serializeShaderVertexBuffers(layouts: readonly GPUVertexBufferLayout[]): string;

The internal setters, cache accessor, serializer and cache protocol are stripped from the emitted declarations. retargetShaderPipelineCache moves only a material that already has a shared cache; it does not opt an independent material into sharing. ShaderMaterial carries its shared-cache reference and exact-material/bindings module memo as typed @internal state, without a public/internal companion interface. Renderer and pipeline state extensions inherit that cache field; they must not redeclare it with a narrower protocol containing only the cache generation.

Device caches are stored in a lazily allocated WeakMap<GPUDevice, DeviceCache>. Each device cache owns bindings, shader-module and pipeline maps. Bindings are keyed by attribute names and declared formats, typed uniform declarations, sampler declarations, external-texture declarations and storage-buffer declarations. Equal generated WGSL strings share device-local modules identified by device-cache-local numeric IDs. A per-material module memo is valid only for the exact material/view identity and exact bindings object, so an inheriting material view cannot accidentally reuse its source's generated fragment code.

The complete shared pipeline key JSON-encodes:

  • target signature (color/depth formats, depth comparison and sample count);
  • logical variant key, including alpha-to-coverage specialization;
  • vertex and fragment module IDs;
  • the serialized vertex-buffer layouts;
  • alpha/blend, depth write/compare/bias, culling, topology and stencil state.

Generated modules already capture source WGSL, generated preludes, defines, declarations and instance attributes. Consequently normal/no-color views, color/depth-only targets, packed mesh layouts and thin-instance color variants share only when their complete generated code and pipeline state are equal.

Ordinary scoped caches use the pure _serializeShaderVertexBuffers serializer, with no layout-identity memo. The global enabler installs a resolver through _setShaderVertexBuffersKey; the cache owner only calls that optional resolver or falls back to the pure serializer. The extension owns a lazily allocated WeakMap<readonly GPUVertexBufferLayout[], string> keyed by layout-array identity. The serialized form is [arrayStride, stepMode ?? "vertex", attributes], with each attribute represented by [shaderLocation, offset, format]. Repeated lookups of the same array traverse it once; a different array with equal descriptors produces the same string and therefore shares the pipeline. The layout array, every descriptor and every reusable attribute collection are immutable after the array's first lookup. Builders may finish or replace layouts before lookup, but later changes require a new array rather than in-place mutation. The packed-mesh and thin-instance paths complete their stride/offset, matrix and optional color layouts before the array is captured.

Both paths return exactly the same serialized string, embedded as a string in the outer pipeline-key JSON. Enabling after a scoped cache has compiled pipelines or started async compilation must not change the key representation, replace the device cache or bindings, clear modules/pipelines/pending maps, or advance the cache generation. An equivalent late material joins that same device cache. Repeated enabling replaces the installed callbacks without wrapping them or resetting the layout memo.

clearShaderPipelineCache() drops the device-cache weak map and increments the global generation. Existing scoped caches lazily clear their bindings/modules when next used; globally enabled materials are reassigned to a fresh cache for the current device. Existing material bindings then renew because their recorded cache generation or device no longer matches, and source-first view renewal still invalidates each view's module memo through the new bindings identity. Clearing does not disable the global seam. The immutable vertex-layout identity memo intentionally survives cache generations: its serialized descriptor value is device-independent and remains valid under the immutability contract.

Async preparation must resolve bindings against the real device before descriptor capture. It calls retargetShaderPipelineCache(material, engine._device), then performs the normal bindings lookup. Only pipeline creation runs through the synthetic capture device; the optional sharing seam is not called there. Pending compilation therefore stays in the real device's shared bindings store, deduplicates equivalent materials (including plain, thin-instance and thin-instance-color variants), and the completed pipeline is reused by the synchronous first bind.

No Map, WeakMap or Set is allocated at module import time. The non-opted-in pipeline path does not import the cache owner and pays only for the nullable optional call.

Bind group layout

The pipeline layout is:

GroupOwnerBindings
0
Frame graph render task
SceneUniforms, scene lights UBO
1
ShaderMaterial
system UBO, optional custom UBO, textures, samplers, external texture/sampler pairs

Group 1 binding order:

  1. ShaderSystemUniforms at binding 0. Always present so the layout is stable.
  2. ShaderUniforms at binding 1 if custom uniform declarations exist.
  3. Texture/sampler pairs in declaration order.
  4. External texture/sampler pairs in declaration order.

UBO layout

Use computeUboLayout() from src/shader/ubo-layout.ts. Do not split WGSL strings or parse user shader source.

ShaderSystemUniforms contains only requested per-mesh values:

UniformTypeSource
world
mat4x4<f32>
mesh.worldMatrix
worldView
mat4x4<f32>
view * world in Lite matrix convention
worldViewProjection
mat4x4<f32>
scene.viewProjection * world in Lite matrix convention
projection
mat4x4<f32>
active pass camera projection
screenSize
vec2<f32>
active pass target width/height
alphaCutoff
f32
material/system value, default 0.4

Scene-level values should be aliased or read from group 0 rather than copied per mesh when possible:

UniformPreferred source
view
scene.view
viewProjection
scene.viewProjection
cameraPosition
scene.vEyePosition.xyz

If a caller requests the Babylon-style viewProjection string, the generated prelude may expose an alias function or const-like local expression in helper code, but it should not allocate a duplicate per-mesh UBO slot.

Matrix convention

Lite's camera helper computes viewProjection as projection * view, and material templates currently multiply clip positions by scene.viewProjection * worldPosition according to existing engine conventions. ShaderMaterial must use the same convention so it matches Standard, PBR, and NodeMaterial.

Floating origin

Under LWR (35-large-world-rendering.md) the frame the system uniforms describe is eye-relative, not absolute. getViewMatrix forces the view translation to zero on a floating-origin camera because it expects the mesh world to have already been rebased; Standard, PBR and Node renderables do that in their mesh-world pack, and ShaderMaterial does it in _shaderWorldMatrix(mesh, camera, out?), which both the default and the cached uniform writers call.

Consequences a shader author sees:

  • world, worldView and worldViewProjection all carry the camera-relative translation. They derive from one rebased matrix, so they stay in a single frame.
  • cameraPosition is (0, 0, 0) — in the frame world is expressed in, the camera is the origin. This keeps the documented scene.vEyePosition.xyz equivalence above, which _packSceneUniforms already zeroes under FO. An expression like cameraPosition - worldPos therefore still yields the correct eye-relative vector, and now at full precision. This is a breaking change for any custom shader that read cameraPosition as an absolute world-space position while useFloatingOrigin was enabled — see the release notes for the migration path.
  • Absolute world coordinates are not recoverable from the UBO. A shader that genuinely needs them should take them as a custom uniform.

With floating origin off, every value above is the plain absolute one and the path is copy-free.

_shaderWorldMatrix's third parameter, out, is optional and exists only so tests and other direct callers can supply their own destination instead of reusing the module-scoped FO scratch buffer — without it, two calls in a row alias the same array, and the second overwrites the first. The two renderable writers above never pass it, so they keep the original copy-free behaviour: the shared scratch under FO, mesh.worldMatrix returned by reference when FO is off. When out is given, both branches write into it (including the FO-off case, which would otherwise return mesh.worldMatrix unchanged) so passing out always means "the answer is here."

Pipeline Configuration

Defaults match normal Lite mesh rendering:

primitive.topology = "triangle-list";
primitive.frontFace = target.flipY ? "cw" : "ccw";
primitive.cullMode = options.backFaceCulling === false ? "none" : "back";
depthStencil.format = target.depthStencilFormat ?? "depth24plus-stencil8";
depthStencil.depthCompare = options.depthCompare ?? "greater-equal";
depthStencil.depthWriteEnabled = options.needAlphaBlending ? false : (options.depthWrite ?? true);
multisample.count = target.sampleCount;

Alpha blending:

if (needAlphaBlending) {
blend.color = { srcFactor: "src-alpha", dstFactor: "one-minus-src-alpha", operation: "add" };
blend.alpha = { srcFactor: "one", dstFactor: "one-minus-src-alpha", operation: "add" };
}

Alpha testing:

  • needAlphaTesting does not auto-inject fragment code.
  • The shader must explicitly call discard.
  • If the shader wants an engine-provided cutoff value, it lists "alphaCutoff" in uniforms and reads shaderSystem.alphaCutoff.

Shader Logic Outline

The simplest WGSL equivalent of the Babylon docs' basic ShaderMaterial:

struct VertexOutput {
@builtin(position) position: vec4<f32>,
};
@vertex
fn mainVertex(input: VertexInput) -> VertexOutput {
var out: VertexOutput;
out.position = shaderSystem.worldViewProjection * vec4<f32>(input.position, 1.0);
return out;
}
@fragment
fn mainFragment(input: VertexOutput) -> @location(0) vec4<f32> {
return vec4<f32>(1.0, 0.0, 0.0, 1.0);
}

The texture sampler equivalent:

struct VertexOutput {
@builtin(position) position: vec4<f32>,
@location(0) uv: vec2<f32>,
};
@vertex
fn mainVertex(input: VertexInput) -> VertexOutput {
var out: VertexOutput;
out.position = shaderSystem.worldViewProjection * vec4<f32>(input.position, 1.0);
out.uv = input.uv;
return out;
}
@fragment
fn mainFragment(input: VertexOutput) -> @location(0) vec4<f32> {
return textureSample(textureSampler, textureSamplerSampler, input.uv);
}

The video external texture equivalent:

struct VertexOutput {
@builtin(position) position: vec4<f32>,
@location(0) uv: vec2<f32>,
};
@vertex
fn mainVertex(input: VertexInput) -> VertexOutput {
var out: VertexOutput;
out.position = shaderSystem.worldViewProjection * vec4<f32>(input.position, 1.0);
out.uv = input.uv;
return out;
}
@fragment
fn mainFragment(input: VertexOutput) -> @location(0) vec4<f32> {
return textureSampleBaseClampToEdge(videoSampler, videoSamplerSampler, input.uv);
}

State Machine / Lifecycle

  1. User calls createShaderMaterial(options).
  2. Factory validates attributes, normalizes uniform/sampler/external-texture/define declarations, creates value slots, and attaches _buildGroup.
  3. User assigns the material to meshes and adds them to the scene.
  4. registerScene runs deferred builders; shaderGroupBuilder dynamically imports shader-renderable.ts.
  5. Renderable builder groups meshes by material instance.
  6. If global sharing was enabled, the bindings lookup selects the lazy cache for the engine's current device; otherwise the material keeps its existing scoped or private state.
  7. For each material, shader-pipeline.ts builds or reuses a generated prelude, shader module, group-1 BGL, and render pipeline for the active target signature.
  8. For each mesh, the renderable prepares the CPU system-uniform image, uses createUniformBuffer to allocate and upload it transactionally, then registers packet cleanup before creating group 1. The allocation label is preserved, and a failed initial upload destroys the unpublished buffer.
  9. Each frame, DrawBinding.update(context) refreshes system UBOs when world/camera/target data changes and custom UBOs when _uboVersion changes.
  10. Draw binds vertex buffers in material attribute order, sets index buffer and group 1, then issues an indexed draw with the mesh's optional storage-allocation _baseVertex.
  11. If setShaderTexture changes a texture, the next update recreates group 1 for affected mesh packets and updates acquired/released texture references.
  12. If external textures are declared, every packet update reimports the current video frames and recreates group 1 without changing ordinary texture leases.
  13. Material swaps use shaderGroupBuilder._rebuildSingle, matching Standard/PBR.

Auxiliary rebuilds receive an explicit MeshRebuildResources lifetime sink instead of registering their packet in scene-owned disposer maps. Storage-buffer allocations remain owned by their StorageBuffer and engine registration; packets bind the live validated handle but do not maintain a second, unread raw-buffer list. Disposing a shader packet releases its system UBO and texture leases without disposing caller-owned storage allocations.

releaseMaterialViewGpu(engine: EngineContext, view: MaterialView): void closes an abandoned ShaderMaterial view's own custom UBO. Detach every draw using that view first; this is terminal abandonment, not a suspension/resume API. A source material or a view borrowing its source's UBO is a no-op. The pipeline owner captures the exact owned buffer and its allocating engine, clears the private CPU/UBO state synchronously, and queues its destruction through retireGpuResources. Repeated calls before or after the queue fence do not destroy again. Shared bindings, shader modules, source uniforms, textures and storage buffers are untouched.

Pipeline context renewal also retires a view's previous owned custom UBO before replacing its state. Source-material cleanup is outside this view-abandonment API. Shared pipeline-cache lifetime is independent from packet and view-UBO lifetime. Device renewal resolves bindings on the new device before packets are rebuilt; dropping cache references is not a replacement for the existing device-recovery rebuild of other packet-owned GPU resources. The allocation records its engine, so renewal on another engine retires through the old engine's queue. Packet updates recreate a missing custom UBO and compare the actually bound buffer with the current one, in addition to resource revision, before drawing. Plain, transparent and thin-instance packets consume this same update path. Device recovery still owns rebuilding all other device-bound packet resources; this rule alone is not a complete packet recovery API.

The focused lifetime tests use real material/view factories and packet writers with inert GPU buffers: source plus three private views, fenced/idempotent release, borrowed-view safety, generation renewal and a changed allocating engine. They inspect submitted bytes and bindings; they do not measure rendered pixels, VRAM or browser performance.

Packet ownership is independent of material-override identity: a supplied resource sink owns an auxiliary packet; without one, the packet belongs to the scene's main mesh disposer list. Plain and thin-instance builders forward the same sink. The override flag only controls material identity guards while updating and drawing, not a second scene-owned auxiliary registry.

Babylon.js Equivalence Map

Babylon ShaderMaterial conceptLite ShaderMaterial equivalent
new ShaderMaterial(name, scene, route, options)
createShaderMaterial({ name, vertexSource, fragmentSource, ...options })
scene constructor argument
Not accepted; scene owns meshes/materials via addToScene
GLSL shader source
Not supported
WGSL shader source
Supported
attributes: ["position", "normal", "uv"]
Same names, validated against Lite supported attributes
uniforms: ["worldViewProjection"]
Same for known system uniforms
Custom uniforms: ["time"]
Use { name: "time", type: "f32" }
samplers: ["textureSampler"]
Same name, bound with setShaderTexture
externalTextures: ["videoSampler"]
Same name, bound with setShaderExternalTexture
defines: ["MyDefine"]
defines: { MyDefine: true }, emitted as WGSL const
setFloat, setVector3, setTexture methods
setShaderUniform, setShaderTexture standalone functions
needAlphaBlending
Transparent renderable + blend pipeline
needAlphaTesting
Hint only; shader performs discard
Per-draw thin-instance color opt-out
useThinInstanceColors: false on a color-independent ShaderMaterial

Dependencies

  • material/material.ts for base Material.
  • render/renderable.ts for MeshGroupBuilder, Renderable, DrawBinding.
  • render/scene-helpers.ts for scene bind group layout and default pipeline descriptor.
  • shader/scene-uniforms.ts for shared scene UBO WGSL.
  • shader/ubo-layout.ts for typed UBO packing.
  • material/shader/shader-pipeline-cache.ts for lazy device-keyed shared bindings, modules and pipelines.
  • material/shader/enable-shader-material-pipeline-sharing.ts for opt-in global attachment and layout memoization.
  • material/shader/enable-async-shader-pipeline-compilation.ts for real-device pending compilation and descriptor capture.
  • texture/texture-2d.ts for public texture resources.
  • texture/external-texture.ts for caller-owned video external-texture state.
  • resource/gpu-pool.ts for texture acquire/release and sampler reuse where appropriate.
  • camera/camera.ts for active pass view/projection data if a per-mesh system uniform requires projection.

Test Specification

Use Babylon.js doc playgrounds as BJS reference concepts while keeping Lite source WGSL-only.

SceneReference sourceLite coverage
ShaderMaterial basic color
Doc playground #5T8G3I
Position attribute, worldViewProjection, solid fragment color
ShaderMaterial texture sampler
Doc playground #D8IDR8
uv attribute, Texture2D, sampler pair, setShaderTexture
ShaderMaterial external texture
Lite-authored video reference
texture_external, per-frame import, setShaderExternalTexture
ShaderMaterial uniform update
Doc playground #5T8G3I#16
Custom scalar/vector/color uniform mutation through setShaderUniform
ShaderMaterial defines variant
Derived from doc defines option
WGSL const define emitted into prelude and included in pipeline key
ShaderMaterial alpha
Lite-authored WGSL reference
needAlphaBlending and explicit shader-side discard for alpha testing
Thin-instance color opt-out
Lite unit contract
Override keeps matrix instancing but omits color layout, sync and bind

Implementation should add lab scenes using the next available scene IDs, plus parity specs and bundle-size ceilings. The BJS side may use Babylon ShaderMaterial with GLSL from the docs; the Lite side must use equivalent WGSL and the new Lite ShaderMaterial.

Focused pipeline-sharing unit coverage uses inert GPU spies and real material/view factories:

  • default opt-out and enabled sharing for independently created materials;
  • preservation of distinct code, declarations, vertex layouts and render state;
  • current-device retargeting for materials that already hold another device cache;
  • cache-generation renewal, including source-first material views;
  • one traversal per immutable layout-array identity and sharing by equal replacement descriptors;
  • scoped opt-out without memoization and exact key continuity across late/repeated enabling;
  • already compiled scoped-cache reuse without renewed bindings, modules or pipelines;
  • late enabling while a scoped async compilation is pending, including all three instance variants;
  • pending async deduplication and completed synchronous reuse for plain, thin-instance and instance-color layouts;
  • emitted root-only zero-argument API exposure without the internal setter or cache interface.

These tests validate CPU-side ownership and cache contracts only. They do not claim rendered pixels, WGSL validation on a real GPU, VRAM reduction, bundle-size movement or performance improvement.

Focused validation, only when execution is authorized:

pnpm run lint:fix
pnpm run lint
pnpm exec vitest run tests/lite/unit/shader-pipeline-cache.test.ts tests/lite/unit/shader-pipeline-module-memo.test.ts tests/lite/unit/async-shader-pipeline-compilation.test.ts
pnpm exec vitest run tests/lite/build/public-api-types.test.ts -t "pipeline-sharing enabler"
git diff tests/lite/parity/bundle-size.spec.ts
git diff reference/lite/

The emitted API test requires a fresh package build. CI owns visual parity and repository-wide scene coverage. Do not run pnpm test, full parity, unfiltered scene bundles or pnpm test:perf from an agent session.