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 form | Lite 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 calleduseThinInstanceColors 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 presentFor 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 viashader-group-builder.ts→buildShaderGrouponly whenmeshes.some(m => m.thinInstances). Non-instanced ShaderMaterial scenes route through the unchanged synchronousbuildShaderMaterialRenderables. - 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 theVertexInputstruct differ, sogetOrCreateShaderPipeline()keys instanced pipelines on a compact non-empty variant suffix (0or1). The color bit is1only when the mesh has colors and the material did not opt out throughuseThinInstanceColors. - Instanced ShaderMaterial meshes render as one
_directrenderable 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.tshelper (same as Standard/PBR): whenenableThinInstanceGpuCulling(mesh)is set, the compute cull pass runs in the bindingupdate()and the draw becomesdrawIndexedIndirect. 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:
@vertexfn mainVertex(input: VertexInput) -> VertexOutput { ... }
@fragmentfn mainFragment(input: VertexOutput) -> @location(0) vec4<f32> { ... }Lite prepends a generated prelude before user source:
SceneUniformsfrom the shared scene group (@group(0) @binding(0)).ShaderSystemUniformsfor requested per-mesh system values (@group(1) @binding(0)).- Optional
ShaderUniformsfor custom uniforms (@group(1) @binding(1)). - Texture/sampler declarations for
options.samplers. - External texture/sampler declarations for
options.externalTextures. - WGSL const declarations for
options.defines. VertexInputgenerated fromoptions.attributes.- Opt-in
getFinalWorld(input)andgetFinalColor(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 VertexInputunless 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>Samplerfiltering samplers and are sampled withtextureSampleBaseClampToEdge. - 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:
| Group | Owner | Bindings |
|---|---|---|
| 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:
ShaderSystemUniformsat binding 0. Always present so the layout is stable.ShaderUniformsat binding 1 if custom uniform declarations exist.- Texture/sampler pairs in declaration order.
- 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:
| Uniform | Type | Source |
|---|---|---|
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:
| Uniform | Preferred 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,worldViewandworldViewProjectionall carry the camera-relative translation. They derive from one rebased matrix, so they stay in a single frame.cameraPositionis(0, 0, 0)— in the frameworldis expressed in, the camera is the origin. This keeps the documentedscene.vEyePosition.xyzequivalence above, which_packSceneUniformsalready zeroes under FO. An expression likecameraPosition - worldPostherefore still yields the correct eye-relative vector, and now at full precision. This is a breaking change for any custom shader that readcameraPositionas an absolute world-space position whileuseFloatingOriginwas 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:
needAlphaTestingdoes not auto-inject fragment code.- The shader must explicitly call
discard. - If the shader wants an engine-provided cutoff value, it lists
"alphaCutoff"inuniformsand readsshaderSystem.alphaCutoff.
Shader Logic Outline
The simplest WGSL equivalent of the Babylon docs' basic ShaderMaterial:
struct VertexOutput { @builtin(position) position: vec4<f32>,};
@vertexfn mainVertex(input: VertexInput) -> VertexOutput { var out: VertexOutput; out.position = shaderSystem.worldViewProjection * vec4<f32>(input.position, 1.0); return out;}
@fragmentfn 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>,};
@vertexfn mainVertex(input: VertexInput) -> VertexOutput { var out: VertexOutput; out.position = shaderSystem.worldViewProjection * vec4<f32>(input.position, 1.0); out.uv = input.uv; return out;}
@fragmentfn 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>,};
@vertexfn mainVertex(input: VertexInput) -> VertexOutput { var out: VertexOutput; out.position = shaderSystem.worldViewProjection * vec4<f32>(input.position, 1.0); out.uv = input.uv; return out;}
@fragmentfn mainFragment(input: VertexOutput) -> @location(0) vec4<f32> { return textureSampleBaseClampToEdge(videoSampler, videoSamplerSampler, input.uv);}State Machine / Lifecycle
- User calls
createShaderMaterial(options). - Factory validates attributes, normalizes uniform/sampler/external-texture/define declarations, creates value slots, and attaches
_buildGroup. - User assigns the material to meshes and adds them to the scene.
registerSceneruns deferred builders;shaderGroupBuilderdynamically importsshader-renderable.ts.- Renderable builder groups meshes by material instance.
- 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.
- For each material,
shader-pipeline.tsbuilds or reuses a generated prelude, shader module, group-1 BGL, and render pipeline for the active target signature. - For each mesh, the renderable prepares the CPU system-uniform image, uses
createUniformBufferto 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. - Each frame,
DrawBinding.update(context)refreshes system UBOs when world/camera/target data changes and custom UBOs when_uboVersionchanges. - 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. - If
setShaderTexturechanges a texture, the next update recreates group 1 for affected mesh packets and updates acquired/released texture references. - If external textures are declared, every packet update reimports the current video frames and recreates group 1 without changing ordinary texture leases.
- 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 concept | Lite 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.tsfor baseMaterial.render/renderable.tsforMeshGroupBuilder,Renderable,DrawBinding.render/scene-helpers.tsfor scene bind group layout and default pipeline descriptor.shader/scene-uniforms.tsfor shared scene UBO WGSL.shader/ubo-layout.tsfor typed UBO packing.material/shader/shader-pipeline-cache.tsfor lazy device-keyed shared bindings, modules and pipelines.material/shader/enable-shader-material-pipeline-sharing.tsfor opt-in global attachment and layout memoization.material/shader/enable-async-shader-pipeline-compilation.tsfor real-device pending compilation and descriptor capture.texture/texture-2d.tsfor public texture resources.texture/external-texture.tsfor caller-owned video external-texture state.resource/gpu-pool.tsfor texture acquire/release and sampler reuse where appropriate.camera/camera.tsfor 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.
| Scene | Reference source | Lite 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:fixpnpm run lintpnpm 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.tspnpm exec vitest run tests/lite/build/public-api-types.test.ts -t "pipeline-sharing enabler"git diff tests/lite/parity/bundle-size.spec.tsgit 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.