Platform

Testing without a GPU

SoftwareGPUDevice - a validating software WebGPU device that lets renderer code be constructed, driven and asserted on under node, with no adapter and no shader execution.

Meep 3 is WebGPU only, which makes “get a device” the first line of almost every rendering code path and a problem for every test that wants to run in CI. The answer the engine uses for its own suite is SoftwareGPUDevice: a CPU-memory stand-in for GPUDevice that validates what you hand it the way WebGPU does, records what you encode, and executes none of it.

It is not an emulator. Nothing here compiles or runs WGSL, and nothing rasterizes. What it makes testable is everything around the draw - which resources were created and with what descriptors, which passes a frame opens and in what order, what each was pointed at, which pipeline and bindings each draw ran under, and whether anything leaked.

Constructing one

import { SoftwareGPUDevice } from "@woosh/meep-engine/src/shade/device/mock/SoftwareGPUDevice.js";

const device = new SoftwareGPUDevice({
    label: "test",
    requiredFeatures: ["timestamp-query"],
    requiredLimits: { maxStorageBufferBindingSize: 1 << 20 }
});

Every class in src/shade/device/mock/ is a named export - there are no default exports in src/shade/ at all. The device is the entry point; the rest are the objects it hands out.

ModuleExports
SoftwareGPUDevice.jsSoftwareGPUDevice
SoftwareGPUQueue.jsSoftwareGPUQueue
SoftwareGPUBuffer.js, SoftwareGPUTexture.js, SoftwareGPUTextureView.js, SoftwareGPUSampler.jsthe resources
SoftwareGPUBindGroup.js, SoftwareGPUBindGroupLayout.jsbinding
SoftwareGPUCommandEncoder.js, SoftwareGPUCommandBuffer.js, SoftwareGPURenderPassEncoder.js, SoftwareGPUComputePassEncoder.jsencoding
SoftwareGPUPipeline.jsSoftwareGPURenderPipeline, SoftwareGPUComputePipeline
SoftwareGPUShaderModule.js, SoftwareGPUQuerySet.js, SoftwareGPUImmediateData.jsthe rest of the surface
SoftwareGPUError.jsSoftwareGPUError, SoftwareGPUValidationError, SoftwareGPUOutOfMemoryError, SoftwareGPUInternalError
SoftwareGPUObjectBase.js, SoftwareGPUUncapturedErrorEvent.jsshared plumbing

SoftwareGPUError is deliberately not an Error subclass, because GPUError is not one either.

Features

The device always carries core-features-and-limits, which is what a core WebGPU adapter reports. requiredFeatures accepts exactly two optional features:

FeatureWhy it is allowed
timestamp-querythe device has a synthetic monotonic counter, so query sets and pass timestamps work
subgroupsit changes only which WGSL a shader module may contain, and this device runs no WGSL - refusing it would make most compute passes untestable for a reason unrelated to what is being tested

Anything outside that set throws a TypeError at construction rather than being silently granted. A device that claimed a feature it cannot emulate would send the code under test down a path the mock cannot honour.

Limits, and why you would lower one

With no requiredLimits, the device reports the WebGPU specification’s default limits - the same values adapter.requestDevice() with no requiredLimits yields, verified field by field against a Dawn default device. That includes the stage-scoped storage limits (maxStorageBuffersInVertexStage: 8, maxStorageTexturesInFragmentStage: 4 and their neighbours), which are easy to get wrong: the zeroes those are sometimes written as are the compatibility-mode values, not the defaults.

An override may only make a limit worse, which is the direction requestDevice allows to succeed. “Worse” is smaller for a max* limit and larger for a min* one - the alignment limits are minimums, and a device that permitted a smaller alignment would hide exactly the misalignment bugs those limits exist to catch. Asking for a better value throws a DOMException (OperationError); naming a limit that is not a GPUSupportedLimits member throws a TypeError, so a typo cannot sit in the object doing nothing.

