wesl-debug
v0.6.15
Published
Utilities for testing WESL/WGSL shaders in Node.js environments.
Readme
wesl-debug
Utilities for testing WESL/WGSL shaders in Node.js environments.
Overview
wesl-debug provides simple test harnesses
for quickly running fragment and compute shaders GPU
tests.
- Write shader code in either WESL or WGSL.
- Shader library imports in WESL are
resolved automatically from
node_modules. - Shaders are run using Dawn, the WebGPU engine used inside the Chrome browser.
Installation
npm install wesl-debugTesting Compute Shaders
Use testComputeShader() to test compute shader behavior. A storage buffer is provided for writing test results.
Basic Example
import { testComputeShader } from "wesl-debug";
const gpu = /* initialize GPU */;
const src = `
import test; // provides test::results storage buffer
@compute @workgroup_size(1)
fn main() {
test::results[0] = 42u;
test::results[1] = 100u;
}
`;
const result = await testComputeShader(import.meta.url, gpu, src, "u32");
// result = [42, 100, -999, -999] // (unwritten values are filled with -999)Storage Buffer
The test virtual module provides a storage buffer for results in WESL code:
- Default buffer size: 16 bytes (4 × 4-byte elements for u32 or f32)
- Custom size: Use the
sizeparameter to specify buffer size in bytes - Access via
test::results[index]
// Example with custom buffer size
const result = await testComputeShader({
projectDir: import.meta.url,
device,
src: `
import test;
@compute @workgroup_size(1)
fn main() {
for (var i = 0u; i < 8u; i++) {
test::results[i] = i * 10u;
}
}
`,
resultFormat: "u32",
size: 32 // 32 bytes = 8 × 4-byte u32 elements
});
// result = [0, 10, 20, 30, 40, 50, 60, 70]Testing Fragment Shaders
Use testFragmentShader() to test fragment shader behavior.
The function renders using a fullscreen triangle and returns pixel values for validation.
Basic Example
import { testFragmentShader, getGPUDevice } from "wesl-debug";
const projectDir = import.meta.url;
const device = await getGPUDevice();
const src = `
@fragment
fn fs_main() -> @location(0) vec4f {
return vec4f(0.5, 0.25, 0.75, 1.0);
}
`;
const textureFormat = "rgba32float";
const r = await testFragmentShader({ projectDir, device, src, textureFormat });
// r = [0.5, 0.25, 0.75, 1.0]Testing using Derivatives
Derivative functions like dpdx, dpdy, and fwidth
require at least a 2×2 pixel quad.
Use the size parameter to create a 2×2 texture:
// derivative of x coordinate
const src = `
@fragment
fn fs_main(@builtin(position) pos: vec4f) -> @location(0) vec4f {
let dx = dpdx(pos.x);
return vec4f(pos.x, dx, 0.0, 1.0);
}
`;
const result = await testFragmentShader({
projectDir: import.meta.url,
device,
src,
textureFormat: "rg32float",
size: [2, 2],
});
const [x, dx] = result; // result at pixel (0, 0)
// x = .5 dx = 1Note: The test function always samples pixel (0,0) from the rendered texture.
Testing with Input Textures
Use inputTextures to test fragment shaders that sample from textures.
Helper functions provide common test patterns:
import {
testFragmentShader,
getGPUDevice,
createSolidTexture,
createGradientTexture,
createCheckerboardTexture,
createSampler
} from "wesl-debug";
const device = await getGPUDevice();
const inputTex = createSolidTexture(device, [0.5, 0.5, 0.5, 1.0], 256, 256);
const sampler = createSampler(device);
const src = `
@group(0) @binding(0) var input_tex: texture_2d<f32>;
@group(0) @binding(1) var input_samp: sampler;
@fragment
fn fs_main(@builtin(position) pos: vec4f) -> @location(0) vec4f {
let uv = pos.xy / 256.0;
return textureSample(input_tex, input_samp, uv);
}
`;
const result = await testFragmentShader({
projectDir: import.meta.url,
device,
src,
inputTextures: [{ texture: inputTex, sampler }]
});
// result = [0.5, 0.5, 0.5, 1.0]Texture Helper Functions:
createSolidTexture(device, color, width, height)- uniform colorcreateGradientTexture(device, width, height, direction?)- gradient ('horizontal' or 'vertical')createCheckerboardTexture(device, width, height, cellSize?)- checkerboard patterncreateSampler(device, options?)- texture sampler (linear filtering, clamp-to-edge by default)
Binding Convention: Textures bind sequentially starting at binding 0:
inputTextures[0]→ texture at@binding(0), sampler at@binding(1)inputTextures[1]→ texture at@binding(2), sampler at@binding(3)
Image Testing & Visual Regression
Test complete rendered images and automate visual regression testing using snapshot comparison.
Full Image Retrieval
Use testFragmentShaderImage() to get the complete rendered image instead of just pixel (0,0):
import { testFragmentShaderImage, saveImageDataToPNG } from "wesl-debug";
const result = await testFragmentShaderImage({
projectDir: import.meta.url,
device,
src: blurShaderSource,
size: [256, 256],
inputTextures: [{ texture: inputTex, sampler }]
});
// Save for visual inspection
await saveImageDataToPNG(result, "__image_dev__/blur-result.png");Advanced Test Textures
Additional texture generators for image processing tests:
import {
createRadialGradientTexture, // White center → black edge
createEdgePatternTexture, // Sharp lines for edge detection
createColorBarsTexture, // RGB primaries/secondaries
createNoiseTexture, // Deterministic seeded noise
createPhotoSampleTexture // Load from PNG file
} from "wesl-debug";
const radial = createRadialGradientTexture(device, 256);
const edges = createEdgePatternTexture(device, 256);
const colors = createColorBarsTexture(device, 256);
const noise = createNoiseTexture(device, 256, 42); // seed = 42
// Use the bundled test photo (512x512 lemur image)
import { getLemurImagePath } from "wesl-debug";
const photo = createPhotoSampleTexture(device, getLemurImagePath());Visual Regression Testing
Use snapshot comparison to catch unintended visual changes:
import { imageMatcher } from "wesl-debug";
// In test setup file or at top of test
imageMatcher();
test("blur filter produces expected result", async () => {
const result = await testFragmentShaderImage({
projectDir: import.meta.url,
device,
src: blurShaderSource,
size: [256, 256],
inputTextures: [{ texture: inputTex, sampler }]
});
// Compare against reference snapshot
await expect(result).toMatchImage("blur-filter");
});Snapshot Workflow:
# Run tests - creates reference snapshots on first run
pnpm vitest
# Review generated snapshots in __image_snapshots__/
# Commit if they look correct
git add __image_snapshots__/
git commit -m "Add visual regression tests"
# After code changes, tests fail if output changed
pnpm vitest # Shows diffs in __image_diffs__/
# If changes are intentional, update snapshots
pnpm vitest -- -uDirectory Structure:
__image_snapshots__/- Reference images (committed to git)__image_actual__/- Current test outputs (gitignored, saved on every run)__image_diffs__/- Diff visualizations (gitignored, only on failure)__image_diff_report__/- HTML report (gitignored, self-contained)__image_dev__/- Dev experiments (gitignored)
Comparison Options
Fine-tune snapshot comparison thresholds:
await expect(result).toMatchImage("edge-detection", {
threshold: 0.1, // Color difference threshold (0-1)
allowedPixelRatio: 0.01, // Allow 1% of pixels to differ
allowedPixels: 100 // Or allow 100 pixels to differ
});HTML Diff Report
When snapshot tests fail, an HTML report is automatically generated showing all failures side-by-side:
// vitest.config.ts
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
setupFiles: ["./test/setup.ts"],
reporters: [
"default",
["vitest-image-snapshot/reporter"]
]
}
});If any tests fail, a report is saved to __image_diff_report__/index.html and shows:
- Side-by-side comparison (Expected | Actual | Diff)
- Mismatch statistics per test
- Clickable images for full-size viewing
Complete Test Example
import { afterAll, beforeAll, expect, test } from "vitest";
import { testFragmentShader, testComputeShader } from "wesl-debug";
import { destroySharedDevice, getGPUDevice } from "wesl-debug";
const projectDir = import.meta.url;
let device: GPUDevice;
beforeAll(async () => {
device = await getGPUDevice();
});
afterAll(() => {
destroySharedDevice();
});
test("fragment shader renders color", async () => {
const src = `
@fragment
fn fs_main() -> @location(0) vec4f {
return vec4f(1.0, 0.0, 0.0, 1.0);
}
`;
const textureFormat = "rgba32float";
const r = await testFragmentShader({ projectDir, device, src, textureFormat });
expect(r).toEqual([1.0, 0.0, 0.0, 1.0]);
});
test("compute shader writes results", async () => {
const src = `
import test;
@compute @workgroup_size(1)
fn main() {
test::results[0] = 123u;
}
`;
const result = await testComputeShader(projectDir, device, src, "u32");
expect(result[0]).toBe(123);
});