wesl-debug
Version:
Utilities for testing WESL/WGSL shaders in Node.js environments.
349 lines (273 loc) • 9.43 kB
Markdown
# 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
```bash
npm install wesl-debug
```
## Testing Compute Shaders
Use `testComputeShader()` to test compute shader behavior. A storage buffer is provided for writing test results.
### Basic Example
```typescript
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 `size` parameter to specify buffer size in bytes
- Access via `test::results[index]`
```typescript
// 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
```typescript
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:
```typescript
// 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 = 1
```
**Note**: 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:
```typescript
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 color
- `createGradientTexture(device, width, height, direction?)` - gradient ('horizontal' or 'vertical')
- `createCheckerboardTexture(device, width, height, cellSize?)` - checkerboard pattern
- `createSampler(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):
```typescript
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:
```typescript
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:
```typescript
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:**
```bash
# 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 -- -u
```
**Directory 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:
```typescript
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:
```typescript
// 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
```typescript
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);
});
```