The reported limits object is frozen, as a real GPUSupportedLimits is read-only.

Lowering a limit is the documented way to reach the renderer’s clamping paths. The renderer divides work into chunks that fit maxStorageBufferBindingSize, maxBufferSize, maxComputeWorkgroupsPerDimension and so on; on a real device those paths only run on hardware you probably do not have. Here you reach them by asking for a small device, and nothing large is ever allocated to get there:

const device = new SoftwareGPUDevice({
    requiredLimits: { maxStorageBufferBindingSize: 4096 }
});

What it validates, and what it does not

Three kinds of bad input are handled three different ways, and the difference tells you whether you have found a bug in your code or a limit of the harness.

call on the deviceWebGPU calls it a validation error?report through the error scopehand back an invalid objectencoder protocol misuse?throwthrow, naming the mockyesyesunemulatableno
  1. Anything the WebGPU specification calls a validation error is reported through the error scope, never thrown, and the call hands back an object that is invalid the way WebGPU’s is - it still reads back the size, usage and label that were asked for, and fails everything that consumes it. Invalidity propagates the same way too: a command naming an invalid buffer poisons the encoder, and the error surfaces from finish(), not from the encoding call; a submission holding one invalid command buffer executes none of its command buffers. This is the default for everything, and it is the one thing a software device must not get wrong.
  2. Encoding on a finished command encoder, or on an ended pass, throws. WebGPU has no way to express that, and it always means test code got the encoder protocol wrong.
  3. Something a real device would accept and this one cannot emulate - flipY, an image source with no CPU-readable pixels, a multi-layer writeTexture - throws a plain Error naming the mock and the unsupported thing.

The error-scope machinery itself is faithful: pushErrorScope / popErrorScope / generate_error / uncapturederror, with the innermost matching scope winning, each scope keeping only its first error, a pop with no scope open rejecting with an OperationError, and an unclaimed error reaching the console.

Not emulated at all

  • No WGSL execution. createShaderModule, createRenderPipeline and createComputePipeline return opaque handles that retain their descriptor for inspection. The source is not parsed.
  • No rasterization. A render pass writes to none of the attachments it names.
  • No dispatch. A compute dispatch changes no bytes.

So: never assert on pixels a draw would have produced or on bytes a shader would have written. Those reads pass and mean nothing. Indirect draws and dispatches are explicit about this - their counts are recorded as -1 rather than a number read out of a buffer nobody filled.

What you assert on

WhereWhat it gives you
renderPass.drawsone entry per draw, in encode order: pipeline, bind_groups, dynamic_offsets, vertex_buffers, index_buffer, viewport, scissor, vertex_count, instance_count, indexed, indirect
renderPass.descriptorthe colorAttachments, depthStencilAttachment and timestampWrites the pass was begun with - the only place to read what a pass was pointed at
computePass.dispatchespipeline, bind_groups, dynamic_offsets, group_counts per dispatchWorkgroups
device.live_resourcesthe buffers and textures still held, in creation order, leaving as soon as they are destroyed. A clean teardown sees an empty array; a leak sees exactly what leaked, by label
buffer.datathe buffer’s CPU bytes directly, no mapping dance - plus mapAsync/getMappedRange/unmap if you want to exercise the real protocol
texture.get_mip_data(mip)the texels of one mip level. Storage is allocated lazily on first write, so code that only creates, views and destroys textures pays nothing
buffer.isValid, texture.isValidwhether the descriptor was accepted
device.next_timestamp()the synthetic counter, shared across every query set the device made, so two of them still order against each other

Texture copies do execute - texture to texture, buffer to texture, texture to buffer - because texture storage here is CPU memory and a copy of CPU memory is a copy. queue.writeBuffer, queue.writeTexture and buffer copies are likewise real. Those are the bytes worth asserting on.

device.destroy() is idempotent, resolves device.lost with reason "destroyed", and afterwards work submitted to the device is a validation error rather than an exception - a renderer shutting down races its own in-flight frames, and it must take the same path here as in a browser.

