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.
| Module | Exports |
|---|---|
SoftwareGPUDevice.js | SoftwareGPUDevice |
SoftwareGPUQueue.js | SoftwareGPUQueue |
SoftwareGPUBuffer.js, SoftwareGPUTexture.js, SoftwareGPUTextureView.js, SoftwareGPUSampler.js | the resources |
SoftwareGPUBindGroup.js, SoftwareGPUBindGroupLayout.js | binding |
SoftwareGPUCommandEncoder.js, SoftwareGPUCommandBuffer.js, SoftwareGPURenderPassEncoder.js, SoftwareGPUComputePassEncoder.js | encoding |
SoftwareGPUPipeline.js | SoftwareGPURenderPipeline, SoftwareGPUComputePipeline |
SoftwareGPUShaderModule.js, SoftwareGPUQuerySet.js, SoftwareGPUImmediateData.js | the rest of the surface |
SoftwareGPUError.js | SoftwareGPUError, SoftwareGPUValidationError, SoftwareGPUOutOfMemoryError, SoftwareGPUInternalError |
SoftwareGPUObjectBase.js, SoftwareGPUUncapturedErrorEvent.js | shared 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:
| Feature | Why it is allowed |
|---|---|
timestamp-query | the device has a synthetic monotonic counter, so query sets and pass timestamps work |
subgroups | it 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.
- 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. - 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.
- Something a real device would accept and this one cannot emulate -
flipY, an image source with no CPU-readable pixels, a multi-layerwriteTexture- throws a plainErrornaming 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,createRenderPipelineandcreateComputePipelinereturn 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
| Where | What it gives you |
|---|---|
renderPass.draws | one entry per draw, in encode order: pipeline, bind_groups, dynamic_offsets, vertex_buffers, index_buffer, viewport, scissor, vertex_count, instance_count, indexed, indirect |
renderPass.descriptor | the colorAttachments, depthStencilAttachment and timestampWrites the pass was begun with - the only place to read what a pass was pointed at |
computePass.dispatches | pipeline, bind_groups, dynamic_offsets, group_counts per dispatchWorkgroups |
device.live_resources | the 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.data | the 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.isValid | whether 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
Errorimmediately whennavigator.gpuis absent, before it looks atdevice; - reads
window.matchMedia,window.devicePixelRatioandnavigator.gpu.getPreferredCanvasFormat(); - creates a canvas through
document.createElementwhen nocontextwas passed, and asserts the context is a realGPUCanvasContext.
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:
| Tier | Use it for |
|---|---|
| Assertions against compiled shader source | shader composition - which chunks landed, in what order, with which constants |
ComputeShaderEmulator | running WGSL on the CPU, thread by thread, and reading the bindings back |
SoftwareGPUDevice | binding, 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.
Related
- Rendering overview - the device floor and what a real startup requires
- Diagnostics - device failure signals and the failure view
- Render extensions - the frame structure this device lets you assert on