Skip to content

@zenfg/webgpu

@zenfg/webgpu is a lightweight WebGPU FrameGraph for declaring and executing per-frame GPU work. It orders graph nodes, validates resource access, culls unused work, derives WebGPU usage, tracks lifetimes, and reuses transient textures and buffers.

Build rendering features directly with WebGPU or compatible libraries, and combine them with existing renderers through explicit graph integration. See the composition model.

It is not a renderer abstraction. ZenFG owns graph-visible dependencies, execution order, retention, transient allocation, and optional diagnostics. The caller owns scenes, pipelines, bind groups, samplers, long-lived resources, surface presentation, device-loss policy, and concrete draw or dispatch work.

This is a public beta package. Pin the exact prerelease version while integrating. Import runtime APIs from the package root; the only supported diagnostic subpath is @zenfg/webgpu/snapshot.

Installation

sh
npm install @zenfg/webgpu@0.1.0

Quick start

Run this in a browser module served from HTTPS or localhost, with native WebGPU enabled. The host supplies a GPUDevice and a configured canvas GPUCanvasContext. Call renderFrame() from the host animation loop; the canvas clears to dark blue. For a ready-to-run canvas host, open the minimal-frame recipe in the Examples.

Keep one FrameGraph for the lifetime of a GPUDevice. Create a fresh recording and import a fresh current surface texture for every presentation frame. This complete example needs no render pipeline because it only clears the surface attachment:

ts
import { FrameGraph } from '@zenfg/webgpu';

// `device` and the configured `context` are caller-owned.
const graph = new FrameGraph(device);
let frameIndex = 0;

function renderFrame(): void {
	const recorder = graph.beginFrame();
	const backbuffer = recorder.importSwapchainTexture(
		context.getCurrentTexture(),
		{ label: 'backbuffer' },
	);

	recorder.render({
		label: 'clear-backbuffer',
		colorAttachments: [{
			target: backbuffer,
			loadOp: 'clear',
			storeOp: 'store',
			clearValue: { r: 0.04, g: 0.06, b: 0.1, a: 1 },
		}],
	});

	recorder.markPresent(backbuffer);
	recorder.compile().execute({ frameIndex: frameIndex++ });
}

// When the device-bound renderer stack is released:
// graph.destroy();

markPresent() makes the final surface value observable. Without a resource root or a side-effect node, work that contributes to no result is culled. Normal execution records and submits synchronously; only optional GPU timing returns an asynchronous readback result.

Lifecycle

text
FrameGraph runtime -> FrameGraphRecorder -> CompiledFrame
device lifetime       one recording        retained executable plan
  • FrameGraph is permanently bound to one caller-owned GPUDevice and owns the transient pool and lazy profiler resources.
  • beginFrame() creates an independent, single-use recording. A successful or failed compile() consumes it.
  • Handles, views, and access tokens are local to one recording.
  • A compiled frame can be re-executed only while all captured callbacks and imported GPU objects remain valid. Presentation recordings normally should not be re-executed.
  • destroy() releases runtime-owned resources; it never destroys the device or imported resources.

Common tasks

TaskPublic API
Create the device-bound runtimenew FrameGraph(device)
Start a recordingbeginFrame()
Create transient storagecreateTexture(), createBuffer()
Import caller-owned storageimportTexture(), importBuffer()
Import the current presentation targetimportSwapchainTexture()
Select texture subresourcescreateTextureView()
Declare typed resource accessuse() with TextureAccess or BufferAccess
Record structured workrender(), compute(), copy(), clearBuffer()
Encode custom graph-owned commandscommand()
Call a renderer that submits itselfexternalSubmission()
Retain observable valuesmarkPresent(), markOutput(), markReadback(), markDebugCapture(), markPersistentState()
Compile a compact executable plancompile()
Request full compilation diagnosticscompile({ report: true })
Execute and optionally request timing/debug groupscompiled.execute()
Inspect or clear retained allocationsgetResourcePoolStats(), clearResourcePool()
Export portable diagnosticscreateFrameGraphSnapshot() from @zenfg/webgpu/snapshot
Release runtime-owned resourcesdestroy()

Exact fields, overloads, defaults, return types, and failure conditions are documented by the TSDoc preserved in the packaged source and declarations.