How the device reaches the code under test

Shade’s own managers take a device in their constructor - new GraphicsContext(device), new TextureManager(device), new GPUTextureContext(device) and so on - and that is the seam. Production code accepts a device-like object by duck-typing rather than by instanceof GPUDevice; GPUTextureContext says so in a comment next to the assert, which is the only place in shipping source that names SoftwareGPUDevice at all. Construct the piece you are testing with the software device directly.

Renderer.initialize({ device }) is not the headless path. The parameter exists and a supplied device is used instead of one requested from an adapter, but initialize() still:

  • throws a plain Error immediately when navigator.gpu is absent, before it looks at device;
  • reads window.matchMedia, window.devicePixelRatio and navigator.gpu.getPreferredCanvasFormat();
  • creates a canvas through document.createElement when no context was passed, and asserts the context is a real GPUCanvasContext.

So { device } is for substituting a device inside a browser that already has WebGPU, not for standing the whole renderer up in node. Test below the Renderer boundary.

The rule: a spec must never acquire a real device

This is a rule in the engine, not just a description of how things happen to be. A spec must not import a native WebGPU binding, stand up Dawn, or request an adapter or a device. The failure mode is nasty: a device held at module scope keeps its vitest worker alive and takes the whole run down with Worker exited unexpectedly, naming no test. No spec breaks the rule, and the native binding is not a dependency, so an import of one does not resolve.

Three sanctioned tiers instead, in increasing cost:

TierUse it for
Assertions against compiled shader sourceshader composition - which chunks landed, in what order, with which constants
ComputeShaderEmulatorrunning WGSL on the CPU, thread by thread, and reading the bindings back
SoftwareGPUDevicebinding, validation, resource lifetime, pass and frame-graph structure

The vitest setup file

Production modules touch WebGPU globals at load time - GPUShaderStage, GPUBufferUsage, GPUTextureUsage, GPUMapMode - and reading them under node throws a ReferenceError that trips any import of a shader module which builds an ImageShader or ComputeShader at top level. The engine ships the stub it uses:

// vitest.config.mjs
export default {
    test: {
        setupFiles: ["@woosh/meep-engine/src/shade/vitest.setup.mjs"]
    }
};

Besides the bit-flag constants it installs GPUBuffer, GPUTextureView and GPUSampler stand-ins whose Symbol.hasInstance recognises the software resources. That matters more than it looks: the resource-hashing and binding code branches on instanceof GPUBuffer, and empty stand-ins would answer false for everything the software device hands out, so two bind groups differing only in bound texture would hash identically.

Running WGSL on the CPU

For the tier the device deliberately leaves empty:

import { ComputeShaderEmulator } from "@woosh/meep-engine/src/shade/wgsl/emulator/ComputeShaderEmulator.js";

const emulator = await ComputeShaderEmulator.fromComputeShader(shader);

emulator.bindings = { data: someArray };
emulator.dispatch([0, 0, 0]);   // global_invocation_id
emulator.dispatch([1, 0, 0]);

It translates WGSL to JavaScript and dispatches threads point-wise. Four ways in, all async statics: fromSource(source), fromComputeShader(shader), fromImageShader(shader) (a single-pixel fragment pass - you supply the @builtin(position) value, and the derivative builtins throw because they cannot be faked from one pixel), and fromCodeChunk(chunk), which turns a WGSL chunk into a plain object where every function is a method - the one-line way to unit-test an RNG or a packing helper. Texture bindings take a CPUBitmapData; sampling honours the bound sampler’s address modes and filter. resetWorkgroup() clears var<workgroup> state between simulated workgroups.

One practical caveat: the emulator goes through WGSLParser, which imports web-tree-sitter. That is a development dependency of the engine, not a runtime or peer dependency, so a consumer using this tier has to install it themselves. SoftwareGPUDevice has no such requirement.