The Render Graph system in Aphrodite provides a high-level abstraction for GPU resource management and rendering pipeline construction. It automatically handles resource transitions, dependencies, and synchronization while allowing developers to focus on the logical structure of their rendering.
The system is built on a directed acyclic graph (DAG) where:
- Nodes represent rendering operations (passes)
- Edges represent resource dependencies between passes
graph TD
%% Frame composer and render graphs
FC[FrameComposer] --> RG0[RenderGraph 0]
FC --> RG1[RenderGraph 1]
%% Passes in RenderGraph 0
RG0 --> PA0[Pass A]
RG0 --> PB0[Pass B]
RG0 --> PC0[Pass C]
%% Passes in RenderGraph 1
RG1 --> PA1[Pass A]
RG1 --> PB1[Pass B]
%% Resources
subgraph Resources["Resources"]
direction TB
subgraph SharedRes["Shared Resources"]
direction LR
SR1[Texture]
SR2[Uniform Buffer]
end
subgraph PerFrameRes["Per-Frame Resources"]
direction LR
RT0[RenderTarget 0]
RT1[RenderTarget 1]
DB0[DepthBuffer 0]
DB1[DepthBuffer 1]
end
end
%% Dependencies through resources
PA0 -- "writes" --> RT0
PA0 -- "writes" --> DB0
PA0 -- "reads" --> SR1
PA0 -- "reads" --> SR2
PB0 -- "reads" --> RT0
PB0 -- "reads" --> DB0
PB0 -- "writes" --> RT0
PC0 -- "reads" --> SR1
PC0 -- "reads" --> RT0
PA1 -- "writes" --> RT1
PA1 -- "writes" --> DB1
PA1 -- "reads" --> SR1
PA1 -- "reads" --> SR2
PB1 -- "reads" --> RT1
PB1 -- "reads" --> DB1
%% Sequential dependencies
PA0 -. "executes before" .-> PB0
PB0 -. "executes before" .-> PC0
PA1 -. "executes before" .-> PB1
%% Styling
classDef composer fill:#3b82f6,stroke:#1e3a8a,color:#fff,stroke-width:2px
classDef renderGraph fill:#60a5fa,stroke:#1e3a8a,color:#000,stroke-width:1px
classDef pass fill:#fcd34d,stroke:#92400e,color:#000,stroke-width:1px
classDef sharedRes fill:#4ade80,stroke:#166534,color:#000,stroke-width:2px
classDef frameRes fill:#a5b4fc,stroke:#3730a3,color:#000,stroke-width:1px
classDef dependency stroke:#888,stroke-width:1px,stroke-dasharray:5 5
class FC composer
class RG0,RG1 renderGraph
class PA0,PB0,PC0,PA1,PB1 pass
class SR1,SR2 sharedRes
class RT0,RT1,DB0,DB1 frameRes
FrameComposer: Manages multiple render graphs with shared resources across framesRenderGraph: Orchestrates passes and resources within a single frameRenderPass: Represents a rendering operation with specific inputs and outputsPassResource: Abstract base class for resources managed by the graphPassImageResource: Image/texture resourcesPassBufferResource: Buffer resources
- Builder patterns for intuitive configuration:
RenderPass::Builderfor configuring render pass inputs/outputs
- Automatic resource state transitions and barriers
- Dependency analysis and topological sorting
- Command buffer recording and submission
- Resource sharing between multiple frames
- Shader loading with resource setup callbacks
- Powerful debugging capabilities
- Dry run mode for validation without GPU operations
- Support for conditional pass execution
The render graph manages two primary resource types:
| Resource Type | Class | Examples |
|---|---|---|
| Images | PassImageResource | Textures, render targets, depth buffers |
| Buffers | PassBufferResource | Vertex, index, uniform, and storage buffers |
Resources are automatically tracked and transitioned between states:
Undefined: Initial stateGeneral: General purpose accessRenderTarget: Used as a color attachmentDepthStencil: Used as a depth/stencil attachmentShaderResource: Used for sampling in shadersComputeGeneral: Used in compute shadersPresent: Ready for presentation
The render graph analyzes resource lifetimes to optimize memory usage:
- Resources are created when first needed
- Transient resources (only used within the graph) can be optimized
- Shared resources exist across multiple frames
- Resources are automatically cleaned up
Render passes define operations that read and write resources:
Passes can be created for different queue types:
Graphics: Rendering operationsCompute: Compute shader operationsTransfer: Memory transfer operations
Passes declare their resource usage through clear interfaces:
- Inputs: Resources read by the pass
- Textures
- Uniform buffers
- Storage buffers (read)
- Outputs: Resources written by the pass
- Color attachments
- Depth/stencil attachments
- Storage textures
- Storage buffers (write)
Passes can be organized into logical groups for better organization.
// Create a frame composer with frame count for buffering
aph::FrameComposerCreateInfo composerInfo{
.pDevice = m_pDevice,
.pResourceLoader = m_pResourceLoader,
.frameCount = 3 // Triple buffering
};
auto* composer = aph::FrameComposer::Create(composerInfo).value();
// Iterate through frames for setup
for (auto frameResource : composer->frames())
{
auto* graph = frameResource.pGraph;
// Configure graph for this frame...
}
// Build all graphs with shared resources
composer->buildAllGraphs(m_pSwapChain);
// In render loop - get current frame's graph and advance
auto frameResource = composer->nextFrame();
auto* graph = frameResource.pGraph;
// Execute the current frame
graph->execute();// Create a simple render pass
auto* renderPass = pRenderGraph->createPass("main render", aph::QueueType::Graphics);
// Create a pass in a logical group
auto renderGroup = pRenderGraph->createPassGroup("Deferred");
auto* geometryPass = renderGroup.addPass("geometry pass", aph::QueueType::Graphics);
auto* lightingPass = renderGroup.addPass("lighting pass", aph::QueueType::Graphics);// Configure a pass using the builder pattern
drawPass->configure()
.colorOutput("render output", {.createInfo = renderTargetColorInfo})
.depthOutput("depth buffer", {.createInfo = renderTargetDepthInfo})
// Shared resources across frames
.sharedTextureInput("albedo", {.debugName = "albedo texture",
.data = "texture://textures/albedo.png"})
.sharedBufferInput("matrix ubo", {.debugName = "matrix data",
.data = &m_mvp,
.dataSize = sizeof(m_mvp),
.createInfo = {
.size = sizeof(m_mvp),
.usage = aph::BufferUsage::Uniform,
.domain = aph::MemoryDomain::Host,
}})
// Add shader with resource setup callback
.shader("main_program",
aph::ShaderLoadInfo{.debugName = "main shader",
.data = {"shader://main.slang"},
.stageInfo = {{aph::ShaderStage::VS, "vertMain"},
{aph::ShaderStage::FS, "fragMain"}}},
[this]() {
// Resource setup after loading but before execution
// Set up bindless resources, etc.
})
.build();// Access shared resources from composer
auto* textureAsset = composer->getSharedResource<aph::vk::Image>("albedo");
auto* bufferAsset = composer->getSharedResource<aph::vk::Buffer>("matrix ubo");
// Update shared resource
auto mvpBuffer = composer->getSharedResource<aph::vk::Buffer>("matrix ubo");
m_pResourceLoader->update({.data = &m_mvp, .range = {0, sizeof(m_mvp)}}, mvpBuffer);// Record commands for a pass
renderPass->recordExecute([this](auto* pCmd) {
// Set rendering state
pCmd->setDepthState({
.enable = true,
.write = true,
.compareOp = aph::CompareOp::Less,
});
// Add debug labels
pCmd->beginDebugLabel({
.name = "main rendering",
.color = {0.5f, 0.3f, 0.2f, 1.0f},
});
// Draw with specific shader
pCmd->setProgram(m_pProgram->getProgram());
pCmd->draw(aph::DispatchArguments{1, 1, 1});
pCmd->endDebugLabel();
});
// Push commands for a specific shader
renderPass->pushCommands("main_program", [](auto* pCmd) {
// Shader-specific commands
pCmd->draw(aph::DispatchArguments{1, 1, 1});
});// Set a condition for pass execution
debugPass->setExecutionCondition([this]() {
return m_showDebugOverlay;
});
// Explicitly cull a pass
occlusionPass->setCulled(m_disableOcclusion);// Build the graph and resolve dependencies
pRenderGraph->build(pSwapChain);
// Execute the graph
pRenderGraph->execute();
// Get a fence for CPU/GPU synchronization
vk::Fence* pFrameFence = nullptr;
pRenderGraph->execute(&pFrameFence);// Clean up when done
aph::FrameComposer::Destroy(pComposer);// Import an existing buffer
vk::Buffer* pExternalBuffer = /* existing buffer */;
renderPass->addBufferIn("external buffer", pExternalBuffer, aph::BufferUsage::Uniform);
// Import an existing image
vk::Image* pExternalImage = /* existing image */;
renderPass->addTextureIn("external texture", pExternalImage);// Set the back buffer for presentation
pRenderGraph->setBackBuffer("final color");// Export the graph structure to GraphViz format
std::string dotGraph = pRenderGraph->exportToGraphviz();
std::ofstream outFile("render_graph.dot");
outFile << dotGraph;The dry run mode allows you to validate, analyze, and visualize your render graph without executing any GPU operations. This is invaluable for debugging, architecture planning, and documentation.
// Create a graph in dry run mode for validation
auto dryRunResult = aph::RenderGraph::CreateDryRun();
APH_VERIFY_RESULT(dryRunResult);
aph::RenderGraph* pDryRunGraph = dryRunResult.value();
// Enable detailed logging of graph operations
pDryRunGraph->enableDebugOutput(true);- Pipeline Design: Plan and validate the structure of complex rendering pipelines
- Dependency Analysis: Identify and understand the dependencies between rendering passes
- Documentation: Generate visualizations of your rendering architecture
- Testing: Validate graph structure changes without GPU overhead
- Education: Demonstrate rendering concepts without requiring GPU access
void setupRenderGraph()
{
// Set up the render graph for each frame resource
for (auto frameResource : m_pFrameComposer->frames())
{
auto* graph = frameResource.pGraph;
// Create descriptions for color and depth attachments
aph::vk::ImageCreateInfo renderTargetColorInfo{
.extent = {m_pSwapChain->getWidth(), m_pSwapChain->getHeight(), 1},
.format = m_pSwapChain->getFormat(),
};
aph::vk::ImageCreateInfo renderTargetDepthInfo{
.extent = {m_pSwapChain->getWidth(), m_pSwapChain->getHeight(), 1},
.format = aph::Format::D32,
};
// Create a render pass group for main rendering
auto renderGroup = graph->createPassGroup("MainRender");
// Create and configure drawing pass using the builder pattern
auto* drawPass = renderGroup.addPass("drawing cube", aph::QueueType::Graphics);
drawPass->configure()
.colorOutput("render output", {.createInfo = renderTargetColorInfo})
.depthOutput("depth buffer", {.createInfo = renderTargetDepthInfo})
.sharedTextureInput("container texture", {.debugName = "container texture",
.data = "texture://container2.png"})
.sharedBufferInput("matrix ubo", {.debugName = "matrix data",
.data = &m_mvp,
.dataSize = sizeof(m_mvp)})
.shader("bindless_mesh_program", loadInfo, [this]() {
// Register resources with the bindless system after loading
auto bindless = m_pDevice->getBindlessResource();
bindless->updateResource(textureAsset->getImage(), "texture_container");
bindless->updateResource(mvpBufferAsset->getBuffer(), "transform_cube");
})
.build();
// Set the output buffer for display
graph->setBackBuffer("render output");
}
// Build all graphs with shared resources
m_pFrameComposer->buildAllGraphs(m_pSwapChain);
}
void buildGraph(aph::RenderGraph* pGraph)
{
auto drawPass = pGraph->getPass("drawing cube");
drawPass->pushCommands("bindless_mesh_program",
[](auto* pCmd) {
pCmd->setDepthState({.enable = true, .write = true});
pCmd->draw(aph::DispatchArguments{1, 1, 1});
});
pGraph->build(m_pSwapChain);
}
void loop()
{
for (auto frameResource : m_pEngine->loop())
{
// Update shared resources
auto mvpBuffer = m_pFrameComposer->getSharedResource<aph::vk::Buffer>("matrix ubo");
m_pResourceLoader->update({.data = &m_mvp}, mvpBuffer);
// Build the render graph for this frame
buildGraph(frameResource.pGraph);
}
}- Use FrameComposer: For multi-frame applications, use FrameComposer to manage resources across frames
- Share Stable Resources: Mark resources that don’t change per-frame as shared to reduce memory use
- Logical Organization: Group related passes together using PassGroups
- Resource Naming: Use clear, descriptive names for resources to aid debugging
- Minimize Dependencies: Design passes to minimize cross-dependencies
- Conditional Execution: Use setExecutionCondition for optimal performance
- Validation: Use dry run mode to validate graph structure before GPU execution
- Debug Labels: Add debug labels to command buffers for better profiling
The render graph performs topological sorting to determine execution order:
- Analyze resource dependencies between passes
- Build a directed graph where edges represent dependencies
- Perform Kahn’s algorithm for topological sorting
- Detect and report cycles in the dependency graph
The system automatically inserts appropriate barriers:
- Track current resource states
- Generate barriers when state transitions are needed
- Combine barriers when possible for better performance
Resources are managed efficiently:
- External resources are imported without ownership
- Internal resources are created and owned by the graph
- Transient resources are optimized for better memory usage
- Shared resources are maintained across multiple frames
The FrameComposer handles resource sharing with these steps:
- Resources are marked as shared via sharedTextureInput/sharedBufferInput methods
- During build, shared resources are loaded once
- Resource references are distributed to all per-frame render graphs
- Updates to shared resources affect all frames
## RenderGraphComposer
The `RenderGraphComposer` is a utility for managing multiple `RenderGraph` instances with shared resources between frames. It provides a simplified interface for multi-frame rendering and automatic resource sharing.
### Usage Example
“`cpp // Create a composer with the device and resource loader aph::RenderGraphComposerCreateInfo composerInfo{ .pDevice = m_pDevice, .pResourceLoader = m_pResourceLoader, .frameCount = 3 // Triple buffering };
auto* composer = aph::RenderGraphComposer::Create(composerInfo).value();
// Setup all graphs with shared/non-shared resources for (auto frameResource : composer->frames()) { auto* graph = frameResource.pGraph;
// Create common render passes auto* drawPass = graph->createPass(“drawPass”, aph::QueueType::Graphics);
// Configure with shared texture - this will be loaded once and shared across frames drawPass->configure() .sharedTextureInput(“albedo”, {.debugName = “albedo texture”, .data = “texture://textures/albedo.png”}) .sharedTextureInput(“normal”, {.debugName = “normal texture”, .data = “texture://textures/normal.png”}) // Per-frame resources (default behavior) .colorOutput(“render output”, {.createInfo = colorInfo}) .build();
// Set back buffer graph->setBackBuffer(“render output”); }
// Build all graphs composer->buildAllGraphs(m_pSwapChain);
// Access shared resources directly // when using sharedTextureInput/sharedBufferInput auto* albedoTexture = composer->getSharedResourceaph::vk::Image(“albedo”); if (composer->hasSharedResourceaph::vk::Image(“normal”)) { auto* normalTexture = composer->getSharedResourceaph::vk::Image(“normal”); // Use shared textures… }
// In render loop while (running) { // Get the current frame’s graph and advance to next frame uint32_t frameIndex = composer->nextFrame(); auto* graph = composer->getCurrentGraph();
// Execute the current frame graph->execute(); }
// Cleanup aph::RenderGraphComposer::Destroy(composer); “`
### Benefits
- **Resource Deduplication** - Automatically shares resources across frames when marked as shared
- **Frame Management** - Handles creation and management of per-frame RenderGraph instances
- **Simplified API** - Makes multi-frame rendering easier through a centralized API
- **Clear Ownership** - Resources are properly managed with explicit sharing semantics
- **Compatibility** - Works with existing RenderGraph API
## RenderGraph