Key pattern: declare, list, and unwrap access

use() creates an opaque typed token. A node must list that exact token in uses before its synchronous callback can resolve it with unwrap():

ts
const sampledSceneColor = recorder.use(
	sceneColor,
	TextureAccess.Sampled,
);

recorder.render({
	label: 'present',
	uses: [sampledSceneColor],
	colorAttachments: [{
		target: backbuffer,
		loadOp: 'clear',
		storeOp: 'store',
	}],
	encode({ pass, unwrap }) {
		const sceneColorView = unwrap(sampledSceneColor);
		pass.setPipeline(presentPipeline);
		pass.setBindGroup(0, createPresentBindGroup(sceneColorView));
		pass.draw(3);
	},
});

Resolved transient objects are callback-scoped. Do not cache them across callbacks or frames. The complete transient-to-present.ts recipe shows the pipeline and bind-group setup without placeholder helpers.

Resource and integration choices

Declaration granularity is optional: complex workloads can keep private weights, parameters, and scratch internally bound, while teaching or diagnostic use can expose more resources. Graph-visible dependencies and access correctness still apply. See Choosing resource declaration granularity.

For resources exposed to the graph:

  • Create a transient resource when native storage is needed only for the compiled frame. Import a resource when the caller owns its native storage or it must survive execution.
  • Transient and surface contents begin undefined. The first write to a transient range must fully overwrite it. Use preserve for partial, conditional, sparse, or atomic writes.
  • Use structured render, compute, copy, and clear nodes whenever possible. Use command() for custom work on a FrameGraph-owned encoder.
  • Use externalSubmission() when a third-party renderer owns and submits its encoders. The node orders queue submissions but is not a GPU-completion fence.
  • Acquire, import, compile, execute, and present a fresh surface texture on each presentation frame.

See Core concepts for the complete ownership, content, dependency, lifetime, and integration model.

Diagnostics and Snapshot

Compilation reports, CPU/GPU timing, and pool statistics are opt-in and independent. Requesting them does not change the execution plan. Convert matching reports to the portable protocol through @zenfg/webgpu/snapshot; ordinary compile and execute paths do not create Snapshot data.

Snapshot export produces an in-memory value only. Capture naming, filesystem storage, transport, and retention policy remain caller-owned. The language- neutral wire contract is defined by the @zenfg/snapshot specification.

Use compiled.executeWithTiming({ frameIndex, timing: 'cpu' }), choosing 'cpu', 'gpu' or 'both'. The returned cpu report is immediately available; consume gpu as a Promise only when requested. Ordinary execute() remains synchronous and does not collect timing. CPU duration is elapsed time, not thread CPU usage, and covers every executed node kind. Keep compilation, frame identity and pool counters from the same execution when exporting.

Common mistakes

SymptomFix
A node disappears from executionRetain its final value with the correct root, or mark only genuine side effects as such.
A read or preserving write reports undefined contentsClear or fully overwrite the selected range first, or declare imported initial contents correctly.
The first transient write is rejectedUse overwrite only when the complete declared range is written.
unwrap() rejects a tokenList the same token in the active node's uses; never cross a recording boundary.
The same native object is imported twiceImport it once at the composition boundary and share the logical handle.
A later presentation frame failsDo not reuse an old current texture or compiled presentation frame.
Work after an external node starts too earlyQueue all declared external work on the shared device queue before the callback returns.
Timing is unavailableTreat unsupported, busy, and readback-failed as non-fatal results.

Complete recipes

The following files are published with the package and type-checked as consumers of supported public entrypoints:

WorkflowRecipe
Minimal presentation lifecycleminimal-frame.ts
Transient render target to presentationtransient-to-present.ts
Caller-owned imported resourceimported-resource.ts
Cross-frame persistent statepersistent-state.ts
Opaque third-party submissionexternal-submission.ts
Portable Snapshot exportsnapshot-export.ts
Asynchronous GPU timinggpu-timing.ts
Compute storage outputcompute-output.ts

GPU-dependent recipes are compile-checked. CPU-only workflows also execute in CI. See the examples index for their input contracts.

Further reading

Documentation and versions

This README describes @zenfg/webgpu 0.1.0. Registry badges show the current published channel, not your installed version.

Open source / MIT licensed