forked from Misaki/GhostEngine
Implemented a transient render graph system as a proof of concept, including resource aliasing, pass culling, and typed pass data. Added new project `Ghost.RenderGraph.Concept` targeting `.NET 10.0`. Refactored graphics-related components: - Simplified resource state transitions in `RenderingContext`. - Improved resize handling in `GraphicsTestWindow`. - Updated `D3D12GraphicsEngine` to streamline frame rendering. - Enhanced `D3D12ResourceDatabase` and `D3D12SwapChain` for better resource management. Added detailed documentation: - `ALIASING.md` explains resource aliasing techniques. - `API_DESIGN.md` outlines the render graph API design. Updated solution to include the new render graph project.
6.5 KiB
6.5 KiB
Render Graph API Design Summary
Overview
This render graph implementation uses a production-grade API design inspired by Unity HDRP's render graph, focusing on performance and usability.
Core Design Principles
1. Typed Pass Data > String Lookups
❌ Anti-pattern (slow, error-prone):
builder.SetRenderFunc(cmd => {
cmd.SetRenderTarget("GBuffer.Albedo"); // String lookup!
});
✅ Best practice (fast, type-safe):
builder.SetRenderFunc((data, cmd) => {
cmd.SetRenderTarget(data.Albedo.Name); // Direct field access!
});
2. Blackboard for Complex Data
When multiple passes need the same resources (like GBuffer with albedo, normal, depth):
class GBufferData {
public RenderGraphTextureHandle Albedo = null!;
public RenderGraphTextureHandle Normal = null!;
public RenderGraphTextureHandle Depth = null!;
}
// Producer pass
using (var builder = renderGraph.AddRenderPass<GBufferData>("GBuffer", out var data)) {
data.Albedo = builder.WriteTexture(builder.CreateTexture(...));
data.Normal = builder.WriteTexture(builder.CreateTexture(...));
data.Depth = builder.UseDepthBuffer(builder.CreateTexture(...), true);
builder.SetRenderFunc((d, cmd) => { /* use d.Albedo, d.Normal, d.Depth */ });
}
renderGraph.Blackboard.Add(gbufferData);
// Consumer passes
using (var builder = renderGraph.AddRenderPass<LightingData>("Lighting", out var data)) {
var gbuffer = renderGraph.Blackboard.Get<GBufferData>();
data.Albedo = builder.ReadTexture(gbuffer.Albedo);
data.Normal = builder.ReadTexture(gbuffer.Normal);
// ...
}
3. Direct Handle Passing for Simple Cases
When passing a single texture between two passes:
// Pass 1: Return handle
RenderGraphTextureHandle lightingOutput;
using (var builder = renderGraph.AddRenderPass<LightingData>("Lighting", out var data)) {
lightingOutput = builder.CreateTexture(...);
data.Output = builder.WriteTexture(lightingOutput);
builder.SetRenderFunc((d, cmd) => { /* ... */ });
}
// Pass 2: Use handle directly
using (var builder = renderGraph.AddRenderPass<TAAData>("TAA", out var data)) {
data.Input = builder.ReadTexture(lightingOutput); // Direct pass!
builder.SetRenderFunc((d, cmd) => { /* ... */ });
}
Performance Benefits
| Aspect | Traditional String-Based | Typed Pass Data |
|---|---|---|
| Resource Access | Dictionary lookup | Direct field access |
| Type Safety | Runtime errors | Compile-time checks |
| Refactoring | Find & Replace | Compiler-assisted |
| IDE Support | Limited | Full IntelliSense |
| Performance | Hash lookup overhead | Zero overhead |
Real-World Example
Here's how a complete deferred rendering pipeline looks:
// 1. GBuffer Pass - produce multiple outputs
GBufferData gbufferData;
using (var builder = renderGraph.AddRenderPass<GBufferData>("GBuffer", out gbufferData)) {
gbufferData.Albedo = builder.WriteTexture(builder.CreateTexture(...));
gbufferData.Normal = builder.WriteTexture(builder.CreateTexture(...));
gbufferData.Depth = builder.UseDepthBuffer(builder.CreateTexture(...), true);
builder.SetRenderFunc((data, cmd) => { /* render geometry */ });
}
renderGraph.Blackboard.Add(gbufferData);
// 2. Lighting Pass - consume GBuffer, produce lighting
RenderGraphTextureHandle lightingOutput;
using (var builder = renderGraph.AddRenderPass<LightingData>("Lighting", out var data)) {
var gbuffer = renderGraph.Blackboard.Get<GBufferData>();
data.GBufferAlbedo = builder.ReadTexture(gbuffer.Albedo);
data.GBufferNormal = builder.ReadTexture(gbuffer.Normal);
data.GBufferDepth = builder.ReadTexture(gbuffer.Depth);
lightingOutput = builder.CreateTexture(...);
data.Output = builder.WriteTexture(lightingOutput);
builder.SetRenderFunc((data, cmd) => { /* deferred lighting */ });
}
// 3. SSAO Pass - also consume GBuffer
RenderGraphTextureHandle ssaoOutput;
using (var builder = renderGraph.AddRenderPass<SSAOData>("SSAO", out var data)) {
var gbuffer = renderGraph.Blackboard.Get<GBufferData>();
data.Depth = builder.ReadTexture(gbuffer.Depth);
data.Normal = builder.ReadTexture(gbuffer.Normal);
ssaoOutput = builder.CreateTexture(...);
data.Output = builder.WriteTexture(ssaoOutput);
builder.SetRenderFunc((data, cmd) => { /* SSAO */ });
}
// 4. Post Processing - combine lighting + SSAO
using (var builder = renderGraph.AddRenderPass<PostData>("Post", out var data)) {
data.Lighting = builder.ReadTexture(lightingOutput); // Direct handle
data.SSAO = builder.ReadTexture(ssaoOutput); // Direct handle
data.Output = builder.WriteTexture(backbuffer);
builder.SetRenderFunc((data, cmd) => { /* combine */ });
}
renderGraph.Compile();
renderGraph.Execute();
When to Use What?
| Scenario | Use | Example |
|---|---|---|
| Multiple outputs used by many passes | Blackboard | GBuffer (albedo, normal, depth) |
| Single texture passed to next pass | Direct Handle | Lighting → TAA |
| Temporary working data | Pass Data | Intermediate blur textures |
| Persistent frame data | Import | Backbuffer, history textures |
Comparison with Other Systems
Unity HDRP
// Unity's API (very similar!)
using (var builder = renderGraph.AddRenderPass<PassData>("MyPass", out var passData))
{
passData.input = builder.ReadTexture(inputHandle);
passData.output = builder.WriteTexture(outputHandle);
builder.SetRenderFunc((data, ctx) => { /* ... */ });
}
Unreal Engine 5 RDG
// Unreal's API (similar concepts, different syntax)
FRDGTextureRef Output = GraphBuilder.CreateTexture(Desc, TEXT("Output"));
AddPass(GraphBuilder, "MyPass", Parameters,
[Parameters](FRHICommandList& RHICmdList) {
// Execute
});
Frostbite
// Frostbite (original frame graph paper)
FrameGraphTextureHandle output = frameGraph.create("Output", desc);
frameGraph.addPass("MyPass",
[&](FrameGraphBuilder& builder) {
builder.write(output);
},
[=](const Resources& resources, CommandBuffer& cmd) {
// Execute
});
Conclusion
This API design prioritizes:
- Performance: Zero-cost abstractions with direct field access
- Safety: Compile-time type checking
- Ergonomics: Natural C# patterns (using blocks, typed data)
- Flexibility: Blackboard for complex data, handles for simple cases
It matches industry-standard patterns from Unity, Unreal, and Frostbite while leveraging C#'s type system for maximum safety and performance.