Files
GhostEngine/Ghost.RenderGraph.Concept/API_DESIGN.md
Misaki 676f8bb74c Add render graph proof of concept and refactor graphics
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.
2025-12-01 22:31:17 +09:00

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:

  1. Performance: Zero-cost abstractions with direct field access
  2. Safety: Compile-time type checking
  3. Ergonomics: Natural C# patterns (using blocks, typed data)
  4. 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.