Chapter 6: Materials
Materials bundle shaders, textures, and parameters into a unit the renderer can consume. Building on the textures of Chapter 5, this chapter covers how surface properties are written into the deferred G-Buffer, how Taos's PBR material maps the glTF KHR_materials_* set onto GPU resources, and how advanced features — clear-coat, sheen, iridescence, anisotropy, and translucency — are stored and rendered.
6.1 Textures in the G-Buffer#
The deferred G-Buffer encodes surface properties for the lighting pass. As Taos's PBR model grew from "albedo + roughness + metallic" to the full glTF KHR_materials_* set, the G-Buffer grew with it — from two color attachments to six, plus depth. The formats are defined once (in block_geometry_pass.ts) and shared by every geometry pass:
| Loc | Label | Format | Channels |
|---|---|---|---|
| 0 | gbuffer.albedo |
rgba8unorm |
RGB albedo · A roughness (specular-AA widened) |
| 1 | gbuffer.normal |
rgba16float |
RGB world normal (n·0.5+0.5) · A metallic |
| 2 | gbuffer.emissive |
rgba16float |
RGB HDR emissive radiance (allows glow > 1) |
| 3 | gbuffer.params |
rgba8unorm |
R shadingModelId · G specular (F0=0.08·g) · B occlusion · A materialId |
| 4 | gbuffer.params2 |
rgba8unorm |
per-pixel extension multipliers: R clearcoat · G clearcoat rough · B sheen rough · A iridescence |
| 5 | gbuffer.params3 |
rgba16float |
RGB world-space anisotropy tangent · A anisotropy strength |
| — | gbuffer.depth |
depth32float |
depth (world-position reconstruction) |
The rgba16float format on locations 1 and 5 is critical — world-space normals and tangents are signed and need more precision than unorm provides; rgba16float on location 2 lets emission exceed 1.0 for HDR glow (e.g. KHR_materials_emissive_strength).
Clear values are chosen so a pass that doesn't write an attachment degrades gracefully. params clears to (0, 0.5, 1, 0) — DefaultLit, F0 0.04, no occlusion, material id 0. params2 clears to (1, 1, 1, 1) so every extension multiplier is identity. params3 clears to (0, 0, 0, 0) — strength 0 is isotropic, and the lighting pass gates the anisotropic lobe on a non-zero strength. This is why only the mesh geometry passes write locations 4 and 5: voxel and terrain geometry leave them at their identity clears and light exactly as before. Locations 3's A channel (materialId) and the params2/params3 attachments are the deferred path's bridge to the advanced material features described in §6.6 — they carry just enough per-pixel data to drive the extension BRDF lobes without bloating the G-Buffer with one attachment per feature.
6.2 The Physically Based Rendering (PBR) Material System#
The Material abstract class (src/engine/material.ts) defines the interface that all materials must implement:
export abstract class Material {
abstract readonly shaderId: string;
transparent: boolean = false;
abstract getShaderCode(passType: MaterialPassType): string;
abstract getBindGroupLayout(device: GPUDevice): GPUBindGroupLayout;
abstract getBindGroup(device: GPUDevice): GPUBindGroup;
update?(queue: GPUQueue): void;
destroy?(): void;
}
Material Pass Types#
A material can provide different shader code for different render passes:
export enum MaterialPassType {
Forward = 'forward', // Forward rendering (transparent objects)
Geometry = 'geometry', // Deferred G-Buffer fill (opaque)
SkinnedGeometry = 'skinnedGeometry', // Skinned mesh G-Buffer fill
SkinnedForward = 'skinnedForward', // Skinned transparent (multi-light)
SkinnedForwardPlus = 'skinnedForwardPlus', // Skinned, tiled forward+ light culling
}
Most materials use only Geometry (opaque objects). Transparent materials use Forward. Skinned meshes use SkinnedGeometry (deferred) or, when transparent, one of the skinned forward variants — PbrMaterial.getShaderCode returns a distinct WGSL string per pass type.
Shared Bind Group Slot#
All materials place their resources at @group(2), which is reserved in the render pass pipeline layouts:
export const MATERIAL_GROUP = 2;
This means a material's bind group can contain the uniform buffer plus up to six textures and a sampler. The albedo map is the only optional one (a shader variant, §6.5); normal, MER, emissive, ext, and anisotropy are always bound, with 1×1 defaults standing in when absent:
@group(2) @binding(0) var<uniform> material: MaterialUniforms;
@group(2) @binding(1) var albedo_map : texture_2d<f32>; // variant-gated
@group(2) @binding(2) var normal_map : texture_2d<f32>;
@group(2) @binding(3) var mer_map : texture_2d<f32>; // metallic/emissive/roughness
@group(2) @binding(4) var mat_samp : sampler;
@group(2) @binding(5) var emissive_map : texture_2d<f32>; // glTF emissive (sRGB)
@group(2) @binding(6) var ext_map : texture_2d<f32>; // clearcoat/sheen/iridescence scalars
@group(2) @binding(7) var anisotropy_map: texture_2d<f32>; // direction + strength
The material uniform struct has grown to 112 bytes to carry the glTF metallic-roughness parameters plus the scalar/color factors of the supported extensions:
struct MaterialUniforms {
albedo : vec4<f32>, // RGBA base color
emissiveFactor: vec3<f32>, // linear emissive multiplier
roughness : f32,
metallic : f32,
specular : f32, // dielectric reflectance (F0 = 0.08·specular)
uvOffset : vec2<f32>, // atlas tile / KHR_texture_transform offset
uvScale : vec2<f32>,
uvTile : vec2<f32>, // UV tiling repetition
alphaCutoff : f32, // MASK alpha-test threshold (0 = off)
normalScale : f32, // glTF normalTexture.scale
shadingModelId: f32, // 0 = DefaultLit, 1 = Unlit
materialId : f32, // index into the MaterialParams SSBO (§6.2)
specularColor : vec3<f32>, // dielectric F0 tint
uvRotation : f32, // KHR_texture_transform rotation (radians)
anisotropyStrength: f32,
anisotropyRotation: f32, // + padding to 112 bytes
}
Not every parameter fits in this uniform. Some are per-pixel (clearcoat/sheen/iridescence multipliers, the anisotropy tangent) and travel through the G-Buffer (§6.1); others are per-material but bulky (sheen color, iridescence thickness, subsurface, diffuse-transmission color) and live in a shared storage buffer indexed by materialId, described next.
The MaterialParams Storage Buffer#
The deferred and point/spot lighting passes are fullscreen — they read the G-Buffer but have no per-surface uniform to consult. For material parameters that are constant per material (not textured) yet too bulky or too rare to spend a per-pixel G-Buffer channel on, Taos carries only an 8-bit material id in the G-Buffer (gbuffer.params.a) and looks the real values up in a per-device storage buffer, MaterialParamsTable (src/renderer/materials/material_params_table.ts):
// 20 floats (80 bytes) per material; 256 entries (custom0 is 8-bit).
// [0..2] specularColor [3] clearcoatFactor [4] clearcoatRoughness
// [5] sheenRoughness [6] iridescenceFactor [7] iridescenceIor
// [8..10] sheenColor [11] iridescenceThickness
// [12..14] diffuseTransmissionColor [15] diffuseTransmission
// [16..18] subsurfaceColor [19] subsurface
PbrMaterial.update() calls table.register(this, {...}) each time its uniform is flushed; the table assigns a stable id on first sight (reused thereafter) and writes that material's row. Id 0 is the reserved neutral default — white specular, every extension factor zero — so any surface that writes materialId = 0 (voxels, terrain, custom passes) reads sensible defaults without ever registering. On overflow past 256 distinct materials the table returns 0 and warns once.
Refractive transmission and volume parameters are not in this table — the forward TransmissionPass (§6.7) draws with the material objects in hand and reads those fields directly into per-draw uniforms.
Pipeline Caching#
Pipelines are cached by shaderId — a stable identifier shared by all instances of a material subclass:
private _pipelineCache = new Map<string, GPURenderPipeline>();
private _getPipeline(device: GPUDevice, material: Material): GPURenderPipeline {
let pipeline = this._pipelineCache.get(material.shaderId);
if (pipeline) return pipeline;
// Create and cache pipeline
const shaderModule = device.createShaderModule({
code: material.getShaderCode(MaterialPassType.Geometry),
});
pipeline = device.createRenderPipeline({
layout: device.createPipelineLayout({
bindGroupLayouts: [
cameraBGL, // group 0
modelBGL, // group 1
material.getBindGroupLayout(device), // group 2
],
}),
// ... vertex, fragment, depth, primitive state ...
});
this._pipelineCache.set(material.shaderId, pipeline);
return pipeline;
}
Materials sharing a shaderId MUST return identical WGSL source and bind group layouts — the cache assumes they are interchangeable.
Material Update Pattern#
Materials can implement an optional update() method that the pass calls once per draw to flush dirty uniforms:
// In the geometry pass execute():
for (let i = 0; i < this._drawItems.length; i++) {
const item = this._drawItems[i];
// Upload model matrix
ctx.queue.writeBuffer(this._modelBuffers[i], 0, modelData.buffer);
// Flush material updates
item.material.update?.(ctx.queue);
}
This pattern allows materials to lazily update GPU uniform buffers only when their CPU-side properties change, using a dirty flag internally.
6.3 Material Passes#
Different render passes use different subsets of the material system. The mesh kind (static / skinned / voxel) and the transparent flag determine which pass actually draws an object:
GeometryPass draws opaque materials into the G-Buffer. It expects materials to output albedo+roughness and normal+metallic in the fragment shader.
BlockGeometryPass draws voxel chunk geometry. Chunks use a dedicated shader (chunk_geometry.wgsl) that samples the block texture atlas and packs the same G-Buffer format.
ForwardPass renders transparent materials with per-pixel lighting. Transparent materials use depthWriteEnabled: false and alpha blending:
fragment: {
module: shaderModule,
entryPoint: 'fs_main',
targets: [{
format: HDR_FORMAT,
blend: {
color: { srcFactor: 'src-alpha', dstFactor: 'one-minus-src-alpha' },
alpha: { srcFactor: 'one', dstFactor: 'one-minus-src-alpha' },
},
}],
},
6.4 Shader Management and Caching#
WGSL shaders are stored in src/shaders/ and loaded at module scope via Vite's ?raw import:
import lightingWgsl from '../../shaders/deferred_lighting.wgsl?raw';
This inlines the shader source as a JavaScript string at build time. No runtime fetch is needed.
Common Shader Module#
The src/shaders/common.wgsl file defines shared types and utility functions used across multiple shaders:
struct CameraUniforms {
view : mat4x4<f32>,
proj : mat4x4<f32>,
viewProj : mat4x4<f32>,
invViewProj: mat4x4<f32>,
position : vec3<f32>,
near : f32,
far : f32,
}
Each Material subclass returns complete WGSL source from getShaderCode(), which may concatenate shared code with specific implementations. Reinventing this per material avoids the complexity of a full shader include system while keeping the shader source self-contained.
6.5 Shader Variants and Default Textures#
A PbrMaterial is configurable: any of its texture maps (albedoMap, normalMap, merMap) can be present or absent independently. Two strategies handle that optionality:
- Shader variants — compile the shader twice, with and without the binding, using
#ifdefguards. The variant without the map has no binding slot at all and notextureSamplecall. - Always-bound defaults — declare the binding unconditionally and supply a 1×1 placeholder texture whose value makes the sampled result a no-op multiplier. One shader, one bind-group layout, one always-on sample per pixel.
Taos uses variants for the albedo map and always-bound defaults for normal and MER. The split keeps the variant cache small (one bit → two variants instead of eight) while only paying for the two extra fragment samples that materials without normal/MER maps don't strictly need.
The mechanism behind #ifdef — ctx.createShaderModule(source, label, defines) and the preprocessor that runs ahead of #import resolution — is described in §2.7 Shader Variants. This section covers how the material system actually drives it.
The Variant Mask#
Each PbrMaterial instance exposes a small bitmask describing which compile-time features are active for this material:
// ── from src/renderer/materials/pbr_material.ts ──
const HAS_ALBEDO_MAP = 1 << 0;
get variantMask(): number {
let mask = 0;
if (this._albedoMap) mask |= HAS_ALBEDO_MAP;
return mask;
}
A material with an albedo map reports 0b1 = 1; one without reports 0. The mask is computed from the same _albedoMap field that drives getBindGroup(), so it always reflects what the material is actually about to bind. (One additional bit, PBR_HAS_INSTANCING, gets OR-ed in by the particle pass when it draws meshes instanced; it doesn't appear on the material itself.)
The Shader Guards#
The geometry shader uses #ifdef HAS_ALBEDO_MAP around both the albedo binding declaration and the sampling code:
// ── from src/shaders/geometry.wgsl ──
@group(2) @binding(0) var<uniform> material: MaterialUniforms;
#ifdef HAS_ALBEDO_MAP
@group(2) @binding(1) var albedo_map: texture_2d<f32>;
#endif
// Always bound; PbrMaterial supplies 1×1 defaults for materials without maps.
@group(2) @binding(2) var normal_map: texture_2d<f32>;
@group(2) @binding(3) var mer_map : texture_2d<f32>;
@group(2) @binding(4) var mat_samp : sampler;
// ...
@fragment
fn fs_main(in: VertexOutput) -> FragmentOutput {
let atlas_uv = fract(in.uv * material.uvTile) * material.uvScale + material.uvOffset;
#ifdef HAS_ALBEDO_MAP
let albedo = textureSample(albedo_map, mat_samp, atlas_uv).rgb * material.albedo.rgb;
#else
let albedo = material.albedo.rgb;
#endif
// Default MER texel (1, 0, 1) leaves material uniforms unchanged.
let mer = textureSample(mer_map, mat_samp, atlas_uv);
let roughness = max(material.roughness * mer.b, 0.04);
let metallic = material.metallic * mer.r;
let emission = mer.g;
// ... TBN + normal-map sample, no #ifdef needed ...
}
Guarding the albedo binding declaration — not just the sample — is what makes the variant pay off. The HAS_ALBEDO_MAP-undefined variant doesn't have an albedo_map binding at all; its bind-group layout omits the slot entirely, and the compiled shader is strictly smaller.
The Default Textures#
For every always-bound map — normal, MER, emissive, ext, and anisotropy — PbrMaterial keeps a device-cached 1×1 view and substitutes it in getBindGroup() when the material has no map of its own:
// ── from src/renderer/materials/pbr_material.ts ──
entries.push({ binding: 2, resource: this._normalMap?.view ?? defaultNormalView });
entries.push({ binding: 3, resource: this._merMap?.view ?? defaultMerView });
entries.push({ binding: 4, resource: sampler });
entries.push({ binding: 5, resource: this._emissiveMap?.view ?? defaultEmissiveView });
entries.push({ binding: 6, resource: this._extMap?.view ?? defaultExtView });
entries.push({ binding: 7, resource: this._anisotropyMap?.view ?? defaultAnisotropyView });
The values are chosen so that sampling the default has the same effect as not sampling at all:
| Map | Default RGBA | Decoded | Why it's a no-op |
|---|---|---|---|
| Normal | (128, 128, 255, 255) |
tangent-space (0, 0, 1) |
Identity TBN perturbation — world normal unchanged. |
| MER | (255, 0, 255, 255) |
R·metallic ×1, +0 emissive, B·roughness ×1 | Uniform metallic/roughness pass through; no emission. |
| Emissive | (0, 0, 0, 255) |
black | Multiplied by emissiveFactor → no contribution. |
| Ext | (255, 255, 255, 255) |
all 1.0 | Every extension multiplier is identity; SSBO factors pass through. |
| Anisotropy | (255, 128, 255, 255) |
dir (1, 0), strength 1 |
Constant tangent + full strength → matches the constant-factor path. |
The bind-group layout always reserves slots 2, 3, 5, 6, and 7, regardless of variantMask. The shader always samples them. The cost is a handful of extra texture samples per fragment for materials that don't carry these maps; the savings are in pipeline-cache size and in not having to keep a half-dozen independent #ifdef paths in lockstep. Only albedo earns a variant — see Variants vs. Always-Bound Defaults below for the trade-off.
Wiring It Together#
The geometry pass ties shader variant, bind-group layout, and pipeline together via a single cache key:
// ── from src/renderer/render_graph/passes/geometry_pass.ts ──
const variantMask = (item.material as any).variantMask ?? 0;
enc.setPipeline(this._getPipeline(item.material, variantMask));
// ...
private _getPipeline(material: Material, variantMask: number): GPURenderPipeline {
const key = `${material.shaderId}:${variantMask}`;
let pipeline = this._pipelineCache.get(key);
if (pipeline) return pipeline;
const defines: Record<string, string> = {};
if (variantMask & 1) defines['HAS_ALBEDO_MAP'] = '1';
const shaderModule = this._ctx.createShaderModule(
material.getShaderCode(MaterialPassType.Geometry, variantMask),
`GeometryShader[${key}]`,
defines,
);
pipeline = this._device.createRenderPipeline({
label: `GeometryPipeline[${key}]`,
layout: this._device.createPipelineLayout({
bindGroupLayouts: [
this._cameraBgl,
this._modelBgl,
material.getBindGroupLayout(this._device, variantMask),
],
}),
// ... vertex, fragment, depth, primitive state ...
});
this._pipelineCache.set(key, pipeline);
return pipeline;
}
Three things are keyed by the same variantMask:
- The shader module.
definesis built from the mask bits and fed toctx.createShaderModule(), which preprocesses the WGSL before passing it to WebGPU. - The bind-group layout.
material.getBindGroupLayout(device, variantMask)returns a layout whose entries match the bindings actually present in this variant's compiled shader. - The pipeline cache key.
shaderId:variantMaskensures one compiled pipeline per (material type, feature combination). With one variant bit, a scene of any size produces at most two pipelines per pass type.
WebGPU validates pipeline-layout/shader-binding compatibility at pipeline creation time, so keeping the layout and the shader in lockstep through the same mask is what makes the whole thing safe. A mismatch — for example, returning the all-textures layout while compiling a no-textures shader — would fail at createRenderPipeline immediately, not at draw time.
Variants vs. Always-Bound Defaults#
Why not pick one strategy and stick with it? Each has a clear cost profile:
- Variants push complexity into the pipeline cache. The cache size grows exponentially with the number of variant bits — three independent bits would mean up to eight pipelines per pass type, ×2 for transparents, ×3 for the pass types (forward/geometry/skinned) — so cheap when bits are few, expensive when many.
- Always-bound defaults push complexity into the fragment shader. Every pixel sampled, every frame, pays for one extra
textureSample(and a tiny multiply with the default value) regardless of whether the material actually has a map.
For Taos's PBR material, the empirical sweet spot is:
- Albedo stays as a variant. Materials without an albedo map are common (debug surfaces, solid-color geometry, anything driven by per-instance tint), and skipping the sample on those fragments is a worthwhile saving. The albedo branch also threads through the material's alpha channel, which interacts with transparency and discard logic — keeping the no-map path lets a future cutout-alpha optimization specialize cleanly.
- Normal, MER, emissive, ext, and anisotropy become always-bound defaults. Most production materials carry the common maps, so the "no map" fast path was earning very little — and the extension maps (ext, anisotropy) are rare enough that a variant per combination would explode the pipeline cache. Folding all five into always-bound defaults keeps the variant count at two (albedo on/off) per pass type, and the one shader stays readable instead of fanning out into dozens of
#ifdefpermutations.
The same trade-off appears wherever optional shader inputs show up. A material with dozens of feature flags should lean heavily on defaults; a tightly-scoped renderer with one or two on/off knobs can afford full variants. Both patterns coexist in Taos's PBR material as a deliberate hybrid.
Same Mask Across Pass Types#
The variant mask isn't pass-specific — it's a property of the material instance and gets used by every pass that draws it. GeometryPass, ForwardPass, and SkinnedGeometryPass all consult material.variantMask and pass it to both getShaderCode(passType, mask) and getBindGroupLayout(device, mask). The same opaque mesh drawn into the G-Buffer and into a depth-only shadow map will compile two pipelines (one per pass type) but they share the same HAS_ALBEDO_MAP setting, keyed by the same mask.
6.6 Advanced PBR Material Features#
Beyond base color, metallic, and roughness, PbrMaterial implements the bulk of the glTF KHR_materials_* extension set — the secondary BRDF lobes and surface layers that turn "plastic and metal" into "car paint, fabric, soap bubbles, leaves, and brushed steel." The full lighting integration (IBL, shadows, the punctual/sun loops) is Chapter 7's subject; here we go a level deeper than the storage table above — into how each feature is parsed from glTF, encoded, stored, decoded, and evaluated, with the actual shader code for each lobe.
| Feature | glTF extension | Where stored | Path |
|---|---|---|---|
| Normal mapping | normalTexture |
normal map + uniform (normalScale) |
all |
| MER (metallic/emissive/roughness/AO) | pbrMetallicRoughness / occlusionTexture |
MER map + uniform | all |
| Dielectric specular | KHR_materials_specular / _ior |
uniform (specular, specularColor) + SSBO |
all |
| Clear-coat | KHR_materials_clearcoat |
SSBO + ext map (params2.rg) |
deferred |
| Sheen (fabric) | KHR_materials_sheen |
SSBO + ext map (params2.b) |
deferred |
| Iridescence | KHR_materials_iridescence |
SSBO + ext map (params2.a) |
deferred |
| Anisotropy | KHR_materials_anisotropy |
uniform + params3 tangent |
deferred + forward |
| Diffuse transmission | KHR_materials_diffuse_transmission |
SSBO | deferred + forward + point/spot |
| Subsurface scattering | engine model (no glTF ext) | SSBO | deferred + forward + point/spot |
| Unlit | KHR_materials_unlit |
uniform (shadingModelId) |
all |
| Emissive (+ strength) | emissiveFactor / KHR_materials_emissive_strength |
emissive map + uniform | all |
Features marked deferred render in the deferred (opaque) path today; the forward transparent path renders those materials with the base lobes only. This is a deliberate scope line, not a permanent one — the SSBO and G-Buffer plumbing is shared, so extending the forward shaders is incremental.
Where each parameter lives#
Every feature follows the same four-stage journey, and the only real design decision per parameter is where it is stored — a choice driven by two questions: is it per-pixel (textured) or per-material (constant)? And is it needed by the fullscreen deferred/punctual passes (which have only the G-Buffer to read) or by a pass that still holds the material in hand?
The four destinations:
- The 112-byte uniform (§6.2) — small per-material scalars the forward path reads directly:
roughness,metallic,specular,normalScale,shadingModelId, anisotropy strength/rotation, and thematerialIdindex. - The
MaterialParamsSSBO (§6.2) — bulky or rare per-material values the fullscreen deferred/punctual passes can't get from a uniform, fetched by the 8-bitmaterialId:specularColor, the clear-coat / sheen / iridescence / transmission / subsurface factors and colors. - G-Buffer extension channels (§6.1) — genuinely per-pixel extension data: the
params2attachment carries clear-coat/sheen/iridescence scalar multipliers;params3carries the world-space anisotropy tangent. - Texture maps — the bound group-2 textures: albedo, normal, MER, emissive, the packed ext map, and the anisotropy map.
The deferred and forward paths then reconverge on the same lobe math — sometimes literally, via the shared pbr_extensions.wgsl module (point/spot and forward), and sometimes via a hand-kept inline copy (deferred, interleaved with its IBL code). The subsections below walk each feature through that pipeline.
Normal mapping and the TBN frame#
A normal map perturbs the shading normal per pixel, in tangent space — so the geometry pass must build a tangent-bitangent-normal (TBN) basis to rotate the sampled normal into world space. Taos builds it from the interpolated world normal and the mesh tangent (whose .w carries the bitangent handedness sign), with one robustness twist:
// ── from src/shaders/geometry.wgsl ──
let N = normalize(in.world_norm);
// Orthogonalize the tangent against N; fall back to an arbitrary perpendicular
// when the tangent is parallel to N (auto-generated fallback on a UV-less mesh),
// so a singular TBN never produces NaN G-buffer normals (box X-face speckle).
let T_proj = in.world_tan.xyz - N * dot(in.world_tan.xyz, N);
let t_len = length(T_proj);
let T_ortho = select(T_proj / t_len,
normalize(cross(N, select(vec3(0.0, 1.0, 0.0), vec3(1.0, 0.0, 0.0), abs(N.y) > 0.99))),
t_len < 1e-5);
let B = cross(N, T_ortho) * in.world_tan.w;
let tbn = mat3x3<f32>(T_ortho, B, N);
let n_raw = textureSample(normal_map, mat_samp, atlas_uv).rgb * 2.0 - 1.0;
// normalTexture.scale widens/narrows the tangent-space perturbation (XY only).
let n_ts = vec3(n_raw.xy * material.normalScale, n_raw.z);
let mapped_N = normalize(tbn * n_ts);
Three details matter. The Gram-Schmidt re-orthogonalization (T_proj) keeps the tangent perpendicular to the (possibly normal-mapped-and-interpolated) normal. The singular-tangent fallback synthesizes a perpendicular when the mesh tangent collapses onto the normal — common on procedurally-generated or UV-less geometry — which otherwise produces NaN normals and the classic speckled-cube-face artifact. And normalScale multiplies only the tangent-space XY, leaving Z intact, so it flattens or exaggerates relief without denormalizing.
When a material carries no normal map, PbrMaterial binds the 1×1 default texel (128, 128, 255), which decodes to tangent-space (0, 0, 1) — an identity perturbation, so mapped_N == N. The mapped normal then feeds geometric specular anti-aliasing: roughness is widened by sub-pixel normal variance at fill time (geometric_specular_aa, see specular_aa.wgsl) and the widened value is what gets stored, so the deferred lighting pass never double-applies it.
MER: metallic, emissive, roughness, occlusion#
Taos packs four scalar surface properties into one MER texture (engine convention: R=metallic, G=emissive scalar, B=roughness, A=baked occlusion). The geometry pass multiplies each channel into the matching uniform factor, so an untextured material binds the 1×1 default (255, 0, 255, 255) — metallic ×1, +0 emissive, roughness ×1, AO 1 — and the constants pass through unchanged:
// ── from src/shaders/geometry.wgsl ──
let mer = textureSample(mer_map, mat_samp, atlas_uv);
let roughness = max(material.roughness * mer.b, 0.04);
let metallic = material.metallic * mer.r;
// Two emissive paths coexist: the MER atlas glow scalar (mer.g, tinted by
// albedo) and the glTF emissive map × emissiveFactor.
let em_tex = textureSample(emissive_map, mat_samp, atlas_uv).rgb;
let emission = albedo * mer.g + em_tex * material.emissiveFactor;
glTF does not use this layout — its metallic-roughness texture is (G=roughness, B=metallic) with occlusion in a separate texture's R. GltfLoader rewrites them into engine-MER on import, also baking the occlusion strength into the alpha:
// ── from src/assets/gltf_loader.ts (loadGltfMerTexture) ──
const g = px[i + 1]; // roughness in glTF
const b = px[i + 2]; // metallic in glTF
px[i] = b; // R = metallic
px[i + 1] = 0; // G = emission scalar (unused; glTF emission is a separate map)
px[i + 2] = g; // B = roughness
px[i + 3] = 255; // A = occlusion (overwritten from the occlusion texture below)
// ... then, per pixel, folding occlusionTexture.r and its strength:
const ao = occPx[i] / 255;
const occluded = 1 + occStrength * (ao - 1); // strength fades AO toward 1.0
px[i + 3] = clamp(occluded, 0, 1) * 255;
The roughness floor of 0.04 written into the G-Buffer keeps the GGX denominator finite (a perfectly smooth surface would divide by zero). The baked occlusion rides through the G-Buffer in params.b and multiplies only the ambient/IBL term in the lighting pass — never direct light or emissive.
Perceptual-roughness floor (specular anti-aliasing)#
That 0.04 is a numerical floor — just enough to keep the math defined. On top of it sits a higher perceptual-roughness floor whose job is visual: a near-mirror surface (roughness → 0) packs its specular highlight into a sub-pixel sliver, so the highlight crawls and sparkles as the surface or camera moves — temporal aliasing that no amount of MSAA fixes, because the detail is below the sample grid. Geometric specular AA (widening roughness by the per-pixel normal variance, §6.1 — that's the "specular-AA widened" note on the albedo attachment's alpha) handles the curvature-driven case, but a flat smooth surface has no normal variance to detect. Filament's answer is to simply clamp perceptual roughness to a MIN_PERCEPTUAL_ROUGHNESS (~0.045 on desktop), and Taos now does the same:
- The forward shaders clamp
base_roughnessto0.045directly. - The deferred path keeps the G-Buffer write at
0.04(it's only a storage floor) and applies the perceptual floor in the lighting pass from aminRoughnessuniform — so it's tunable at runtime viaDeferredLightingFeature.minRoughnesswithout a re-bake:
// ── from src/shaders/deferred_lighting.wgsl ──
// hard 0.04 for numerical safety; minRoughness (~0.045) for specular AA.
let roughness = max(albedo_rough.a, max(light.minRoughness, 0.04));
The difference between 0.04 and 0.045 is imperceptible on a still frame — its whole purpose is to round off the sub-pixel highlight that would otherwise shimmer in motion. The tonemap_grading_test sample exposes the floor as a slider over a thin near-mirror metal rod (a reliable aliasing magnet) to make the effect easy to see.
Dielectric specular and IOR#
For non-metals, the Fresnel reflectance at normal incidence (F0) is small and roughly colorless — the canonical 0.04. Taos exposes it through a specular scalar where F0 = 0.08 · specular (so the default 0.5 gives 0.04), an optional specularColor tint, and lets KHR_materials_ior drive it physically. The loader converts IOR to F0 and back into the engine's scalar:
// ── from src/assets/gltf_loader.ts ──
const ior = exts?.KHR_materials_ior?.ior ?? 1.5;
const iorF0 = ((ior - 1) / (ior + 1)) ** 2; // 1.5 → 0.04
const specularFactor = exts?.KHR_materials_specular?.specularFactor ?? 1.0;
const specular = (iorF0 / 0.08) * specularFactor; // back into the 0.08·s scalar
At shading time, F0 is reconstructed and lerped toward albedo by metallic — metals have no separate dielectric reflectance, their F0 is their color:
// ── from src/shaders/deferred_lighting.wgsl ──
// 0.08·specular·specularColor for dielectrics (default 0.5 / white → 0.04),
// albedo for metals.
let base_F0 = mix(0.08 * mat_specular * mat_spec_color, albedo, metallic);
specular rides in the uniform (the forward path reads it directly), but specularColor is mirrored into the SSBO — the fullscreen deferred and point/spot passes have no per-surface uniform, so they fetch the tint by materialId. This is the simplest example of the SSBO indirection that the layered lobes lean on next.
Material ids: the per-pixel lobe lookup#
§6.2 introduced the MaterialParams SSBO and its 8-bit id. Here is the decode side. The deferred and point/spot passes recover the id from the G-Buffer's params.a (stored as a normalized byte) and read the row, then multiply in the per-pixel params2 multipliers so a clear-coat or sheen mask texture modulates the constant factor with no extra binding:
// ── from src/shaders/deferred_lighting.wgsl ──
var mat_params = materialParams[u32(round(mat_data.a * 255.0))];
// Per-pixel extension multipliers (loc 4): clearcoat · clearcoat roughness ·
// sheen roughness · iridescence. Identity (1,1,1,1) when untextured.
let mat_ext = textureLoad(materialData2Tex, coord, 0);
mat_params.clearcoatFactor = mat_params.clearcoatFactor * mat_ext.r;
mat_params.clearcoatRoughness = mat_params.clearcoatRoughness * mat_ext.g;
mat_params.sheenRoughness = mat_params.sheenRoughness * mat_ext.b;
mat_params.iridescenceFactor = mat_params.iridescenceFactor * mat_ext.a;
The forward path, which still has the material uniform in hand, indexes the same SSBO directly by materialParams[u32(material.materialId)] — no ×255 round-trip, because it isn't reading the id back out of an 8-bit G-Buffer channel. Both paths then arrive at the same MaterialParams struct and the same lobe code.
Clear-coat and sheen: layered lobes#
Clear-coat and sheen are additive layers over the base Cook-Torrance lobe. The shared shade_direct_ext (used by forward and point/spot; the deferred pass keeps a synced inline copy) shows how they stack — the clear-coat is a second colorless dielectric specular lobe that the base must pass through, so the base is attenuated by (1 − F_coat):
// ── from src/shaders/modules/pbr_extensions.wgsl (shade_direct_ext) ──
// Clearcoat: a second colorless dielectric lobe (F0 0.04) over the base, with
// its own roughness. The base passes through the coat → attenuated by (1 − F_coat).
if (mp.clearcoatFactor > 0.0) {
let cc_rough = max(mp.clearcoatRoughness, 0.04);
let Fcc = fresnel_schlick(VdotH, vec3(0.04)).x * mp.clearcoatFactor;
let Dcc = distribution_ggx(NdotH, cc_rough);
let Vcc = visibility_smith_ggx(NdotV, NdotL, cc_rough);
diff = diff * (1.0 - Fcc);
spec = spec * (1.0 - Fcc) + vec3(Dcc * Vcc * Fcc);
}
// Sheen (KHR_materials_sheen): a soft retroreflective fabric lobe — Charlie
// distribution (Estevez-Kulla) + Ashikhmin visibility. sheenColor 0 → skipped.
let sheen = mp.sheenColor;
if (sheen.r + sheen.g + sheen.b > 0.0) {
let sheen_rough = max(mp.sheenRoughness, 0.07);
let inv_a = 1.0 / sheen_rough;
let sin_h2 = max(1.0 - NdotH * NdotH, 0.0);
let d_charlie = (2.0 + inv_a) * pow(sin_h2, inv_a * 0.5) / (2.0 * PI);
let v_sheen = 1.0 / max(4.0 * (NdotL + NdotV - NdotL * NdotV), 1e-4);
out_rad += sheen * d_charlie * v_sheen * radiance * NdotL;
}
Clear-coat (car paint, lacquer, varnish) is the easy intuition: a thin glossy varnish on top that adds its own sharp highlight and dims what's beneath it. Sheen is the opposite shape — the Charlie distribution peaks at grazing angles rather than at the mirror direction, which is exactly the soft retroreflective rim you see on velvet, suede, and brushed fabric. Both lobes also have IBL counterparts in the deferred/forward ambient term (the coat samples the prefiltered cube at its own roughness; sheen adds a tinted slice of the diffuse irradiance), so they hold up under image-based lighting, not just punctual lights.
Cloth wrap and a separate clear-coat normal#
Two refinements bring the cloth and clear-coat models closer to Filament's. Both are forward-path features today (the deferred inline lobes carry the parameters for struct-layout parity but don't yet read them — a documented follow-up); the material_ext_test sample drives both from sliders.
Cloth diffuse wrap. A sheen lobe alone gives the grazing rim of fabric, but real cloth also wraps light around the silhouette — the threads scatter so the shaded side stays softly lit rather than crushing to black at the terminator. Filament's cloth model captures this with an energy-conservative wrapped diffuse, which Taos adds when clothWrap > 0: the diffuse cosine is softened so light bleeds past the shadow line, and the unlit side blends toward a subsurfaceColor tint.
// ── from src/shaders/modules/pbr_extensions.wgsl (shade_direct_ext) ──
if (mp.clothWrap > 0.0) {
let wrap = clamp((dot(N, L) + 0.5) / 2.25, 0.0, 1.0); // wraps past the terminator
let d = wrap / PI; // wrapped Lambert scalar
let cloth_col = kD * (albedo * d + mp.subsurfaceColor * (1.0 - d));
diff_rad = mix(diff * radiance * NdotL, cloth_col * radiance, mp.clothWrap);
}
Paired with a sheen color, clothWrap turns the sheen lobe into a full cloth surface — author it on PbrMaterial with sheenColor + clothWrap.
Separate clear-coat normal. The clear-coat lobe used to share the base shading normal, so a glossy coat over a normal-mapped base inherited every bump in its reflection — wrong for car paint, where the lacquer is mirror-smooth even when the panel beneath has orange-peel or a flake normal. Setting clearcoatNormalToGeometric (Filament's default clearCoatNormal behavior) makes the coat lobe — and its IBL reflection — use the smooth geometric normal instead of the bumpy base normal, so the coat reflects the environment cleanly over a textured base.
For this the coat had to become a separate additive term with its own cosine — rather than being folded into the base specular — so it can be evaluated against a different normal:
// ── from src/shaders/modules/pbr_extensions.wgsl (shade_direct_ext) ──
let coat_N = select(N, n_geom, mp.clearcoatNormalMode > 0.5); // base vs geometric
let cNdotL = max(dot(coat_N, L), 0.0);
let cNdotH = max(dot(coat_N, H), 0.0);
let cNdotV = max(dot(coat_N, V), 0.0);
let Fcc = fresnel_schlick(VdotH, vec3(0.04)).x * mp.clearcoatFactor;
diff = diff * (1.0 - Fcc);
spec = spec * (1.0 - Fcc);
coat_rad = vec3(Dcc * Vcc * Fcc) * radiance * cNdotL; // coat lobe, own normal + cosine
When the coat normal equals the base normal (the default) this is bit-for-bit the old folded form, so the change is a no-op until you opt in.
Iridescence: thin-film interference#
Iridescence (KHR_materials_iridescence) is the rainbow shimmer of soap bubbles, oil slicks, and anodized metal. Physically it is thin-film interference: light reflects off both the top and bottom of a microscopically thin film, and the two reflections interfere constructively or destructively depending on the film thickness, the viewing angle, and the wavelength — so the color of F0 shifts with view angle.
Taos ports the Belcour-Barla model from the glTF Sample Viewer. Rather than recompute the full Airy summation here, the material system's job is to feed it: the per-material iridescenceIor and iridescenceThickness (nm) live in the SSBO, the per-pixel iridescenceFactor rides the ext map (params2.a), and the lobe replaces the base F0 with the view-dependent thin-film F0, blended by the factor:
// ── from src/shaders/deferred_lighting.wgsl ──
var F0 = base_F0;
if (mat_params.iridescenceFactor > 0.0) {
// eval_iridescence: Airy summation over the two film interfaces (iridescence.wgsl).
let irid_F0 = eval_iridescence(1.0, mat_params.iridescenceIor, NdotV,
mat_params.iridescenceThickness, base_F0);
F0 = mix(base_F0, irid_F0, mat_params.iridescenceFactor);
}
Because iridescence acts purely by rewriting F0, it composes for free with everything downstream — the same shifted F0 drives the direct specular Fresnel and the IBL specular term, so a soap bubble iridesces under both the sun and the environment. The heavy lifting is the spectral eval_iridescence (iridescence.wgsl), which evaluates the optical path difference 2·n·d·cosθ₂, the per-interface phase shifts, and an Airy sum projected through CIE-fit Gaussians back to linear Rec.709.
Anisotropy: stretched highlights#
Brushed metal, hair, and satin have a grain — their micro-scratches run in one direction, stretching the specular highlight perpendicular to it. Anisotropy (KHR_materials_anisotropy) models this by splitting roughness into two values, one along the surface tangent and one along the bitangent.
This is the one extension that needs a per-pixel direction, which no scalar G-Buffer channel can carry. So the geometry pass does the work up front: it reads the tangent-space direction from the anisotropy map, rotates it by anisotropyRotation, expresses it in world space, and writes it (with strength) into the rgba16float params3 attachment:
// ── from src/shaders/geometry.wgsl ──
let aniso_tex = textureSample(anisotropy_map, mat_samp, atlas_uv);
let aniso_d0 = aniso_tex.rg * 2.0 - 1.0; // tangent-space direction
let aniso_cos = cos(material.anisotropyRotation);
let aniso_sin = sin(material.anisotropyRotation);
let aniso_dir = vec2(aniso_d0.x * aniso_cos - aniso_d0.y * aniso_sin,
aniso_d0.x * aniso_sin + aniso_d0.y * aniso_cos);
let aniso_T = normalize(T_ortho * aniso_dir.x + B * aniso_dir.y); // → world space
out.material_data_3 = vec4(aniso_T, material.anisotropyStrength * aniso_tex.b);
The lighting pass decodes that tangent and, when strength is non-zero, swaps the isotropic GGX for the anisotropic form. The roughness split is the heart of it:
// ── from src/shaders/modules/pbr_brdf.wgsl ──
fn aniso_alphas(roughness: f32, anisotropy: f32) -> vec2<f32> {
let a = roughness * roughness;
return vec2(max(a * (1.0 + anisotropy), 1e-3), // at — along the tangent
max(a * (1.0 - anisotropy), 1e-3)); // ab — along the bitangent
}
// ── from src/shaders/modules/pbr_extensions.wgsl (shade_direct_ext) ──
if (abs(anisotropy) > 0.001) {
let aT = normalize(aniso_dir - N * dot(aniso_dir, N));
let aB = cross(N, aT);
let aa = aniso_alphas(roughness, anisotropy);
D = distribution_ggx_aniso(NdotH, dot(aT, H), dot(aB, H), aa.x, aa.y);
Vis = visibility_smith_ggx_aniso(NdotV, NdotL,
dot(aT, V), dot(aB, V), dot(aT, L), dot(aB, L), aa.x, aa.y);
} else {
D = distribution_ggx(NdotH, roughness);
Vis = visibility_smith_ggx(NdotV, NdotL, roughness);
}
Anisotropy is one of the two extensions that also runs in the forward path (so anisotropic transparents match), because both deferred and forward share this exact shade_direct_ext code. The IBL side bends the reflection vector toward the bitangent (anisotropic_bent_normal / anisotropic_reflect) so the environment streaks along the grain too — the same way a brushed-steel pan smears window highlights.
Diffuse transmission and subsurface scattering#
These are the non-refractive half of translucency: light that comes through a thin surface without bending the scene behind it (that refractive half is §6.7). They are the only extension lobes that run in all three lit paths — deferred, forward, and point/spot — because they live entirely in shade_direct_ext.
Diffuse transmission (KHR_materials_diffuse_transmission — backlit leaves, paper, lampshades) is a Lambertian back-gather: it uses the back-side cosine max(-N·L, 0), so it only contributes when the light is behind the surface relative to the shading normal:
// ── from src/shaders/modules/pbr_extensions.wgsl (shade_direct_ext) ──
if (mp.diffuseTransmissionFactor > 0.0) {
let back_NdotL = max(-dot(N, L), 0.0);
out_rad += mp.diffuseTransmissionFactor * mp.diffuseTransmissionColor
* albedo / PI * back_NdotL * radiance_trans;
}
Subsurface scattering is an engine model (no glTF extension) — a view-dependent forward-scatter glow for the light that travels a short way through thin geometry (the bright rim on backlit leaves, ears, wax, marble). The transmission half-vector is the light direction bent slightly toward the normal, and the term peaks when the eye looks back toward the light through the surface:
// ── from src/shaders/modules/pbr_extensions.wgsl ──
const SSS_DISTORTION : f32 = 0.2; // bends the transmission half-vector toward N
const SSS_POWER : f32 = 4.0; // sharpens the view-dependent glow
// ...
if (mp.subsurface > 0.0) {
let sss_h = normalize(L + N * SSS_DISTORTION);
let sss_t = pow(max(dot(V, -sss_h), 0.0), SSS_POWER) * mp.subsurface;
out_rad += sss_t * mp.subsurfaceColor * radiance_trans;
}
Both lobes take radiance_trans rather than radiance. For the directional sun the caller passes this un-shadowed: a shadow cast on the surface's front face must not kill the light coming through from behind it — otherwise a backlit leaf in its own shadow would go black instead of glowing. For punctual lights the two are equal, because a point light's visibility (range, cone, cookie) genuinely does gate the transmitted light.
Unlit and emissive#
The two simplest features bookend the lobe machinery. Unlit (KHR_materials_unlit) is a different shading model, not a lobe — the geometry pass writes shadingModelId = 1 into params.r, and the deferred pass short-circuits before any lighting:
// ── from src/shaders/deferred_lighting.wgsl ──
// KHR_materials_unlit (shadingModelId 1): output base color directly — no
// lighting, IBL, SSGI, or aerial perspective.
if (mat_data.r > 0.5) {
return vec4(albedo, 1.0);
}
(The test is > 0.5 rather than == 1 because the id round-trips through a unorm channel as a float.) Unlit is what you want for skyboxes, UI-in-world, holograms, and reference/debug surfaces that must show their exact authored color.
Emissive is additive HDR radiance, stored in the rgba16float emissive attachment (loc 2) so it can exceed 1.0 — required for surfaces that should bloom. KHR_materials_emissive_strength is folded into the factor at load time, so the rest of the engine never has to know the extension exists:
// ── from src/assets/gltf_loader.ts ──
const emissiveStrength = exts?.KHR_materials_emissive_strength?.emissiveStrength ?? 1.0;
const ef = matJson?.emissiveFactor ?? [0, 0, 0];
const emissiveFactor = [ef[0] * emissiveStrength, ef[1] * emissiveStrength, ef[2] * emissiveStrength];
The geometry pass computes the final emissive RGB (the MER glow scalar tinted by albedo, plus the glTF emissive map × emissiveFactor — see the MER subsection), and the deferred pass adds it to the lit result completely unmodulated: color = lit_direct + lit_ambient + ssgi + emissive. Emission is the one term that bypasses occlusion, shadows, and the unlit branch alike — light a surface emits, it emits.
6.7 Refractive Transmission (Translucency)#
KHR_materials_transmission makes a surface see-through: instead of alpha-blending, the material refracts the lit scene behind it. Because that requires sampling the already-rendered scene color, it cannot run in the deferred G-Buffer fill (which hasn't been lit yet) and it cannot run as ordinary alpha blending (which only tints, never bends). Taos renders it in a dedicated forward pass — TransmissionPass (src/renderer/render_graph/passes/transmission_pass.ts, shader transmission.wgsl) — scheduled after the deferred opaque scene is fully lit.
The per-fragment algorithm:
- Refract the view ray through the front surface using the material's
ior, march it through the volumethickness, and project the exit point back to a framebuffer UV. - Sample the lit scene at that refracted UV. Surface roughness selects a blurred mip of the scene snapshot (
lod ≈ roughness²·16), so smooth glass samples mip 0 and frosted glass reads a heavily blurred copy. - Beer-Lambert absorption tints the transmitted light by the distance traveled:
exp(σ·thickness)withσ = log(attenuationColor) / attenuationDistance(KHR_materials_volume). This is what gives thick colored glass its depth — edges stay clear, the body deepens in color. - Dispersion (
KHR_materials_dispersion) spreads the IOR across R/G/B so transmitted light separates into prismatic fringes — chromatic aberration, the rainbow edge of a gemstone or prism. - Fresnel environment reflection adds a glossy surface reflection from the split-sum IBL (prefiltered cube + BRDF LUT), so glass reflects as well as transmits.
The rest of this section unpacks the pieces that make this work: the two-pass structure that snapshots the lit scene, the mip chain that turns roughness into a blur, the screen-space refraction, and the depth handling that lets the glass sit correctly among foreground and background geometry.
Two passes: snapshot, then refract#
Refraction needs to read the lit scene while writing to it, which a single pass can't do — you can't sample the same HDR texture you're rendering into. So addToGraph registers two passes. The first (transfer) copies the lit HDR aside into a snapshot and builds a mip chain on it; the second (render) draws the glass, sampling that snapshot and compositing back onto the original HDR:
// ── from src/renderer/render_graph/passes/transmission_pass.ts ──
// No transmissive meshes this frame → skip both passes entirely. Otherwise the
// snapshot copy + full mip chain would run every frame even for opaque scenes.
if (this._items.length === 0) {
return { hdr: deps.hdr };
}
// A full mip chain so rough transmission can sample a blurred background by LOD.
const mipLevelCount = Math.floor(Math.log2(Math.max(ctx.width, ctx.height))) + 1;
// Pass 1 — snapshot the lit HDR (the "scene behind" the glass) + build mips.
graph.addPass(`${this.name}.snapshot`, 'transfer', (b) => {
snapshot = b.createTexture({
label: 'TransmissionSnapshot', format: HDR_FORMAT,
width: ctx.width, height: ctx.height, mipLevelCount,
extraUsage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
});
b.read(deps.hdr, 'copy-src');
snapshot = b.write(snapshot, 'copy-dst');
b.setExecute((pctx, res) => {
const snap = res.getTexture(snapshot);
pctx.commandEncoder.copyTextureToTexture(
{ texture: res.getTexture(deps.hdr) },
{ texture: snap, mipLevel: 0 },
{ width: ctx.width, height: ctx.height },
);
generateMipmaps(ctx.device, snap, HDR_FORMAT, mipLevelCount, pctx.commandEncoder);
});
});
Splitting on _items.length === 0 matters in practice: a viewer that always registers a TransmissionFeature would otherwise pay for a full-screen copy plus a downsample pass per mip every frame, even when nothing on screen is transmissive. The early-out makes the whole feature free for opaque frames.
The scene snapshot and its mip chain#
The snapshot is allocated at full resolution with mipLevelCount levels. After mip 0 is filled by the copy, generateMipmaps box-downsamples 0 → 1 → 2 → … into the same command encoder, so the blur chain is ready before the render pass reads it:
// ── from src/assets/texture.ts ──
// Fills mip levels 1..mipLevelCount-1 by repeatedly box-downsampling the
// previous level on the GPU. Recorded into the snapshot pass's encoder so the
// caller controls ordering.
for (let mip = 1; mip < mipLevelCount; mip++) {
const pass = encoder.beginRenderPass({
colorAttachments: [{
view: chain.targetViews[mip], // render into mip N
loadOp: 'clear', storeOp: 'store', clearValue: [0, 0, 0, 0],
}],
});
pass.setPipeline(pipeline);
pass.setBindGroup(0, chain.bindGroups[mip]); // sampling mip N-1
pass.draw(3); // full-screen triangle
pass.end();
}
The per-mip views and bind groups are cached by texture identity (the snapshot is a pooled transient with stable identity across frames), so this records command-buffer work each frame but allocates no GPU objects after the first frame.
In the shader, roughness chooses which level to read. The mapping is quadratic so smooth glass stays pinned at mip 0 (sharp, no mip ghosting on near-mirror surfaces) while roughness ramps the blur up quickly toward the frosted end:
// ── from src/shaders/transmission.wgsl ──
let max_lod = f32(textureNumLevels(sceneColor) - 1);
let lod = clamp(roughness * roughness * 16.0, 0.0, max_lod);
// ... later, sampled with an explicit LOD (no implicit derivatives here):
transmitted = textureSampleLevel(sceneColor, sceneSampler, sample_uv, lod).rgb;
This is the cheap stand-in for a true rough-refraction integral: rather than tracing many refracted rays through the volume and averaging, a single ray reads a pre-blurred mip whose blur radius approximates the cone the rough surface would have scattered into. roughness² · 16 reaches the top of an 11-level (1080p) chain at roughness ≈ 0.83, so anything frostier saturates to the blurriest copy.
Refracting the view ray#
The displacement itself is screen-space. refract_sample_uv bends the view ray at the front interface with the GLSL-style refract, walks it thickness through the volume, projects the exit point back to clip space, and converts to a framebuffer UV (y-down, matching the snapshot):
// ── from src/shaders/transmission.wgsl ──
fn refract_sample_uv(world_pos: vec3<f32>, N: vec3<f32>, V: vec3<f32>,
ior: f32, thickness: f32, surface_uv: vec2<f32>) -> vec2<f32> {
let refr = refract(-V, N, 1.0 / max(ior, 1.0001));
let exit_world = world_pos + refr * thickness;
let exit_clip = camera.viewProj * vec4<f32>(exit_world, 1.0);
var uv = surface_uv; // fallback: this fragment's own UV
if (exit_clip.w > 1.0e-4) { // guard the perspective divide
uv = (exit_clip.xy / exit_clip.w) * vec2<f32>(0.5, -0.5) + 0.5;
}
return clamp(uv, vec2<f32>(0.0), vec2<f32>(1.0)); // stay on-screen
}
Two robustness details earn their keep. The w > 1e-4 guard falls back to the fragment's own screen UV when the exit point lands at or behind the camera plane, where the divide would otherwise explode into banding. The final clamp keeps the sample on-screen — the snapshot only contains what the camera saw, so a refraction that points off the framebuffer edge reads the edge texel rather than garbage. Both are reminders that this is a screen-space effect: it can only refract what's already in the snapshot.
Foreground and background: depth integration#
The snapshot is the background — it's the fully-lit opaque scene, so refraction reads exactly the geometry behind the glass. The interesting question is the foreground: an opaque object standing between the camera and the glass must occlude it, and the glass's own front faces must sort against each other. Both fall out of how the render pass uses depth:
// ── from src/renderer/render_graph/passes/transmission_pass.ts ──
// Composite onto the existing lit HDR (load, don't clear).
outHdr = b.write(deps.hdr, 'attachment', { loadOp: 'load', storeOp: 'store' });
// Test+write against the opaque scene depth, but `discard` the writes so the
// shared G-Buffer depth that later passes read is left unchanged.
b.write(deps.depth, 'depth-attachment', { depthLoadOp: 'load', depthStoreOp: 'discard' });
b.read(snapshot, 'sampled');
// ── pipeline state, from TransmissionPass.create ──
// Front faces only (one refractive layer per pixel).
primitive: { topology: 'triangle-list', cullMode: 'back', frontFace: 'ccw' },
depthStencil: { format: DEPTH_FORMAT, depthWriteEnabled: true, depthCompare: 'less-equal' },
Reading them together:
- Foreground occlusion. The pass loads the opaque scene's depth and tests
less-equalagainst it. An opaque object nearer than the glass already wrote a smaller depth, so the glass fragments behind it fail the test and are never drawn — the foreground object correctly hides the glass. (Thetransmission_testsample places an opaque cube directly in front of the middle sphere to prove this integration.) - Self-sorting.
cullMode: 'back'keeps only front faces, so each pixel sees a single refractive layer rather than front-and-back of the same shell. WithdepthWriteEnabledon, the nearest front face wins on concave or overlapping glass. - Leaving the G-Buffer alone. The pass writes depth so the glass sorts itself, but
depthStoreOp: 'discard'throws those writes away at pass end. Later passes (tonemap, post) read the original opaque depth, unperturbed by the transparent geometry.
The one thing the snapshot does not contain is other transmissive surfaces — it's taken before any glass is drawn. So glass-behind-glass refracts the opaque scene correctly but does not refract the nearer glass. That's the deliberate cost of a single screen-space snapshot rather than a per-object depth-peel.
Absorption, dispersion, and the reflective lobe#
With the background sample in hand, three operations finish the look. Beer-Lambert absorption attenuates it over the path length; base color tints it; and a Fresnel environment reflection is added on top, with (1 − kS) energy-conserving the split between what reflects and what transmits:
// ── from src/shaders/transmission.wgsl ──
// Beer-Lambert volume absorption over the path length (≈ thickness).
if (draw.attenuationDistance < 1.0e8 && thickness > 0.0) {
let sigma = log(max(draw.attenuationColor, vec3(1e-4))) / draw.attenuationDistance; // ≤ 0
transmitted = transmitted * exp(sigma * thickness);
}
transmitted = transmitted * base_color;
// Fresnel environment reflection off the front surface (split-sum IBL).
let kS = fresnel_schlick_roughness(NdotV, vec3(0.04), roughness);
let prefiltered = textureSampleLevel(prefilter_cube, ibl_sampler, reflect(-V, N),
roughness * (IBL_MIP_LEVELS - 1.0)).rgb;
let env_brdf = textureSampleLevel(brdf_lut, ibl_sampler, vec2(NdotV, roughness), 0.0).rg;
let reflection = prefiltered * (kS * env_brdf.x + env_brdf.y);
// What isn't reflected (1 − kS) is transmitted, weighted by the transmission factor.
let color = transmitted * transmission * (1.0 - kS) + reflection;
Dispersion happens one step earlier, at the sampling stage, and it is where Taos models chromatic aberration for refraction. A real dielectric's index of refraction is wavelength-dependent (the physical effect is dispersion): short blue wavelengths bend more than long red ones, so white light entering a prism or gemstone fans out into a spectrum and the transmitted image picks up colored fringes at high-contrast edges. That color separation — the same artifact a camera lens produces when its glass fails to focus all wavelengths to one point — is chromatic aberration.
The engine reproduces it geometrically rather than as a cheap screen-space channel offset. When dispersion > 0, the single IOR is spread into three slightly different values and refract_sample_uv is called once per channel, so each color refracts along its own ray and exits the volume at a slightly different framebuffer UV; the three single-channel reads are then recombined into one RGB color. Because the split is driven by the actual refracted geometry, the fringes widen with the IOR contrast and the path length (thickness), and they swing the correct way as the surface curves — exactly how a real prism behaves, not a uniform red/blue smear painted over the whole image.
The spread magnitude follows the glTF reference: half_spread = (ior − 1) / 50 · dispersion. The (ior − 1) factor ties the aberration to how strongly the material bends light in the first place (a higher-index gem disperses more), and the dispersion factor is the artist's dial — 0 collapses all three IORs back to one and a single shared sample is taken (no aberration), while larger values widen the red↔blue gap and make the prismatic edges more pronounced. Red uses ior − half_spread (bends least, the lowest effective index), blue uses ior + half_spread (bends most), and green stays on the unmodified ray as the midpoint:
// ── from src/shaders/transmission.wgsl ──
if (draw.dispersion > 0.0) {
let half_spread = (draw.ior - 1.0) / 50.0 * draw.dispersion; // glTF reference spread
let uv_r = refract_sample_uv(in.world_pos, N, V, draw.ior - half_spread, thickness, surface_uv);
let uv_g = refract_sample_uv(in.world_pos, N, V, draw.ior, thickness, surface_uv);
let uv_b = refract_sample_uv(in.world_pos, N, V, draw.ior + half_spread, thickness, surface_uv);
transmitted = vec3(
textureSampleLevel(sceneColor, sceneSampler, uv_r, lod).r,
textureSampleLevel(sceneColor, sceneSampler, uv_g, lod).g,
textureSampleLevel(sceneColor, sceneSampler, uv_b, lod).b,
);
}
The per-draw uniform feeding all of this is 192 bytes (model + normal matrices, base color, attenuation color, roughness, transmission, ior, thickness, attenuation distance, dispersion, with scalar padding to avoid a vec3 bumping the struct to 208). The pass binds the base-color and MER maps, plus the per-pixel transmissionTexture (.r) and thicknessTexture (.g) multipliers the loader resolved in §4.7; materials lacking any of these get a 1×1 default whose value is the identity for that channel.
The result is the full translucency spectrum in one material: opaque (transmission 0), thin translucent (diffuse transmission / subsurface, §6.6), and refractive (transmission > 0, optionally with volume absorption and dispersion).
6.8 OpenPBR Surface#
Everything so far has been Taos's home-grown PbrMaterial — the glTF KHR_materials_* set wired onto the G-Buffer. OpenPBR Surface is a different starting point: an industry-standard, fully-specified layered über-BSDF (the Academy Software Foundation's successor to Autodesk's standard_surface), with one cleanly-named parameter set that subsumes metal, dielectric, coated, fabric, thin-film, and subsurface materials in a single shader. Taos ships it as OpenPbrMaterial (src/renderer/materials/openpbr_material.ts), a second Material subclass that plugs into the exact same passes as PbrMaterial.
It is tractable precisely because of §6.6: the lobes are already written. OpenPBR is mostly a re-parameterization and a stricter layering order over the same Cook-Torrance/Charlie/iridescence math, so OpenPbrMaterial is a new front-end (a parameter struct + a WGSL composition) rather than a new BRDF.
The slab stack#
OpenPBR defines a fixed slab order, outer to inner: a clear coat, then a fuzz (sheen) layer, then the base — either a metal or a dielectric with optional subsurface — modified by a thin-film, with an emission slab. Each layer that reflects some energy must attenuate what passes to the layer below it, which is what makes the stack energy-conserving rather than a pile of additive lobes:
src/shaders/openpbr_surface.wgsl is the forward shader. openpbr_direct() evaluates one light through the whole stack in that order — base lobes first, then fuzz and subsurface added, then the coat folded on top so it attenuates and tints everything beneath:
// ── from src/shaders/openpbr_surface.wgsl (openpbr_direct) ──
var out_rad = (diff + spec) * radiance * NdotL; // base diffuse + specular
if (s.fuzz_weight > 0.0) { /* Charlie + Ashikhmin grazing lobe */ out_rad += … }
if (s.subsurface_weight > 0.0) { /* backlit forward-scatter glow */ out_rad += … }
// Coat on top: colorless dielectric GGX; everything below is dimmed by
// (1 − coat_weight·F_coat) and tinted by the coat_color throughput.
if (s.coat_weight > 0.0) {
let Fcc = fresnel_schlick(VdotH, vec3(s.coat_F0)).x * s.coat_weight;
let throughput = mix(vec3(1.0), s.coat_color, s.coat_weight * s.coat_darkening);
out_rad = out_rad * (1.0 - Fcc) * throughput + vec3(Dcc * Vcc * Fcc) * radiance * cNdotL;
}
The thin-film slab is applied earlier, at the fragment level, because it modifies the base/metal F0 rather than adding a lobe — exactly the iridescence trick from §6.6, reusing eval_iridescence:
// ── from src/shaders/openpbr_surface.wgsl (fs_main) ──
let dielectric_f0 = vec3(ior_to_f0(material.specular_ior)) * material.specular_weight * material.specular_color;
var F0 = mix(dielectric_f0, base_color, metallic);
if (material.thin_film_weight > 0.0) {
let irid = eval_iridescence(1.0, material.thin_film_ior, NdotV, material.thin_film_thickness, F0);
F0 = mix(F0, irid, material.thin_film_weight);
}
The parameter set#
OpenPbrMaterial exposes OpenPBR's parameter names — base_*, specular_*, coat_*, fuzz_*, subsurface_*, thin_film_*, emission_* — packed into a 176-byte uniform (OpenPbrUniforms, 44 floats). It owns bind group 2 exactly as PbrMaterial does: a uniform, an optional base-color map (the one variant bit, HAS_ALBEDO_MAP), and always-bound normal / MER / emissive / coat-normal / anisotropy maps with 1×1 identity defaults — the same hybrid of §6.5. A few OpenPBR inputs map onto features Taos already had under different names: specular_anisotropy drives the anisotropic GGX of §6.6; a dedicated coat_normal map lets the coat lobe use its own (smooth) normal over a bumpy base, the separate-clear-coat-normal trick from earlier in this chapter.
Two paths: full forward, lossy deferred#
The forward path shades the complete slab stack. The deferred path can't — deferred_lighting.wgsl is a fixed fullscreen shader, so a material only influences what it writes into the G-Buffer and the MaterialParams SSBO. So OpenPbrMaterial ships a second shader, openpbr_geometry.wgsl, that maps the OpenPBR params onto the stock G-Buffer encoding and registers the extras into the SSBO, shading through the unchanged deferred path with no engine changes and no new G-Buffer attachment:
The mapping (coat→clearcoat, fuzz→sheen, thin_film→iridescence, subsurface→subsurface, and the specular_ior/weight collapsed into the F0 scalar) is lossy by design: the deferred model has no coat color or coat IOR (its coat is a colorless F0-0.04 lobe), no base/specular weight beyond the F0 mapping, and folds fuzz in as additive sheen rather than a true layer. Those forward-only extras are dropped or approximated. The same getShaderCode(passType) switch also returns Forward+ (clustered), SkinnedGeometry, SkinnedForward, and SkinnedForwardPlus variants, so an OpenPBR material rides every lit path PbrMaterial does.
Converting from PbrMaterial#
Because the glTF loader already produces a fully-decoded PbrMaterial, the cheapest route to OpenPBR-shaded glTF is to load as usual and convert. pbrMaterialToOpenPbr (src/renderer/materials/openpbr_from_pbr.ts) is the executable form of the OpenPBR ↔ glTF/PBR mapping table:
// ── from src/renderer/materials/openpbr_from_pbr.ts ──
// PbrMaterial's F0 is 0.08·specular; OpenPBR's is iorToF0(specular_ior)·specular_weight.
// Keep the real IOR and fold the rest into the weight so the base F0 matches exactly.
const specularWeight = (0.08 * pbr.specular) / Math.max(iorToF0(ior), 1e-4);
return new OpenPbrMaterial({
baseColor: [pbr.albedo[0], pbr.albedo[1], pbr.albedo[2]],
baseMetalness: pbr.metallic, specularRoughness: pbr.roughness,
specularIor: ior, specularWeight,
coatWeight: pbr.clearcoat, coatRoughness: pbr.clearcoatRoughness, coatIor: 1.5,
fuzzWeight: 1, fuzzColor: [...pbr.sheenColor], // sheenColor already carries the magnitude
thinFilmWeight: pbr.iridescence, thinFilmIor: pbr.iridescenceIor,
// … transmission/volume/dispersion, normal/MER/emissive/anisotropy maps shared by reference …
});
Textures are shared by reference (no GPU copy). The converter is faithful for everything OpenPbrMaterial models; a handful of PbrMaterial-only fields — the per-pixel ext map, the UV transform, alphaCutoff, clearcoatNormalToGeometric, clothWrap — fall back to constant defaults and are documented inline.
6.9 MaterialX#
OpenPBR is a shading model. MaterialX is the layer above it: an interchange format (.mtlx documents — node graphs over a standard node library) plus a shader code generator, the way DCC tools (Houdini, Maya, USD) ship materials between applications. Taos reads it, but with a deliberate scope: MaterialX's upstream code generator has GLSL / MSL / OSL backends but no WGSL backend, so a from-scratch GenWgsl is out of scope. Instead Taos leans on a happy coincidence — OpenPbrMaterial is the OpenPBR parameter set, and most real .mtlx files are a single open_pbr_surface (or legacy standard_surface) node — so the common case needs no node-graph compiler at all.
The loader (src/assets/materialx_loader.ts) parses the document, then routes each material down one of three lanes depending on how its inputs are driven:
Lane 1: constant and foldable inputs#
The mainline. mtlxSurfaceToOptions (a pure, unit-tested function) maps each open_pbr_surface / standard_surface input onto an OpenPbrMaterialOptions key through three small tables — float inputs, color inputs, and texture inputs:
// ── from src/assets/materialx_loader.ts ──
const OPENPBR_FLOAT: Record<string, keyof OpenPbrMaterialOptions> = {
base_weight: 'baseWeight', base_metalness: 'baseMetalness',
specular_roughness: 'specularRoughness', specular_ior: 'specularIor',
coat_weight: 'coatWeight', fuzz_weight: 'fuzzWeight',
thin_film_thickness: 'thinFilmThickness', /* … */
};
const OPENPBR_TEX: Record<string, { map: keyof OpenPbrMaterialOptions; srgb: boolean }> = {
base_color: { map: 'baseColorMap', srgb: true },
geometry_normal: { map: 'normalMap', srgb: false }, /* … */
};
Beyond literal values, the loader follows a few ubiquitous upstream chains so they "fold" into a plain OpenPbrMaterial: a bare image/tiledimage, a constant node (→ its literal), normalmap→image, and the multiply(image, color) tint-times-texture pattern (→ a map plus a tint, which the shader already multiplies). Connections through a single-output nodegraph are chased to their source. The result is a stock OpenPbrMaterial — no codegen, identical to the §6.8 path.
Lanes 2 and 3: procedural graphs via codegen#
When an input is driven by a graph that isn't foldable — a noise/math network — Taos compiles it. The split is by what the graph reads:
- UV-space graphs (functions of texture coordinates only) are compiled to a fullscreen WGSL shader by
emitGraphToWgsl(materialx_codegen.ts) and baked once to a mipped texture bybakeWgslToTexture, which then feeds the relevant map slot of a plainOpenPbrMaterial. No core-shader change; the procedural becomes "just a texture." - World-space graphs (reading
position/normal) can't be UV-baked, so they're compiled to a per-fragment block byemitGraphInputsand spliced into a copy of the OpenPBR forward shader at marker lines, producing aMaterialXGraphMaterial:
// ── from src/renderer/materials/materialx_graph_material.ts ──
export class MaterialXGraphMaterial extends OpenPbrMaterial {
constructor(options: OpenPbrMaterialOptions, graph: GraphInputsResult) {
super(options);
this._wgsl = materialxSurfaceWgsl
.replace('//__MTLX_FUNCTIONS__', graph.functions)
.replace('//__MTLX_INPUTS__', `${graph.statements}\n${overrides}`);
// Unique per generated shader so ForwardPass caches a distinct pipeline.
this.shaderId = `mtlx:${hashString(this._wgsl)}`;
}
}
MaterialXGraphMaterial extends OpenPbrMaterial, so it inherits the entire uniform/bind-group/shading machinery and only overrides getShaderCode (forward path) and the shaderId cache key. The node-graph emitter covers constants, texcoord/world sources, binary/unary math, mix/clamp, noise2d/fractal2d, swizzle and combine — and emits a type-zero for unknown nodes so generated WGSL always compiles. What remains is node-coverage breadth (more of the standard library, per-fragment roughness/metalness, images inside a graph), not new mechanism; unsupported inputs fall back to defaults with a one-line warning.
6.10 Summary#
The material system decouples surface appearance from the renderer:
- The G-Buffer spans six color attachments plus depth, with clear values chosen so passes that skip the advanced attachments degrade to standard lighting (§6.1).
- Materials implement an abstract interface providing WGSL, bind group layouts, and per-frame uniform updates, with pipelines cached by
shaderId(§6.2). - PBR materials map the glTF
KHR_materials_*set onto a 112-byte uniform, per-pixel ext/anisotropy G-Buffer channels, and a sharedMaterialParamsSSBO indexed by an 8-bit material id (§6.2, §6.6). - Shader variants vs. always-bound defaults trade pipeline-cache size against per-fragment samples; Taos keeps albedo a variant and the rest defaulted (§6.5).
- The advanced features — normal mapping, MER, dielectric specular/IOR, clear-coat, sheen, iridescence, anisotropy, diffuse transmission, subsurface, unlit, and emissive — each follow a parse → store → decode → evaluate pipeline, with storage chosen by per-pixel vs. per-material and whether the fullscreen passes need it (§6.6).
- Refractive transmission renders in a dedicated forward pass that refracts the lit scene, with Beer-Lambert absorption and dispersion (§6.7).
- OpenPBR Surface is a second
Materialsubclass implementing the standard layered über-BSDF as a re-parameterization over the same lobes — full fidelity in forward, a lossy mapping onto the stock G-Buffer in deferred (§6.8). - MaterialX
.mtlxdocuments load ontoOpenPbrMaterial— field-for-field for the common single-node case, via WGSL codegen + GPU bake for procedural graphs (§6.9).
Further reading:
src/engine/material.ts— Abstract Material base class +MaterialPassTypesrc/renderer/materials/pbr_material.ts—PbrMaterial(uniform, bindings, defaults)src/renderer/materials/openpbr_material.ts—OpenPbrMaterial(OpenPBR slab parameter set)src/renderer/materials/openpbr_from_pbr.ts—PbrMaterial→OpenPbrMaterialconvertersrc/renderer/materials/materialx_graph_material.ts— per-fragment MaterialX graph materialsrc/renderer/materials/material_params_table.ts— Extended-parameter SSBOsrc/shaders/openpbr_surface.wgsl— Forward OpenPBR slab stack (openpbr_direct/openpbr_ibl)src/shaders/openpbr_geometry.wgsl— OpenPBR → stock G-Buffer mapping (deferred)src/assets/materialx_loader.ts—.mtlx→OpenPbrMaterial(input mapping + fold chains)src/assets/materialx_codegen.ts— MaterialX node-graph → WGSL emittersrc/assets/gltf_loader.ts— glTF material/extension resolution (§4.7)src/shaders/geometry.wgsl— G-Buffer fill shader (encode side: TBN, MER, ext/anisotropy packing)src/shaders/deferred_lighting.wgsl— Fullscreen deferred lighting (decode + inline extension lobes)src/shaders/forward_pbr.wgsl— Forward PBR shader (transparency)src/shaders/modules/pbr_brdf.wgsl— Shared Cook-Torrance + anisotropic GGX termssrc/shaders/modules/pbr_extensions.wgsl— Shared direct extension lobes (shade_direct_ext)src/shaders/modules/iridescence.wgsl— Thin-film F0 (Belcour-Barla)src/shaders/transmission.wgsl— Refractive transmission shadersrc/shaders/common.wgsl— Shared struct definitions