zenfg usage
zenfg is a renderer-agnostic FrameGraph compiler and transient-resource executor for wgpu. It records logical resources and accesses, validates content flow, builds dependencies, culls dead work, derives usage, plans transient aliasing, and optionally materializes retained work on a caller-owned device and queue.
Build domain-specific rendering and compute with wgpu while keeping explicit resource and execution boundaries. See the composition model.
ZenFG does not own scenes, pipelines, bind groups, samplers, surfaces, presentation, or device-loss policy. Public APIs may change before 1.0; pin exact package versions and review migration notes while integrating.
Features
| Feature | Default | Adds |
|---|---|---|
default | Yes | Empty feature set; the core compiler and device-backed executor are always available |
serde | No | Serde support for current-version runtime compilation reports; use Snapshot for portable exchange |
snapshot | No | zenfg-snapshot, wire-type re-exports, and portable report export |
FrameGraph::new() is CPU-only. FrameGraph::with_device() stores a cloned wgpu::Device, owns a transient pool, and enables execution. The queue and all imported resources remain caller-owned.
Quick start
Use a Cargo application with the Rust toolchain declared in the included Cargo.toml. Place this code inside fn main() -> Result<(), zenfg::FrameGraphError> and finish with Ok(()). Run cargo run; the assertion succeeds without requiring a GPU. Lines prefixed with # in this Markdown are rustdoc test scaffolding, not lines to paste.
This complete CPU-only example records one transient output, retains it, and compiles a diagnostic plan:
use zenfg::{
BufferDesc, BufferRange, CompileOptions, FrameGraph, RootReason,
UsagePolicy, WriteContents,
};
let mut graph = FrameGraph::new();
let mut frame = graph.begin_frame();
let output = frame.create_buffer(BufferDesc {
label: "output".into(),
size: 1024,
usage: UsagePolicy::Infer,
})?;
let mut pass = frame.compute_pass("produce");
let _output = pass.storage_buffer_write(
output,
BufferRange::whole(),
WriteContents::Overwrite,
)?;
pass.finish()?;
frame.mark_buffer_root(output, BufferRange::whole(), RootReason::Output)?;
let compiled = frame.compile(CompileOptions::full_report())?;
assert_eq!(compiled.report().unwrap().summary.retained_node_count, 1);Lifecycle
FrameGraph -> Frame<'frame> -> CompiledFrame<'frame> -> execute(queue)
runtime recording retained CPU plan optional, one-shotbegin_frame()exclusively borrows the runtime and creates one recording.Frame::compile()consumes the recording. Handles and access tokens carry runtime and recording identities enforced by the API and validation.CompiledFrame::execute()is one-shot. Native bindings and callbacks needed only by culled work are released after compilation.- Surface acquisition and presentation remain caller-owned; import and bind a fresh current surface texture for each presentation frame.
- Dropping
FrameGraphreleases retained pool and profiler resources, but not caller-owned imported resources.
Diagnostic grouping
Debug groups annotate declarations and passes for reports and Inspector. Recorded pass paths can emit optional native GPU markers. Groups do not establish dependencies, retain work, own native storage, or isolate allocations. Resource-pool scopes serve a separate purpose.
- Group coherent features and useful phases with shallow, meaningful names. Atmosphere may cover sky, clouds, haze and lens flare without absorbing Water.
- Keep feature-local graph-visible declarations and passes together where their correct recording order permits. Import shared storage once per recording at the composition boundary and reuse its handle across groups.
- Preserve recording and submission order. Interleaved features can use semantic phase labels such as
atmosphere.environment,atmosphere.sky,atmosphere.air-hazeandatmosphere.lens-flare; these are examples, not required names. - Bloom benefits from an outer
bloomgroup and optionaldownsample/upsamplephases for its repeated passes. Label passes with their real mip/iteration numbers rather than nesting one group per mip. Meaningful iterations may be numbered. - Use synchronous
with_debug_group()or balanced manual push/pop calls; close groups before compilation. Each push/with creates a new group. A returnedDebugGroupIdcannot be passed back to reopen it; duplicate labels remain distinct, and dotted labels are not hierarchical. - Resource attribution is captured at registration; a pass captures its group when its
PassBuilderis created. Cross-group use does not move the declaration or change storage ownership or recording-local handle validity.
Bloom grouping adapter
The caller's closures record the actual passes in their valid order, with specific labels such as bloom.downsample.mip-2 and bloom.upsample.mip-1. This adapter supplies only the diagnostic hierarchy. Use it when the downsample and upsample stages are contiguous in the existing plan; otherwise group the semantic segments at their original call sites. Shared input handles can remain outside these groups; declare local intermediates at their appropriate recording points.
use zenfg::{Frame, FrameGraphError};
pub fn record_bloom_groups<'frame>(
frame: &mut Frame<'frame>,
downsample: impl FnOnce(&mut Frame<'frame>) -> Result<(), FrameGraphError>,
upsample: impl FnOnce(&mut Frame<'frame>) -> Result<(), FrameGraphError>,
) -> Result<(), FrameGraphError> {
frame.with_debug_group("bloom", |frame| {
frame.with_debug_group("downsample", downsample)?;
frame.with_debug_group("upsample", upsample)
})
}The wire protocol can describe non-contiguous group references, and execution may reopen native markers on successive graph-owned command segments. Neither provides recording-time re-entry. Native markers are opt-in through ExecutionOptions::with_gpu_debug_groups(true).
Validation Boundary
ZenFG validates graph semantics: logical resource ranges, content flow, usage contracts, format categories, and bounded planning inputs. Non-empty buffer-texture copies also require a known format footprint so ZenFG can track the buffer byte range used by the copy. Device features, device limits, native descriptor validity, render-pass compatibility, texture-view compatibility, and native copy-command alignment and format validity remain wgpu responsibilities. Native wgpu validation may therefore be reported during allocation or command encoding rather than during graph recording. The graph does not take ownership of the caller's error handling or device-loss policy.
ZenFG validates device-independent texture shape rules and structural usage requirements for retained transient allocations. It preserves wgpu's format/backend-dependent multisample support rather than imposing the browser WebGPU sample-count set. TRANSIENT_ATTACHMENT is currently rejected because Snapshot 1.2 does not encode that usage; it is distinct from ZenFG's transient resource lifetime policy.
Zero-sized copies are retained as native no-op commands when their node is retained, but they have no graph content effect and cannot produce an output value.
Common tasks
| Task | Public API |
|---|---|
| Create a CPU-only compiler | FrameGraph::new() |
| Create a device-backed runtime | FrameGraph::with_device() |
| Annotate diagnostic groups | push_debug_group(), pop_debug_group(), with_debug_group() |
| Start a recording | begin_frame() |
| Create transient storage | create_texture(), create_buffer() |
| Register imported storage | import_texture(), import_surface_texture(), import_buffer() |
| Bind imported native objects | bind_imported_texture(), bind_imported_buffer() |
| Select texture subresources | create_texture_view() |
| Record render or compute work | render_pass() / finish_render(), compute_pass() / finish_compute() |
| Record copies or clears | copy_pass() with typed copy methods, clear_buffer(), clear_buffers() |
| Encode custom graph-owned commands | command_pass() / finish_command() |
| Call a renderer that submits itself | external_submission() / finish_external() |
| Retain observable values | mark_present(), mark_buffer_root(), mark_texture_root(), mark_readback() |
| Compile compact or full diagnostics | compile(CompileOptions::default()), compile(CompileOptions::full_report()) |
| Execute retained work | execute(), execute_with_options() |
| Request CPU/GPU timing | execute_with_timing() |
| Inspect or manage retained allocations | resource_pool_stats(), create_resource_pool_scope(), clear_resource_pool_scope(), clear_resource_pool(), evict_resource_pool() |
| Export Snapshot 1.2 | snapshot::create_frame_graph_snapshot() with feature snapshot |
Exact signatures, fields, defaults, and structured FGxxxx errors are documented on docs.rs.
Key pattern: bind and resolve typed access
Imported storage is declared logically, then bound to a caller-owned native object. Transient and imported storage resolve through the same typed pass tokens:
use zenfg::{
BufferDesc, BufferRange, CompileOptions, FrameGraph, ImportBufferOptions,
InitialContents,
};
let mut graph = FrameGraph::with_device(device);
let mut frame = graph.begin_frame();
let buffer = frame.import_buffer(
BufferDesc::new("input", native.size()),
ImportBufferOptions::new(InitialContents::Defined),
)?;
frame.bind_imported_buffer(buffer, native)?;
let mut pass = frame.compute_pass("consume");
let input = pass.storage_buffer_read(buffer, BufferRange::whole())?;
pass.finish_compute(move |ctx| {
let _native = ctx.resources.buffer(input)?;
// Set a compute pipeline and dispatch through ctx.pass.
Ok(())
})?;
frame.compile(CompileOptions::default())?.execute(queue)?;Resolved transient objects are valid only inside their execution callback. Imported objects remain caller-owned, but every graph-visible access still needs a matching declaration. Tokens from another graph or recording return ForeignHandle before local pass/access IDs are checked. Tokens from another pass in the same recording continue to return WrongPassToken.
Resource and integration choices
Allocation ownership and graph visibility are separate: importing storage borrows caller-owned native objects for tracking without transferring ownership.
| Need | Typical choice |
|---|---|
| Frame-local allocation and pooling | Declare transient Bloom intermediates, G-buffer attachments or graph-managed compute scratch. |
| Cross-node data flow, persistent state, output or readback | Register resources and declare the required ranges/accesses. Import and bind caller-owned history, scene storage or staging buffers; add the appropriate roots. |
| Private storage with no useful graph dependency or management role | Read-only cached vertex/index buffers, material textures, weights or workload-internal scratch may remain module-owned and internally bound. The caller owns initialization, lifetime and safe internal execution. |
| CPU or native binding state | Scene/camera objects, pipelines, bind groups and samplers stay application-owned; they are not graph textures or buffers. |
Long-lived storage is not automatically outside the graph. A persistent buffer written by one graph pass and consumed by another needs the relevant visible accesses, including vertex_buffer()/index_buffer() where applicable. Register shared imports once per recording, bind their native objects, and pass the logical handle to consumers. Private native bindings are not scanned for dependencies. Use typed access declarations at graph-visible boundaries of command/external passes; external submission orders shared-queue work, not GPU completion. See resource declaration granularity.
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:
- Use transient resources for storage needed only by one compiled execution; import storage that the caller owns or that must survive execution.
- Imported resources explicitly choose
InitialContents::DefinedorInitialContents::Undefined. The first write to a transient range must fully overwrite it. - Texture-to-buffer copies preserve their tracked buffer range when actual row or layer padding remains unwritten. Initialize undefined destination contents before such a copy; unused single-row or single-layer strides do not require preservation. A whole-buffer clear uses the logical descriptor size.
- Prefer structured render, compute, copy, and clear nodes. Use command passes for custom work on a graph-owned encoder.
- Use external submissions for renderers that own and submit their encoders. The boundary orders queue submissions but is not a GPU-completion fence.
- ZenFG performs no cross-frame dependency analysis and never acquires or presents a surface for the application.
GPU timing uses two queries per retained render/compute pass. More than 2048 timed passes returns GpuTimingUnavailableReason::TooManyTimedNodes before creating timing resources. Execution still proceeds, and TimingMode::Both keeps CPU results.
See Core concepts for the shared ownership, content, dependency, lifetime, and integration model.
Common mistakes
| Symptom | Fix |
|---|---|
| A pass is absent from the compiled plan | Retain its final value with the appropriate root, or declare only genuine side effects. |
| A read or preserving write reports undefined contents | Overwrite the complete range first or choose the correct imported initial contents. |
| An imported resource cannot execute | Bind the matching native object and ensure descriptor and usage metadata agree. |
| A typed token fails to resolve | Resolve it only through the pass that declared it and only inside that pass's callback. |
| A transient object is used later | Never clone or retain resolved transient wgpu handles across callbacks or frames. |
| External work is incorrectly ordered | Submit all declared work on the shared queue before the external callback returns. |
| Timing is unavailable | Treat unsupported, busy, readback failure, and overflow as non-fatal timing results. |
Complete examples
The following Cargo examples are published with the crate and compile-checked outside the workspace:
| Workflow | Example |
|---|---|
| Minimal presentation lifecycle | minimal_frame.rs |
| Transient render target to presentation | transient_to_present.rs |
| Caller-owned imported resource | imported_resource.rs |
| Cross-frame persistent state | persistent_state.rs |
| Opaque third-party submission | external_submission.rs |
| Portable Snapshot export | snapshot_export.rs |
| Asynchronous GPU timing | gpu_timing.rs |
| Compute storage output | compute_output.rs |
| Scoped resize cleanup and idle eviction | resource_pool_control.rs |
The repository also contains CPU-only compile and pool benchmarks. Snapshot export requires the snapshot feature.
Resource pool maintenance
Create one ResourcePoolScope before borrowing a frame and use frame.create_texture_in(scope, desc) or frame.create_buffer_in(scope, desc). The copyable identity has no resource-owning Drop or graph lifetime borrow. clear_resource_pool_scope(scope)? clears that partition's retained cache; it does not invalidate the identity. Foreign runtime scopes return ForeignResourcePoolScope even when the pool is empty. clear_resource_pool() still clears every domain.
evict_resource_pool accepts a non-stored FnMut predicate and a borrowed physical descriptor view. Select info.scope.is_none() for only the default domain or info.unused_executions > 60 for application idle policy. Each physical instance is visited once, without an ordering guarantee. Selection panic propagates; with unwinding it leaves the pool unchanged. External closure effects are not rolled back. No GPU handle, internal key or persistent instance ID is exposed. Management requires the frame/compiled-frame borrow to end and does not wait for GPU completion.
Run cargo run --locked -p zenfg --example resource-pool-control for the noop resize/eviction recipe. Core Concepts defines ownership, idle counts and failure boundaries.
Further reading
Execution timing
Use execute_with_timing(&queue, options, TimingMode::Cpu), TimingMode::Gpu, or TimingMode::Both. The result owns an optional synchronous CPU report and optional GPU readback. Poll the latter with try_take(); CPU-only execution never waits for a GPU result. Ordinary execution collects no timing. CPU is synchronous elapsed time for all executed node kinds, not thread CPU usage. Execution total includes preparation, submission and transient release.