Skip to content
Ben is currently available for contract work for 3D & web solutions — reach out.

Back to Blog Listing

Fine-Grained Shader Validation in Three.js

How Three.js now validates individual TSL shader expressions on WebGPU and WebGL2, with numeric diagnostics, deterministic input sampling, and support for vectors and matrices.

Ben Houston7 min read

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.

Illustration

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';
import { gpuTest } from './gpu-test-utils.js';

gpuTest( 'sRGB <-> linear round trip', ( { assert } ) => {

	const srgb = vec3( 0.5, 0.2, 0.8 );
	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 account for precision differences between WGSL and GLSL implementations. The fixed sRGB example above uses 1e-4. The fuzz test below uses 1e-3 across the full color range, since it compounds the error from two chained transfer functions. A tolerance should catch a meaningful regression while admitting expected floating-point variation on both backends.

Deterministic input sampling#

gpuFuzzTest runs many generated inputs in one compute dispatch. The following test derives repeatable pseudo-random colors from instanceIndex:

import { hash, vec3, sRGBTransferEOTF, sRGBTransferOETF } from 'three/tsl';
import { gpuFuzzTest } from './gpu-test-utils.js';

gpuFuzzTest( 'sRGB <-> linear round trip (fuzz, 256 random colors)', 256, ( { instanceIndex, assert } ) => {

	const srgb = vec3(
		hash( instanceIndex.add( 1 ) ),
		hash( instanceIndex.add( 1000 ) ),
		hash( instanceIndex.add( 2000 ) )
	);
	const roundTrip = sRGBTransferOETF( sRGBTransferEOTF( srgb ) );

	assert.closeAbs( roundTrip, srgb, 1e-3 );

} );

The GPU generates and evaluates 256 cases; the CPU reads the buffers back, compares each result, and reports failures. Because the inputs come from a hash of the instance index, a failed case can be reproduced in the next run.

Relational helpers apply component-wise to vectors:

gpuTest( 'relational assertions', ( { assert } ) => {

	assert.greaterThan( float( 5.0 ), float( 3.0 ) );
	assert.greaterThanOrEqual( float( 3.0 ), float( 3.0 ) );
	assert.greaterThan( vec3( 5.0, 6.0, 7.0 ), vec3( 3.0, 3.0, 3.0 ) );

} );

Running the tests#

Place GPU tests under test/unit/addons/tsl/ in a Three.js checkout, then run the headless add-on suite:

npm run test-unit-addons

Use the headful runner to inspect browser and GPU failures:

npm run test-unit-addons-headful

Puppeteer provides the existing browser test environment, so the GPU tests needed no separate CI job — the per-backend detection described above already handles machines that expose WebGPU, WebGL2, or both.

Six bugs from the first test pass#

Tests added while developing vector and matrix support found six defects, spanning expression authoring, type resolution, backend code generation, and node caching — none of them exposed by rendered demos.

Shader expression bugs#

Matrix conventions and type metadata#

Backend generation and graph caching#

The merged harness supports scalars, vec2 through vec4, mat3, and mat4. If you find an unsupported TSL case or have feedback on the API, add it to PR #34331 or leave a comment below.