Skip to content

Render graph: configurable DAG of render passes #73

@brendancol

Description

@brendancol

Context

Roadmap Phase 2 item (see #57). The current render pipeline is a fixed sequence hardcoded in _update_frame() and analysis/render.py. Adding or removing passes (AO, denoiser, DOF) requires editing control flow and managing buffer dependencies manually. A render graph makes the pipeline declarative and extensible.

Goal

A lightweight DAG of render passes that:

  • Declares inputs/outputs per pass (buffer names + formats)
  • Auto-resolves execution order from data dependencies
  • Allocates/reuses GPU buffers based on lifetime analysis
  • Makes it trivial to add, remove, or reorder passes without touching other passes

Proposed Passes

GBuffer → Shadow → AO → GI → Denoise → Tonemap → Composite
Pass Inputs Outputs
GBuffer scene (OptiX launch) albedo, normal, depth, position, material_id
Shadow position, normal, sun_dir shadow_mask
AO position, normal, depth ao_map
GI position, normal, albedo, shadow_mask indirect_light
Denoise color, albedo, normal denoised_color
Tonemap denoised_color ldr_color
Composite ldr_color, overlays, HUD final_framebuffer

Design

Pass Interface

class RenderPass:
    name: str
    inputs: dict[str, BufferDesc]   # name → (dtype, channels)
    outputs: dict[str, BufferDesc]
    enabled: bool = True

    def setup(self, graph: RenderGraph) -> None: ...
    def execute(self, buffers: dict[str, DeviceBuffer]) -> None: ...
    def teardown(self) -> None: ...

Graph

class RenderGraph:
    def add_pass(self, pass_: RenderPass) -> None: ...
    def remove_pass(self, name: str) -> None: ...
    def compile(self) -> list[RenderPass]:
        """Topological sort, buffer lifetime analysis, allocation plan."""
    def execute(self) -> None:
        """Run compiled pass order, managing buffers."""

Capability Gating

Passes declare required capabilities (e.g., requires=["optix_denoiser"]). The graph skips passes whose requirements aren't met, with fallback wiring (e.g., if Denoise is skipped, Tonemap reads raw color instead of denoised_color).

Implementation Plan

  1. Define RenderPass base class and RenderGraph container — topological sort, cycle detection, buffer descriptor registry
  2. Extract GBuffer pass — wrap existing OptiX launch in a GBufferPass, output named buffers instead of ad-hoc CuPy arrays
  3. Extract Shadow and AO passes — already computed in the kernel; split into separate launches or keep fused with GBuffer and expose as outputs
  4. Extract Denoise pass — wrap existing OptiX denoiser call
  5. Extract Tonemap pass — color stretching, gamma, exposure currently in _apply_post_processing()
  6. Extract Composite pass — overlay blending, HUD rendering, minimap
  7. Buffer lifetime analysis — track first-use/last-use per buffer, reuse allocations where lifetimes don't overlap
  8. Capability-gated pass skipping — query get_capabilities(), wire fallback connections
  9. Validation — detect missing inputs, warn on unused outputs, cycle detection

Scope Boundaries

  • No multi-GPU pass distribution (single device)
  • No async pass overlap initially (sequential execution, async is a future optimization)
  • GBuffer pass may remain fused (single OptiX launch producing multiple outputs) rather than separate geometry/material passes — splitting the kernel is not worth the extra launch overhead at this stage

References

  • Current render pipeline: analysis/render.py (render() function), engine.py (_update_frame(), _apply_post_processing())
  • OptiX denoiser integration: engine.py denoiser setup
  • Capability detection: rtx.py:get_capabilities()

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions