Fine-Grained Shader Validation in Three.jsSkip to contentBen is currently available for contract work for 3D & web solutions — reach out.
Three.js builds its transfer functions, tone mapping curves, BRDFs, and spherical harmonics on a smaller layer of primitives with TSL: easing and remapping functions like gain() and pcurve(), trigonometric helpers like sinc(), and matrix building blocks like rotate() and determinant(). TSL turns those JavaScript-like node expressions into WGSL or GLSL for execution on the GPU.
Three.js had unit tests for the node graphs but no way to test the shader results those graphs actually produce. I added one in PR #34331. It evaluates TSL expressions through WebGPU and the WebGL2 fallback, reads the results back from storage buffers, and reports numeric differences through familiar assertion helpers. Tests written with the new harness exposed six bugs in the foundational layer of the TSL system within the first day.
The missing layer in TSL tests#
The existing TSL/node tests construct a node graph against a fake renderer. They verify graph construction, uniform registration, and cache keys — the JavaScript-side machinery — while the generated shader arithmetic goes unexecuted.
Visual tests catch large rendering failures, but they struggle with a transfer function that drifts near zero or a matrix operation that fails on a degenerate input. Reviewers face the same problem: an incorrect formula can look entirely plausible in a diff. Numeric assertions give maintainers known outputs to check against for exactly these boundary cases.
Compound functions like BRDFs and tone mapping curves need this kind of testing as much as the primitives do — they have their own edge cases, and a subtle bug there is just as easy to miss in a visual diff. But the primitives come first: a single-purpose function like gain(), sinc(), or rotate() is reused inside dozens of higher-level formulas, so a wrong result there propagates into every one of them. Solid foundations make it possible to build up to complex functions with confidence; testing only the compound formulas leaves the ground they stand on unverified.
A small assertion API#
GPU tests require dispatch setup, result buffers, type handling, readback, and useful failure messages. Rebuilding that machinery for every test would bury the assertion in boilerplate, so the harness puts it behind two functions, gpuTest and gpuFuzzTest, with these assertion helpers:
assert.eq checks exact values.
assert.closeAbs and assert.closeRel check numeric tolerances.
Relational helpers cover greater-than and less-than comparisons.
Each helper accepts scalars, vectors, and mat3/mat4 values.
The harness asks the TSL builder for each expression's resolved type, so a test author never declares storage layouts or compares vector components one at a time. Failures report the actual value, expected value, tolerance, and component name.
Both entry points run on WebGPU and on WebGPURenderer's WebGL2 fallback: the test runner creates one QUnit test per backend, skipping a backend with a warning when the host lacks the required GPU or driver support.
How the harness executes an assertion#
Each assert.*() call reserves rows in two vec4 storage buffers, one for actual values and one for expected. A compute invocation writes each row; after the dispatch, the harness reads both buffers back and performs comparison and diagnostic formatting on the CPU.
Scalars and vectors occupy one row. A mat3 uses three vec3 columns, and a mat4 uses four vec4 columns; the harness labels matrix failures by column and component, such as col0.x.
I first used this render-and-readback design in threeify, my WebGL2 renderer, where a fullscreen quad returned pass/fail bytes through gl.readPixels(). TSL's compute shaders and storage buffers let the Three.js harness go further, preserving the actual values needed for better diagnostics.
Writing a GPU test#
Tests import TSL expressions from three/tsl and the harness from gpu-test-utils.js:
import { vec3, sRGBTransferEOTF, sRGBTransferOETF } from 'three/tsl';<br>import { gpuTest } from './gpu-test-utils.js';
gpuTest( 'sRGB linear round trip', ( { assert } ) => {
const srgb = vec3( 0.5, 0.2, 0.8 );<br>const roundTrip = sRGBTransferOETF( sRGBTransferEOTF( srgb ) );
assert.closeAbs( roundTrip, srgb, 1e-4 );
} );
sRGBTransferEOTF decodes sRGB to linear values; sRGBTransferOETF encodes the result back to sRGB. The assertion allows an absolute error of 1e-4.
A failure identifies the component and measured difference:
sRGB linear round trip: expected 0.500000, got 0.499994 (Δ0.000006, tolerance 0.0001)
The merged GPUTest.tests.js file contains complete examples.
Choosing a tolerance#
Use assert.eq for values whose contract requires an exact result, closeAbs when the same error bound applies across the tested range, and closeRel for values whose permitted error scales with their magnitude.
Start from the function's numeric contract and...