This document describes the shader material architecture and rendering patterns used in the WebGL viewer. All materials use GLSL ES 3.0 (WebGL 2).
The Materials singleton (materials.ts) owns and manages all material instances. It exposes a public IMaterials interface for property configuration and keeps system materials (mask, outline, merge) internal.
| Material | File | Purpose | GLSL Version |
|---|---|---|---|
| StandardMaterial | standardMaterial.ts |
Default opaque/transparent rendering. Patches Three.js Lambert shader via onBeforeCompile to inject palette coloring, visibility, and section strokes. |
GLSL1 (Three.js managed) |
| ModelMaterial | modelMaterial.ts |
Fast rendering mode. Custom shader using screen-space derivative normals and pre-normalized lighting. | GLSL3 |
| GhostMaterial | ghostMaterial.ts |
Transparent fill for hidden/ghosted elements in isolation mode. | GLSL3 |
| PickingMaterial | pickingMaterial.ts |
GPU object picking. Outputs packed element ID, depth, and surface normal to Float32 render target. | GLSL3 |
| MaskMaterial | maskMaterial.ts |
Selection mask pass. Writes depth only for selected elements; non-selected vertices are clipped. | GLSL3 |
| OutlineMaterial | outlineMaterial.ts |
Post-process edge detection on depth buffer. Outputs outline intensity to RedFormat texture. | GLSL3 |
| MergeMaterial | mergeMaterial.ts |
Final compositing pass. Blends scene texture with outline texture using configurable color. | GLSL3 |
| TransferMaterial | transferMaterial.ts |
Simple texture passthrough (blit). | GLSL3 |
MaterialSet (materialSet.ts) groups materials by role: opaque, transparent, and hidden (ghost). The applyMaterial() helper in materials.ts resolves a MaterialSet into the correct THREE.Material or [visible, hidden] array for a given mesh based on its userData.transparent flag.
All scene materials (StandardMaterial, ModelMaterial) use a shared color palette texture for submesh coloring. The Materials singleton owns a single 128x128 RGBA DataTexture (16,384 colors max) and distributes it to all materials via setSubmeshColorTexture().
The palette is built by colorPalette.ts:
- Extracts unique colors from submesh material data
- If unique colors exceed 16,384, applies uniform quantization (25 levels per channel = 15,625 max)
- Packs into a
Float32Arrayfor texture upload
Shaders look up colors using texelFetch with integer coordinates derived from a per-vertex submeshIndex attribute:
int x = int(submeshIndex) % 128;
int y = int(submeshIndex) / 128;
vColor = texelFetch(submeshColorTexture, ivec2(x, y), 0).rgb;Instance color overrides are blended using the colored attribute (1 = instance color, 0 = palette color):
#ifdef USE_INSTANCING
vColor = colored * instanceColor + (1.0 - colored) * vColor;
#endifThe light direction is a compile-time constant, avoiding per-fragment normalize():
// (sqrt(2), sqrt(3), sqrt(5)) normalized: magnitude = sqrt(10)
const vec3 LIGHT_DIR = vec3(0.447214, 0.547723, 0.707107);
float light = dot(normal, LIGHT_DIR);
light = 0.5 + (light * 0.5); // Remap to [0.5, 1.0]The ghost opacity uniform stores the final shader value directly (7/255 = 0.0275), so the fragment shader uses it as-is without per-fragment division:
fragColor = vec4(fillColor, opacity);The GhostMaterial class getter/setter expose the raw value without conversion.
All visibility-aware materials (Ghost, Model, Mask, Picking) use the same pattern to cull invisible geometry in the vertex shader by placing vertices behind the near plane:
if (ignore > 0.0) {
gl_Position = vec4(0.0, 0.0, -2.0, 1.0);
return;
}This is faster than fragment discard because no fragments are generated for clipped triangles.
The PickingMaterial class uses a static THREE.Vector3 for camera direction updates, avoiding per-frame allocations:
private static _tempDir = new THREE.Vector3()
updateCamera(camera: THREE.Camera): void {
camera.getWorldDirection(PickingMaterial._tempDir)
this.three.uniforms.uCameraPos.value.copy(camera.position)
this.three.uniforms.uCameraDir.value.copy(PickingMaterial._tempDir)
}The picking material encodes four values into a Float32 RGBA render target:
| Channel | Value | Encoding |
|---|---|---|
| R | Element ID | uintBitsToFloat(packedId) where packedId = (vimIndex << 24) | elementIndex |
| G | Depth | Distance along camera direction (0 = miss) |
| B | Normal X | Screen-space derivative normal |
| A | Normal Y | Screen-space derivative normal |
Normal Z is reconstructed as sqrt(1 - x^2 - y^2), always positive since the normal faces the camera.
The standard material renders colored strokes where geometry intersects clipping planes. The stroke width scales with fragment depth using a configurable falloff exponent:
float thick = pow(vFragDepth, sectionStrokeFalloff) * sectionStrokeWidth;Perpendicular surfaces are excluded by comparing the surface normal against the clipping plane normal.
-
Move computations out of shaders whenever possible:
- Constants: Pre-compute in JavaScript or as shader
const - Per-frame: Compute in uniforms
- Per-vertex: Keep in vertex shader
- Per-fragment: Only when necessary
- Constants: Pre-compute in JavaScript or as shader
-
Avoid per-fragment operations:
normalize()on constants- Divisions (use pre-multiplied reciprocals)
- Expensive math functions (
sqrt,pow,sin,cos) - Prefer simple arithmetic (
+,-,*) - Prefer dot products, cross products
- Texture lookups are GPU-cached and relatively cheap
-
Memory access patterns:
texelFetch()for indexed access (faster thantexture()when no filtering needed)uniformreads are GPU-cachedin(varying) interpolation cost depends on geometry complexity
-
Branching:
- Early returns in vertex shader skip all subsequent work
- Fragment shader branches may execute both paths on GPU (warp divergence)
| Operation | Cost | Example |
|---|---|---|
| Uniform read | 1x | uniform float value |
| Texture fetch | 2-4x | texture(sampler, uv) |
| Addition/Subtraction | 1x | a + b |
| Multiplication | 1x | a * b |
| Division | 3-5x | a / b |
normalize() |
10-15x | sqrt + 3 divides |
sin(), cos() |
8-12x | Approximated in hardware |
All custom shader materials use glslVersion: THREE.GLSL3. The StandardMaterial uses Three.js managed GLSL1 (via onBeforeCompile patching).
| GLSL1 | GLSL3 |
|---|---|
attribute |
in (vertex shader) |
varying |
out (vertex), in (fragment) |
gl_FragColor |
out vec4 fragColor |
texture2D() |
texture() |
| N/A | texelFetch() for indexed access |
src/vim-web/core-viewers/webgl/loader/materials/standardMaterial.tssrc/vim-web/core-viewers/webgl/loader/materials/modelMaterial.tssrc/vim-web/core-viewers/webgl/loader/materials/ghostMaterial.tssrc/vim-web/core-viewers/webgl/loader/materials/pickingMaterial.tssrc/vim-web/core-viewers/webgl/loader/materials/maskMaterial.tssrc/vim-web/core-viewers/webgl/loader/materials/outlineMaterial.tssrc/vim-web/core-viewers/webgl/loader/materials/mergeMaterial.tssrc/vim-web/core-viewers/webgl/loader/materials/transferMaterial.tssrc/vim-web/core-viewers/webgl/loader/materials/materials.tssrc/vim-web/core-viewers/webgl/loader/materials/materialSet.tssrc/vim-web/core-viewers/webgl/loader/materials/colorPalette.ts
- CLAUDE.md - Main project documentation
- INPUT.md - Input system architecture
- optimization.md - Loading pipeline performance