Skip to content

Render Graph

The render graph is a frame graph that declaratively describes the rendering work for a frame. Instead of issuing draw calls in arbitrary order, you declare passes and their resource dependencies; the graph compiles them into an optimized execution order.

Concepts

Resources

A resource is any GPU-side data a pass reads or writes:

TypeDescription
FramebufferRender target with one or more color/depth attachments
Texture2D / Texture3DSampled image resources
StorageBufferGPU-writable SSBO
LogicalBufferTyped CPU-side data shared between passes

Resources can be transient (allocated per-frame by the graph) or persistent (externally owned, imported into the graph).

Passes

A pass is a RenderPass subclass that implements:

cpp
void setup(Ref<RenderTarget> target,
           const std::vector<const RenderGraphResource*>& resources);
void begin(double dt);
void execute(double dt);
void end(double dt);
void cleanup();
std::string name() const;

setup() is called once when the graph is compiled. begin() / execute() / end() are called every frame in order.

Building a Render Graph

Use RenderGraphBuilder to declare resources and passes, then call build():

cpp
RenderGraphBuilder builder;

// Declare transient resources
uint32_t shadow_map = builder.declare_framebuffer(
    "shadow_map", 2048, 2048,
    FramebufferTextureFormat::DEPTH_ONLY
);

uint32_t scene_color = builder.declare_framebuffer(
    "scene_color", width, height,
    FramebufferTextureFormat::RGBA16F
);

// Import the window framebuffer as persistent
uint32_t output = builder.import_persistent_framebuffer(
    "output", render_target->framebuffer().get()
);

// Add passes with resource declarations
builder.add_pass(create_scope<ShadowPass>())
    .write(shadow_map)
    .end();

builder.add_pass(create_scope<GeometryPass>())
    .read(shadow_map)
    .write(scene_color)
    .end();

builder.add_pass(create_scope<SkyboxPass>())
    .write(scene_color)
    .end();

builder.add_pass(create_scope<PostProcessPass>())
    .read(scene_color)
    .write(output)
    .end();

// Compile
auto graph = builder.build();
graph->compile(render_target);

Compilation Steps

When compile() is called the graph runs the following steps in order:

  1. compute_resource_lifetimes - determines first_write_pass and last_read_pass for each resource.
  2. infer_dependencies - adds computed dependency edges between passes that share resources.
  3. topological_sort - produces a valid execution order respecting both declared and inferred dependencies.
  4. cull_passes - marks passes whose outputs are never read (dead passes) as culled.
  5. alias_resources - reuses framebuffer/storage-buffer memory for non-overlapping transient resources.
  6. create_transient_resources - allocates GPU memory for transient framebuffers and storage buffers.
  7. setup_passes - calls RenderPass::setup() on each active pass.

Exporting the Graph

The graph can be exported to Mermaid, Graphviz (DOT), or ASCII for debugging:

cpp
MermaidExporter exporter;
graph->export_graph(exporter, "render_graph.md");

Resource Access Modes

When adding a pass with PassBuilder, declare how each resource is accessed:

MethodMeaning
.read(idx)Pass only reads the resource
.write(idx)Pass writes (produces) the resource
.read_write(idx)Pass both reads and writes the resource

These declarations drive dependency inference and lifetime computation.

Logical Buffers

A logical buffer lets passes exchange typed CPU data without going through GPU memory:

cpp
struct LightData { glm::vec3 direction; float intensity; };

uint32_t light_buf = builder.declare_logical_buffer<LightData>("light_data");

// In one pass's setup/execute:
auto* data = static_cast<LightData*>(resource->get_logical_buffer());
data->direction = glm::vec3(0, -1, 0);

// In a downstream pass:
auto* data = static_cast<LightData*>(resource->get_logical_buffer());
use(data->direction);