Render and Test a WebGPU Shader
Write a WebGPU shader, then render and test it headlessly with Node and vgpu.
Introduction
30 Second Summary
Shaders are the tiny programs that paint the moving colours behind a landing page or inside a game. They only exist while someone is looking at them, which makes it hard to prove one still works after you change it.
In this project, you will write a small WebGPU shader that animates in a browser tab, then render that exact same shader with no browser at all and save it as an image. You will finish with a test that goes red the moment the shader changes.
What You'll Build
By the end, you can pull up your own gradient painting itself in a browser tab, run one command to produce the identical picture from your terminal, and break the shader on purpose to watch a test catch you.
By the end of this project, you'll have:
- A moving gradient animating on a page, written as your own .wgsl module that drops straight into any other page.
- A headless render you kick off from your terminal on a laptop with no GPU, writing a .png you can open next to the browser canvas.
- A rendering test that stays green on an unchanged shader, then fails with the new image beside the saved one as soon as you edit a single number.
- Secret Mission: Run your headless render on every push with a GitHub Actions workflow, so a shader that stops matching its reference image fails the build.
Are there any prerequisites?
You need a current Node.js install and a browser with WebGPU enabled, plus enough comfort to read a few lines of shader code.
Everything runs on your laptop with no account, no card, and no GPU of your own.
Before We Start
Before we dive in, let's lock in what you're about to build and why it matters. Getting clear on the goal now makes every step ahead easier to follow.
Set Up Your Project and Run the Example Shader
Shaders are small programs that run on your graphics card and decide the colour of every pixel on screen. They only exist where you can see them, which makes them hard to trust and impossible to check automatically.
Before you can check a shader, you need somewhere to run one. In this step you'll turn your folder into a Node.js project, install vgpu, and get the library's own example gradient painting on a canvas in your browser.
In this step, get ready to:
- Turn your project folder into a Node project and install vgpu.
- Serve a page with a canvas on it from a local dev server.
- Draw the example shader onto that canvas and see it in your browser.
Set up the Node project and install vgpu
npm won't track dependencies for a folder until that folder has a package.json. Creating one turns your empty folder into a project that can install and remember libraries.
- Create your package.json by running this command:
npm init -y
- Confirm the file exists by running:
ls
- You should see package.json in the output.
- Install the vgpu library by running:
npm install vgpu
- Check the command line tool that ships with the library is ready by running:
npx vgpu --version
- You should see a version number printed on its own line.
No version number printed?
Run ls and check you see both package.json and a node_modules folder. If they're missing, the install landed in a different folder.
Scroll back through your terminal and check the install finished without a red error before you ran the version check.
Why is npx vgpu not printing a version?
Serve a page with a canvas on it
A shader needs something to paint on, and in the browser that something is a <canvas> element. Browsers refuse to load module imports from a file opened straight off disk, so the page has to be served over HTTP.
- Install Vite as a development dependency by running:
npm install --save-dev vite
What is Vite doing here?
Vite serves your folder over HTTP so the browser will accept module scripts.
It also resolves a plain import like vgpu to the copy in your node_modules folder, which the browser cannot do on its own.
- In your editor's file explorer, create a new file called index.html in your project folder.
- Paste this into index.html:
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>Shader lab</title>
<style>
body { margin: 0; display: grid; place-items: center; height: 100vh; background: #111; }
canvas { background: #444; }
</style>
</head>
<body>
<canvas width="512" height="512"></canvas>
</body>
</html>
What does this page do?
- The <canvas> element is the surface a shader paints on. Everything you draw lands inside it.
- The width and height attributes pin the render at 512 by 512 pixels, so every render comes out the same size.
- The grey background on the canvas is there so you can see it before anything is drawn.
The dev server holds onto your terminal
Vite keeps running until you stop it, so that terminal won't accept another command. Press Ctrl+C to stop it, or open a second terminal tab when you need to run something else.
- Save index.html.
- Start the dev server by running:
npx vite
- Open the Local address Vite prints, usually http://localhost:5173, in your browser.
You should see a grey square in the middle of a near black page. That's your empty canvas, waiting for a shader.
Page not loading?
Check the terminal running Vite is still open. Closing it or pressing Ctrl+C shuts the server down.
Use the exact address Vite printed. If port 5173 was already busy, Vite picks a different one.
Why can my browser not reach my Vite dev server?
Draw the example shader onto the canvas
vgpu draws by wiring three things together: a WebGPU device, a surface to paint on, and an effect built from shader code. The example gradient turns each pixel's position on the canvas into a colour.
- Create a new file called main.js in your project folder.
- Paste this into main.js:
import { init, effect, surface } from "vgpu";
// The example fragment shader from the vgpu docs: a pixel's position on
// the canvas becomes its colour.
const gradientSource = `
@fragment fn fs_main(@location(0) uv: vec2f) -> @location(0) vec4f {
return vec4f(uv, 0.4, 1.0);
}
`;
// Start WebGPU and get back a device to render with.
const gpu = await init();
// Hand vgpu the canvas that is already on the page.
const canvas = document.querySelector("canvas");
const canvasSurface = surface(gpu, canvas);
// Compile the shader, then paint it across the whole canvas.
const gradient = effect(gpu, gradientSource);
gradient.draw(canvasSurface);
What does this code do?
- init() starts WebGPU and hands back a device to render with.
- surface(gpu, canvas) tells vgpu which canvas on the page to paint on.
- effect(gpu, gradientSource) compiles the WGSL shader so the GPU can run it.
- gradient.draw(canvasSurface) runs the shader once for every pixel and paints the result.
- Save main.js.
- In index.html, find this line:
<canvas width="512" height="512"></canvas>
- Add a script tag below it, so those two lines look like this:
<canvas width="512" height="512"></canvas>
<script type="module" src="./main.js"></script>
- Save index.html.
✔️ Awesome, I've got everything!
Great. Double check both files are saved before you refresh.
ⓧ I'd like to double check the full code
Here are both files in full. Compare them line by line against yours.
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>Shader lab</title>
<style>
body { margin: 0; display: grid; place-items: center; height: 100vh; background: #111; }
canvas { background: #444; }
</style>
</head>
<body>
<canvas width="512" height="512"></canvas>
<script type="module" src="./main.js"></script>
</body>
</html>
import { init, effect, surface } from "vgpu";
// The example fragment shader from the vgpu docs: a pixel's position on
// the canvas becomes its colour.
const gradientSource = `
@fragment fn fs_main(@location(0) uv: vec2f) -> @location(0) vec4f {
return vec4f(uv, 0.4, 1.0);
}
`;
// Start WebGPU and get back a device to render with.
const gpu = await init();
// Hand vgpu the canvas that is already on the page.
const canvas = document.querySelector("canvas");
const canvasSurface = surface(gpu, canvas);
// Compile the shader, then paint it across the whole canvas.
const gradient = effect(gpu, gradientSource);
gradient.draw(canvasSurface);
Before you refresh, take a guess. Will the canvas stay grey, turn one solid colour, or fade between colours?
- Switch back to your browser tab and refresh the page.
✔️ I see a colourful gradient
The grey square is gone. Red rises smoothly along one axis of the canvas and green rises along the other, over a constant blue.
Every one of those 262,144 pixels got its colour from your GPU running that four line shader. Your setup works.
ⓧ The canvas is still grey or black
A shader with nowhere to run fails quietly, so the first thing to check is whether your browser has WebGPU at all.
- Press Cmd+Option+J (macOS) or Ctrl+Shift+J (Windows) to open the browser console.
- Type navigator.gpu and press Enter.
If the console answers undefined, this browser has no WebGPU and nothing will render. Update to a current version of Chrome or Edge and refresh.
Still nothing on the canvas?
Look for a red error in the browser console. A typo inside the shader string surfaces there rather than on the canvas.
Confirm the script tag you added to index.html points at ./main.js and that you saved the file.
Why is my canvas blank even though the page loads?
Your setup is proven. A real shader is running on your GPU and painting a browser canvas.
Next up, you'll swap that borrowed example for your own shader module and watch your colours take over the canvas.
Write Your Own Gradient Shader
Your browser tab is already painting the vgpu example onto a canvas. That gradient is somebody else's artwork though.
A fragment shader is a tiny program that runs once for every pixel and returns a colour. In this step you write one of your own in WGSL.
The catch with shaders is that a broken one rarely says so. A file that fails to compile usually shows up as a black canvas, so you'll check your shader from the terminal before the page ever loads it.
In this step, get ready to:
- Write a shader.wgsl module that colours every pixel from its position.
- Point your page at your own shader instead of the example.
- Feed the shader a time value so the gradient moves.
Write a fragment shader that paints a gradient
Every pixel your shader draws arrives with a uv coordinate, a pair of numbers between 0 and 1 saying where that pixel sits on the canvas.
- Create your shader file by running this command:
touch shader.wgsl
- Confirm the file landed by running:
ls
- You should see shader.wgsl listed alongside package.json in the output.
- In your editor, open shader.wgsl.
- Paste in this fragment stage:
// Runs once per pixel: uv is that pixel's position, 0 to 1 across the canvas
@fragment fn fs_main(@location(0) uv: vec2f) -> @location(0) vec4f {
// Red rises to the right, green rises downward, blue stays flat
return vec4f(uv, 0.4, 1.0);
}
What does this code do?
fs_main is the fragment stage. It takes the uv of the pixel being drawn and returns its colour as vec4f(red, green, blue, alpha).
There's no vertex stage in the file because vgpu injects a fullscreen triangle for you and hands the fragment stage the interpolated uv.
- Save shader.wgsl.
- Check that it compiles by running:
npx vgpu check shader.wgsl
- You should see the reflection for your shader printed with no errors reported.
Seeing a compile error instead?
Check that the return type arrow is -> and that the function body closes with a matching }. One stray character fails the whole file.
Still stuck? Why won't my WGSL file compile?
Point your page at your own shader
Your page still loads the example's shader source. Swapping that for shader.wgsl is the moment your colours take over the canvas.
- In your editor, open index.html.
- Replace everything inside the <script type="module"> block with this, leaving the <canvas> element as it is:
import { effect, init, surface } from "vgpu";
// Create the GPU context and wrap the page's canvas as a render target
const gpu = await init();
const canvas = document.querySelector("canvas");
const canvasTarget = surface(gpu, canvas);
// Read your own shader file as text, then build a fullscreen effect from it
const source = await fetch("./shader.wgsl").then((response) => response.text());
const gradient = effect(gpu, source);
// Paint one frame onto the canvas
gradient.draw(canvasTarget);
What's happening here?
surface(gpu, canvas) wraps the canvas already on your page as a target that vgpu can draw into.
fetch reads shader.wgsl as plain text. effect(gpu, source) takes that WGSL string and turns it into something drawable.
- Save index.html.
- Reload the browser tab you already have open.
- You should see a still gradient: red rising to the right, green rising down the canvas.
Canvas gone black?
A black canvas usually means the fetch failed or the shader never compiled. Open your browser console and look for a 404 on shader.wgsl.
The path is relative to the page, so shader.wgsl has to sit in the same folder as index.html.
Why is my vgpu canvas black after pointing it at my own shader file?
Add a time uniform so the gradient moves
Nothing in your shader changes between frames, so the gradient sits still. vgpu has no built-in clock inside the shader, so your page passes the time in as a uniform.
- In shader.wgsl, replace the whole file with this:
// Values the page sets once per frame and every pixel reads
struct Params { time: f32 }
@group(0) @binding(0) var<uniform> params: Params;
@fragment fn fs_main(@location(0) uv: vec2f) -> @location(0) vec4f {
// Blue now swings between 0 and 1 as time moves on
return vec4f(uv, abs(sin(params.time)), 1.0);
}
What does this code do?
struct Params describes the data your page sends in. @group(0) @binding(0) is the slot the shader reads it from.
abs(sin(params.time)) swings between 0 and 1 as time grows, so the blue channel rises and falls while red and green stay tied to position.
- Save shader.wgsl.
- Check the new binding compiles by running:
npx vgpu check shader.wgsl
- You should see the reflection printed with no errors, now including the uniform binding.
Reloaded the page already?
Your shader now expects a params uniform that the page isn't setting yet, so vgpu raises VGPU-R1-BINDING-NEVER-SET at draw time.
That's expected right now. The next change hands the shader its value.
- In index.html, replace everything inside the <script type="module"> block with this:
import { clock, effect, frameLoop, init, surface } from "vgpu";
const gpu = await init();
const canvas = document.querySelector("canvas");
const canvasTarget = surface(gpu, canvas);
// Read your shader file and start it with time at 0
const source = await fetch("./shader.wgsl").then((response) => response.text());
const gradient = effect(gpu, source, { set: { params: { time: 0 } } });
// vgpu's frame clock counts the seconds since the loop started
const time = clock(gpu);
// Every tick: push the new time into the shader, then draw a frame
frameLoop(gpu, (frame) => {
gradient.set({ params: { time: time.time } });
frame.pass(canvasTarget, gradient);
});
What's happening here?
frameLoop(gpu, ...) runs your callback every tick and advances vgpu's frame clock for you.
gradient.set(...) writes the current time into the params uniform, then frame.pass draws that frame onto the canvas.
- Save index.html.
Before you reload, make a call: will the whole canvas change colour, or only part of it?
- Reload the browser tab.
Your gradient breathes. Red and green stay pinned to position while the blue channel rises and falls, so the canvas cycles between deep reds and greens and a washed-out pastel version of the same picture.
Picture still frozen?
Check that the gradient.set(...) line sits inside the frameLoop callback. Outside it, time is written once and never again.
Why is my vgpu frameLoop rendering the same frame every tick?
✔️ Awesome, I've got everything!
Great. Double check you've saved both shader.wgsl and index.html.
ⓧ I'd like to double check the full code
Here's shader.wgsl in full:
// Values the page sets once per frame and every pixel reads
struct Params { time: f32 }
@group(0) @binding(0) var<uniform> params: Params;
@fragment fn fs_main(@location(0) uv: vec2f) -> @location(0) vec4f {
// Blue now swings between 0 and 1 as time moves on
return vec4f(uv, abs(sin(params.time)), 1.0);
}
And the script block inside index.html:
import { clock, effect, frameLoop, init, surface } from "vgpu";
const gpu = await init();
const canvas = document.querySelector("canvas");
const canvasTarget = surface(gpu, canvas);
// Read your shader file and start it with time at 0
const source = await fetch("./shader.wgsl").then((response) => response.text());
const gradient = effect(gpu, source, { set: { params: { time: 0 } } });
// vgpu's frame clock counts the seconds since the loop started
const time = clock(gpu);
// Every tick: push the new time into the shader, then draw a frame
frameLoop(gpu, (frame) => {
gradient.set({ params: { time: time.time } });
frame.pass(canvasTarget, gradient);
});
Your shader lives in its own file now and animates in the browser. Next up, you'll run that exact same file from Node with no browser at all and write the result out as a PNG.
Render the Shader Headless
Your gradient is animating in the browser, which proves the shader works on your machine right now. It proves nothing about tomorrow, because the only way to check it is to look at it.
A shader you can only inspect by eye cannot be reviewed or tested. In this step you run the exact same shader.wgsl from Node with no browser at all, and save the frame as a file you can open.
In this step, get ready to:
- Confirm Node can reach a GPU and render without a browser.
- Write a render.js script that loads shader.wgsl and renders one frame headless.
- Save that frame as output.png and compare it against your browser canvas.
Check that Node can render without a browser
In the browser, WebGPU comes from the browser itself. Node.js has no browser, so vgpu renders through Dawn instead.
That native path is the part your machine might be missing. Check it before you write a single line of the script.
- In your terminal, check that this machine can render headless by running this command:
npx vgpu doctor
What is doctor checking?
The command does not just look for files. It acquires an adapter and renders a real frame end to end.
When something is missing, it prints the exact fix as JSON. That makes it the first thing to run on any new machine.
- Read the report it prints and confirm it does not name a missing piece to fix.
No GPU or driver on this machine?
A machine with no GPU and no driver needs a portable CPU renderer set up once. Run this, then run the check again:
npx vgpu install-software-renderer
Still stuck? Help me get a working adapter in Node.
Write the headless render script
A headless render hands you the finished frame as raw RGBA bytes. Turning those bytes into an image file needs an encoder.
- Install the PNG encoder by running this command:
npm install pngjs
- In your code editor, open package.json and confirm pngjs now sits alongside vgpu under dependencies.
Install failed partway through?
A dropped network connection leaves the package half written, so rerunning the same command is safe and usually enough.
A permissions error means the terminal is not sitting in your own project folder. Why did my npm install fail?
Your script will use import statements and top-level await. Node only allows those when the project is marked as an ES module.
- In your code editor, add this line to package.json, directly below the "name" line:
"type": "module",
- Save package.json.
- In your code editor, create a new file called render.js in your project folder, next to shader.wgsl.
- Load your shader from disk by adding this code to render.js:
import { writeFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { PNG } from "pngjs";
import { resolveShader } from "@vgpu/wgsl/runtime";
import { effect, init, target } from "vgpu/node";
// Read shader.wgsl from disk and flatten it into one WGSL string
const resolved = await resolveShader({
entry: fileURLToPath(new URL("./shader.wgsl", import.meta.url)),
});
console.log("Resolved WGSL characters:", resolved.wgsl.length);
What does resolveShader do?
- resolveShader() reads your WGSL entry file from disk and follows anything it imports.
- It hands back one finished shader string on resolved.wgsl, which is what the renderer wants.
- Building the path from import.meta.url means the script finds the shader no matter which folder you run it from.
- Save render.js.
- Switch back to your terminal and run the script with this command:
node render.js
The terminal prints Resolved WGSL characters: followed by a number. Node can now read your shader.
Seeing an import or path error?
An error about import outside a module means the "type": "module" line did not save into package.json.
A VGPU-WGSL-RES-NOTFOUND error means the entry path does not exist, so render.js is not sitting beside shader.wgsl. Help me fix this error
- Render one frame into memory by adding this code to the bottom of render.js:
// init() opens a GPU context with no browser and no canvas
const gpu = await init();
// target() is the offscreen image your shader paints into
const colorTarget = target(gpu, { size: [512, 512] });
// Freeze the animation at one moment so every render matches
const shader = effect(gpu, resolved.wgsl, { set: { params: { time: 1.0 } } });
shader.draw(colorTarget);
// read() hands back the finished frame as raw RGBA bytes
const pixels = await colorTarget.read();
console.log("Pixel bytes read:", pixels.length);
What does this code do?
- init() opens a GPU context that never touches a canvas or a window.
- target() creates the 512 by 512 offscreen image your shader paints into.
- set hands the shader the params.time value your gradient reads. Pinning it to 1.0 renders the same moment every run.
- colorTarget.read() copies the finished frame back as raw bytes, four per pixel.
- Save render.js.
- Render your first headless frame by running this command, which can take a minute the first time:
node render.js
The terminal now prints Pixel bytes read: with a number over a million. Your shader just rendered with no browser involved.
Looks like it has hung?
A headless render on a CPU renderer is far slower than the browser, and the first run also has to warm up the pipeline. Give it a minute before assuming it stalled.
If it never returns, help me work out where my headless render is stuck.
Save the frame as a PNG and compare it
Those bytes only exist inside the running process. Writing them to a file turns your render into something you can open, send to someone, and compare later.
- Write the frame to an image file by adding this code to the bottom of render.js:
const png = new PNG({ width: colorTarget.size[0], height: colorTarget.size[1] });
png.data.set(pixels);
writeFileSync("output.png", PNG.sync.write(png));
gpu.dispose();
What does this code do?
- new PNG(...) takes its size straight from the target, so the file can never disagree with what you rendered.
- png.data.set(pixels) drops your raw bytes into the image.
- writeFileSync saves the encoded image as output.png in your project folder.
- gpu.dispose() stops Dawn polling for events, so the process exits on its own instead of hanging.
Before you run this, do you expect the image to match what your browser is drawing at this exact second, or something else?
- Save render.js.
- Render the frame and write the file by running this command:
node render.js
- Confirm the file landed by running this command:
ls
You should see output.png listed alongside render.js and shader.wgsl.
- Open your project folder in Finder (macOS) or File Explorer (Windows).
- Double click output.png to open it.
- Switch back to the browser tab running your page and put the two side by side.
The colours and the shape of the gradient match, but the image is frozen. It is the single moment you pinned with time, not whatever the browser is drawing right now.
The same shader.wgsl produced both pictures. One went through a canvas, one went through Node.
Image came out black?
A shader problem shows up as flat pixels rather than a stack trace, so a black image usually means the WGSL never compiled. Validate the file on its own:
npx vgpu check shader.wgsl
A black image with a clean check usually means the uniform name in set does not match the one your shader declares. Why is my headless render blank?
✔️ Awesome, I've got everything!
Great, double check you've saved render.js.
ⓧ I'd like to double check the full code
Here is the whole file after all three chunks:
import { writeFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { PNG } from "pngjs";
import { resolveShader } from "@vgpu/wgsl/runtime";
import { effect, init, target } from "vgpu/node";
// Read shader.wgsl from disk and flatten it into one WGSL string
const resolved = await resolveShader({
entry: fileURLToPath(new URL("./shader.wgsl", import.meta.url)),
});
console.log("Resolved WGSL characters:", resolved.wgsl.length);
// init() opens a GPU context with no browser and no canvas
const gpu = await init();
// target() is the offscreen image your shader paints into
const colorTarget = target(gpu, { size: [512, 512] });
// Freeze the animation at one moment so every render matches
const shader = effect(gpu, resolved.wgsl, { set: { params: { time: 1.0 } } });
shader.draw(colorTarget);
// read() hands back the finished frame as raw RGBA bytes
const pixels = await colorTarget.read();
console.log("Pixel bytes read:", pixels.length);
const png = new PNG({ width: colorTarget.size[0], height: colorTarget.size[1] });
png.data.set(pixels);
writeFileSync("output.png", PNG.sync.write(png));
gpu.dispose();
You now have a picture of what your shader is supposed to look like. Next up, you'll turn that picture into a test that goes red the moment the render changes.
Add a Visual Regression Test
Your shader now lives in two places. The browser paints it on a canvas, and render.js draws the same module with no browser at all and writes it to output.png.
Right now you are the checker. You open the PNG, look at it, and decide whether it still looks right.
That check is exactly the job a test can take off your hands. In this step you freeze one good render as a baseline, then write a test that renders again and fails the moment the two stop matching.
In this step, get ready to:
- Save your confirmed render as a reference image.
- Write a test that re-renders your shader and compares it against that reference.
- Run the test and watch it pass.
Save your confirmed render as a reference image
A regression test needs something to compare against. That something is a baseline image you already trust, saved under a name nothing else overwrites.
- Return to the terminal you already have open in your project folder.
macOS
- Save your confirmed render as a baseline by running this command:
cp output.png reference.png
- Confirm the baseline image exists by running this command:
ls
You should see reference.png listed alongside output.png, render.js, and shader.wgsl.
Windows
- Save your confirmed render as a baseline by running this command:
copy output.png reference.png
- Confirm the baseline image exists by running this command:
dir
You should see reference.png listed alongside output.png, render.js, and shader.wgsl.
Why save a second copy of the same image?
render.js overwrites output.png every time it runs. Anything you compared against it would shift the moment you rendered again.
reference.png is never touched by the render script. It sits in your project folder as the frozen frame you already checked with your own eyes.
Write a test that re-renders your shader
A test that reads a file someone rendered last week proves nothing. Running render.js from inside the test means every run judges a brand new frame.
- In your code editor, create a new file called shader.test.js in your project folder, alongside render.js.
- Render a fresh frame and load both images by adding this code to shader.test.js:
const test = require("node:test");
const assert = require("node:assert");
const { execFileSync } = require("node:child_process");
const { readFileSync } = require("node:fs");
test("shader render matches the reference image", () => {
// Run the headless render script so output.png holds a brand new frame
execFileSync("node", ["render.js"]);
// Load the fresh render and the saved baseline as raw image data
const fresh = readFileSync("output.png");
const reference = readFileSync("reference.png");
// Both images have to load before there is anything worth comparing
assert.ok(fresh.length > 0);
assert.ok(reference.length > 0);
});
What does this code do?
- execFileSync runs render.js as its own Node.js process, so the test renders your shader exactly the way you do by hand.
- readFileSync pulls each image into memory as a buffer of raw bytes.
- assert.ok fails the test if either file came back empty.
- Save shader.test.js.
- Run your test file by running this command:
node --test
You should see your test name reported once, followed by a summary of one passing test and zero failures.
No tests found?
Node only picks up files whose names end in .test.js. Check your file is named shader.test.js and sits in the same folder as render.js.
Still stuck? Why isn't Node finding my test file?
Compare every byte against the reference
Loading the two images proves the plumbing works. The test still passes no matter what your shader draws, because nothing compares them yet.
- In shader.test.js, find the two assertions at the bottom of the test:
assert.ok(fresh.length > 0);
assert.ok(reference.length > 0);
- Replace those two lines with a loop that counts every byte that differs, so the test goes red on any change:
// Count every byte of image data that differs between the two renders
let differences = 0;
for (let i = 0; i < reference.length; i++) {
if (fresh[i] !== reference[i]) differences++;
}
assert.strictEqual(differences, 0, differences + " bytes of image data changed");
Why counting bytes catches a changed pixel
Both images come out of the same headless renderer at the same size, so their PNG data lines up byte for byte.
Shift a colour in your shader and those bytes shift with it. The count tells you how much moved, which is far more useful than a bare pass or fail.
- Save shader.test.js.
Before you run it, what do you expect? You have not touched the shader since you saved the baseline, so does a fresh render still match it byte for byte?
- Run your test again by running this command:
node --test
You should see one passing test and zero failures. Your shader now has a check that proves it still renders the way it did when you saved the reference.
Test failing with a byte count?
If render.js feeds a live clock value into your shader's time uniform, every render lands on a different frame of the animation. Pin that value to a fixed number in render.js so each run draws the same frame.
Once your renders are stable, run render.js once more and save a fresh reference.png with the copy command from earlier.
Not sure why the frames differ? Why does my shader render a different image every time I run render.js?
✔️ Awesome, I've got everything!
Great. Double check you have saved shader.test.js.
ⓧ I'd like to double check the full code
const test = require("node:test");
const assert = require("node:assert");
const { execFileSync } = require("node:child_process");
const { readFileSync } = require("node:fs");
test("shader render matches the reference image", () => {
// Run the headless render script so output.png holds a brand new frame
execFileSync("node", ["render.js"]);
// Load the fresh render and the saved baseline as raw image data
const fresh = readFileSync("output.png");
const reference = readFileSync("reference.png");
// Count every byte of image data that differs between the two renders
let differences = 0;
for (let i = 0; i < reference.length; i++) {
if (fresh[i] !== reference[i]) differences++;
}
assert.strictEqual(differences, 0, differences + " bytes of image data changed");
});
Your shader now has a check that runs without a browser and without your eyes. Next up, you'll change one number in the shader and watch that check turn red.
Break the Shader and Watch the Test Catch It
Your test passed on its very first run. A check that has only ever been green is a check you have no real reason to trust yet.
The only way to trust it is to break the thing it watches. In this step you change a single number in your shader and watch the test turn red.
In this step, get ready to:
- Change one numeric constant in shader.wgsl and see the gradient shift.
- Run the test against the edited shader and watch it fail.
- Compare the new render against reference.png before deciding whether to accept it.
Change one number in your gradient
Your WGSL fragment stage builds its colour from UV coordinates and a time uniform. Somewhere in that calculation sits a plain number deciding how far the gradient travels.
- Switch back to shader.wgsl in your editor.
- Find the numeric constant in your fragment stage that scales the time uniform.
- Replace that number with one three times larger.
- Save shader.wgsl.
Why change a number instead of the logic?
One constant is the smallest edit that still moves every pixel in the frame. It proves the test is watching the rendered output rather than the shape of your file.
- Render the edited shader headless by running this command:
node render.js
- Open output.png in your editor.
The colours sit in different places to the frame you confirmed in the last step. One number moved every pixel.
Output looks identical?
Check that you saved shader.wgsl before running the render. An unsaved edit leaves the headless render reading the original shader.
Check that the number you changed lives in the fragment stage and feeds the colour calculation. A constant in the vertex stage or inside a comment will not move the gradient.
Your editor may still be showing a cached preview of the old image, so close the output.png tab and open it again.
Why does my shader edit not change the rendered image?
Run the test and watch it go red
Your baseline image still holds the original gradient. Your fresh render no longer matches it.
Before you run the test, what do you expect it to report? Have a guess at how it describes the gap between the two images.
- Compare the edited shader against your baseline by running this command:
node --test shader.test.js
The run ends in a failure instead of a pass. That red is the whole point of the last four steps.
Nobody told the test what changed. It caught the drift by looking at pixels.
Test still passing?
Confirm your edit reached disk. If shader.wgsl is unsaved, the test renders the original gradient and matches the baseline.
Confirm reference.png still holds the original render. Overwriting it with the new frame makes the comparison pass against the wrong baseline.
Why is my image comparison test passing when the two images look different?
Compare the new image before you accept it
A red test tells you the output changed. It says nothing about whether the new output is the one you want.
- Keep a copy of the failing render by running the command for your system:
macOS
cp output.png failed-run.png
Windows
copy output.png failed-run.png
- Confirm the copy landed by running:
ls
You should see failed-run.png listed alongside reference.png and output.png.
- Open reference.png in your editor.
- Open failed-run.png in a second tab beside it.
The old gradient and the new one now sit side by side. This look is exactly what a red test buys you.
Accepting a new baseline is a deliberate act
When a change is the one you meant to make, you overwrite reference.png with the new render and the test goes green again. When it is a regression, you undo the shader edit instead.
Leave reference.png untouched for now. Keeping the original baseline is what lets you compare the two frames at all.
Your repo now holds a shader, a headless render, and a check that goes red the moment the output drifts. That is what turns shader work into something a reviewer can actually verify.
Secret mission
Tell Size Mismatches Apart from Shader Changes
Your test tells you the render changed. It doesn't tell you whether the shader changed or the canvas did. In this mission you write a size check that reads width and height straight out of your PNG files, so a resized render never masquerades as a shader bug.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely on your own laptop with an open source library, so there are no ongoing costs.
Resources you used:
- The project folder holding index.html, shader.wgsl, render.js and shader.test.js.
- The package.json and package-lock.json files created when you set up the Node project.
- The vgpu library installed as a local dependency inside node_modules.
- Your saved baseline image, reference.png.
- The throwaway renders, output.png and failed-run.png.
Keep everything running
No action needed. Choose this if you're still actively building on your shader repo.
- Clear out the throwaway renders by running this command in your project folder:
rm output.png failed-run.png
- On Windows, clear them out by running this command instead:
del output.png failed-run.png
What stays on your machine
Node.js, npm and the vgpu library all stay installed. Nothing runs in the background and nothing costs money while they sit there.
Your reference.png baseline stays too, so the test still has something to compare a fresh render against.
Pause - I'll come back to this later
Shut down running processes to free up memory, and leave every file exactly where it is.
- Press Ctrl+C in your terminal to stop anything still running there.
- Close the browser tab showing your WebGPU canvas.
- Clear out the throwaway renders by running this command in your project folder:
rm output.png failed-run.png
- On Windows, clear them out by running this command instead:
del output.png failed-run.png
Keep the baseline image
Leave reference.png in place. Delete it and your test has nothing to compare against when you pick this back up.
Delete - I don't want to use this again
Remove all project resources and start fresh. This wipes the shader, the render script, the test, both PNGs and the installed dependency in one go.
- Press Ctrl+C in your terminal to stop anything still running there.
- Move out of your project folder by running this command:
cd ..
- Delete the whole folder, including node_modules, reference.png, output.png and failed-run.png, by running this command:
rm -rf [[PROJECT_FOLDER="your project folder name"]]
- On Windows, delete the folder by running this command instead:
rmdir /s /q [[PROJECT_FOLDER="your project folder name"]]
- Confirm the folder is gone by running this command:
ls
Your project folder is no longer listed in the output.
Getting a permission or in-use error?
A terminal or editor still sitting inside the folder can block the delete. Close the folder in your code editor, then run the delete command again.
Still stuck? Why can't I delete my project folder?
Nice Work!
Nice Work
Nice work! You've just built a WebGPU shader that runs in a browser tab and headless in Node, with a test that catches it the moment the pixels change.
Shader work usually lives or dies on somebody eyeballing it. You've turned yours into something a machine can check.
You've learned how to:
- Write a WGSL module in shader.wgsl that paints a moving gradient onto a canvas in the browser.
- Render that same shader headless with render.js, writing a real PNG to output.png with no browser involved.
- Lock in reference.png as a baseline image and watch shader.test.js go red the moment you changed a single number in your shader.
- Secret Mission: You took the project further with an optional challenge built on top of your shader pipeline.
Ready to quiz yourself?