Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions docs/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -517,6 +517,92 @@ Find which zarr chunks overlap a pixel-coordinate window.

---

## Render Graph

A configurable DAG of render passes. Declare inputs/outputs per pass, and the graph resolves execution order, manages GPU buffers, and gates passes on hardware capabilities.

```python
from rtxpy import RenderGraph, RenderPass, BufferDesc
```

### `BufferDesc(dtype='float32', channels=3, per_pixel=True)`

Describes a GPU buffer's shape and data type.

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `dtype` | str | `'float32'` | NumPy-compatible dtype |
| `channels` | int | `3` | Channels per element |
| `per_pixel` | bool | `True` | `True` = `(H, W, C)` shape; `False` = `(N, C)` flat |

#### `shape(width, height)`

**Returns:** `tuple[int, ...]` — concrete shape for the given resolution

### `RenderPass` (abstract base class)

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `name` | str | required | Unique pass name |
| `inputs` | dict[str, BufferDesc] | `{}` | Buffers this pass reads |
| `outputs` | dict[str, BufferDesc] | `{}` | Buffers this pass writes |
| `enabled` | bool | `True` | Set `False` to skip |
| `requires` | list[str] | `[]` | Capability keys that must be truthy |

Override `execute(buffers)` with your pass logic. Optionally override `setup(graph)` and `teardown()`.

### `RenderGraph(width=1920, height=1080)`

#### `add_pass(pass_)`

Add a render pass. Raises `ValueError` on duplicate names.

#### `remove_pass(name)`

Remove a pass by name. Raises `KeyError` if not found.

#### `get_pass(name)`

**Returns:** `RenderPass`

#### `set_fallback(buffer, fallback)`

If `buffer`'s producer is disabled, downstream passes read `fallback` instead.

```python
graph.set_fallback("denoised_color", "color")
```

#### `compile(capabilities=None, validate=True)`

Compile the graph: capability-gate passes, topological sort, buffer lifetime analysis.

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `capabilities` | dict | `None` | From `get_capabilities()` |
| `validate` | bool | `True` | Raise `GraphValidationError` on problems |

**Returns:** `CompiledGraph`

### `CompiledGraph`

#### `execute(external_buffers=None, allocator=None)`

Run all passes in dependency order.

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `external_buffers` | dict | `None` | Pre-allocated buffers to inject |
| `allocator` | callable | `None` | `(shape, dtype) -> array`; defaults to `cupy.zeros` |

**Returns:** `dict[str, array]` — all buffers after execution

### `GraphValidationError`

Raised on cycle detection, missing inputs, or other graph errors.

---

### Device Utilities

#### `get_device_count()`
Expand Down
57 changes: 57 additions & 0 deletions docs/user-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -535,6 +535,63 @@ verts, indices = dem.rtx.triangulate()
write_stl('terrain.stl', verts, indices)
```

## Render Graph

The render graph lets you define a pipeline of render passes as a DAG. Instead of editing one monolithic render function, you declare what each pass reads and writes. The graph handles execution order, buffer allocation, and capability gating.

```python
from rtxpy import RenderGraph, RenderPass, BufferDesc

rgb = BufferDesc(dtype="float32", channels=3, per_pixel=True)
scalar = BufferDesc(dtype="float32", channels=1, per_pixel=True)

class MyShadePass(RenderPass):
def __init__(self):
super().__init__("shade", outputs={"color": rgb})

def execute(self, buffers):
buffers["color"][:] = 0.5 # your shading logic here

class MyDenoisePass(RenderPass):
def __init__(self):
super().__init__(
"denoise",
inputs={"color": rgb},
outputs={"denoised_color": rgb},
requires=["optix_denoiser"],
)

def execute(self, buffers):
buffers["denoised_color"][:] = buffers["color"] # denoiser call here

class MyTonemapPass(RenderPass):
def __init__(self):
super().__init__(
"tonemap",
inputs={"denoised_color": rgb},
outputs={"final": rgb},
)

def execute(self, buffers):
buffers["final"][:] = buffers["denoised_color"] ** (1.0 / 2.2)

graph = RenderGraph(width=1920, height=1080)
graph.add_pass(MyShadePass())
graph.add_pass(MyDenoisePass())
graph.add_pass(MyTonemapPass())

# If denoiser unavailable, tonemap reads 'color' directly
graph.set_fallback("denoised_color", "color")

compiled = graph.compile(capabilities={"optix_denoiser": False})
result = compiled.execute()
# result["final"] contains the output image
```

The graph skips passes whose `requires` capabilities aren't present, wiring fallbacks so downstream passes still work. Buffer lifetime analysis reuses GPU memory when buffers don't overlap in time.

See the [API Reference](api-reference.md#render-graph) for the full interface.

## Performance Tips

- **Subsample large DEMs**: `dem[::2, ::2]` or `explore(subsample=4)` — 4x subsample is 16x less geometry
Expand Down
196 changes: 196 additions & 0 deletions examples/render_graph_demo.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
"""Render graph demo — build a multi-pass pipeline with capability gating.

Shows how to define custom render passes, wire them into a graph, and let the
graph handle execution order and fallback wiring when optional passes are
unavailable.

This example runs on CPU (numpy) and doesn't need a GPU.
"""

import numpy as np

from rtxpy import BufferDesc, RenderGraph, RenderPass

# Buffer descriptors for the pipeline
RGB = BufferDesc(dtype="float32", channels=3, per_pixel=True)
SCALAR = BufferDesc(dtype="float32", channels=1, per_pixel=True)


# --- Pass definitions --------------------------------------------------------


class GBufferPass(RenderPass):
"""Simulate a GBuffer pass that produces albedo, normals, and depth."""

def __init__(self):
super().__init__(
"gbuffer",
outputs={"albedo": RGB, "normal": RGB, "depth": SCALAR},
)

def execute(self, buffers):
h, w, _ = buffers["albedo"].shape
# Checkerboard albedo
yy, xx = np.mgrid[:h, :w]
checker = ((xx // 8 + yy // 8) % 2).astype(np.float32)
buffers["albedo"][:, :, 0] = 0.2 + 0.6 * checker
buffers["albedo"][:, :, 1] = 0.3 + 0.3 * checker
buffers["albedo"][:, :, 2] = 0.1 + 0.2 * (1 - checker)

# Upward-facing normals
buffers["normal"][:] = [0.0, 0.0, 1.0]

# Linear depth gradient
buffers["depth"][:, :] = np.linspace(0.0, 1.0, w, dtype=np.float32)


class ShadowPass(RenderPass):
"""Compute a simple shadow mask from depth."""

def __init__(self):
super().__init__(
"shadow",
inputs={"depth": SCALAR},
outputs={"shadow_mask": SCALAR},
)

def execute(self, buffers):
# Fake shadow: darker where depth > 0.5
buffers["shadow_mask"][:] = np.where(
buffers["depth"] > 0.5, 0.4, 1.0
).astype(np.float32)


class AOPass(RenderPass):
"""Fake ambient occlusion from depth edges."""

def __init__(self):
super().__init__(
"ao",
inputs={"depth": SCALAR},
outputs={"ao_map": SCALAR},
)

def execute(self, buffers):
depth = buffers["depth"]
# Approximate AO by depth variance in a 3x3 window
padded = np.pad(depth, ((1, 1), (1, 1)), mode="edge")
ao = np.ones_like(depth)
for dy in (-1, 0, 1):
for dx in (-1, 0, 1):
ao -= 0.02 * np.abs(
padded[1 + dy : depth.shape[0] + 1 + dy,
1 + dx : depth.shape[1] + 1 + dx]
- depth
)
buffers["ao_map"][:] = np.clip(ao, 0.3, 1.0)


class ShadePass(RenderPass):
"""Combine albedo, shadow, and AO into a lit color buffer."""

def __init__(self):
super().__init__(
"shade",
inputs={"albedo": RGB, "shadow_mask": SCALAR, "ao_map": SCALAR},
outputs={"color": RGB},
)

def execute(self, buffers):
albedo = buffers["albedo"]
shadow = buffers["shadow_mask"][:, :, np.newaxis]
ao = buffers["ao_map"][:, :, np.newaxis]
buffers["color"][:] = albedo * shadow * ao


class DenoisePass(RenderPass):
"""Placeholder denoiser — requires 'optix_denoiser' capability."""

def __init__(self):
super().__init__(
"denoise",
inputs={"color": RGB, "albedo": RGB, "normal": RGB},
outputs={"denoised_color": RGB},
requires=["optix_denoiser"],
)

def execute(self, buffers):
# Real implementation would call OptiX denoiser
buffers["denoised_color"][:] = buffers["color"]


class TonemapPass(RenderPass):
"""Simple Reinhard tone mapping."""

def __init__(self):
super().__init__(
"tonemap",
inputs={"denoised_color": RGB},
outputs={"ldr_color": RGB},
)

def execute(self, buffers):
hdr = buffers["denoised_color"]
buffers["ldr_color"][:] = hdr / (1.0 + hdr)


# --- Build and run the graph ------------------------------------------------


def main():
width, height = 128, 96

graph = RenderGraph(width=width, height=height)
graph.add_pass(GBufferPass())
graph.add_pass(ShadowPass())
graph.add_pass(AOPass())
graph.add_pass(ShadePass())
graph.add_pass(DenoisePass())
graph.add_pass(TonemapPass())

# If denoiser is unavailable, tonemap reads 'color' directly
graph.set_fallback("denoised_color", "color")

# --- Run without denoiser ---
print("Compiling graph WITHOUT denoiser capability...")
compiled = graph.compile(capabilities={})
print(f" Active passes: {[p.name for p in compiled.ordered_passes]}")
print(f" Buffer pool slots: {compiled.allocation_plan.num_slots}")

result = compiled.execute(
allocator=lambda shape, dtype: np.zeros(shape, dtype=dtype)
)
ldr = result["ldr_color"]
print(f" Output shape: {ldr.shape}, range: [{ldr.min():.3f}, {ldr.max():.3f}]")

# --- Run with denoiser ---
print("\nCompiling graph WITH denoiser capability...")
compiled2 = graph.compile(capabilities={"optix_denoiser": True})
print(f" Active passes: {[p.name for p in compiled2.ordered_passes]}")

result2 = compiled2.execute(
allocator=lambda shape, dtype: np.zeros(shape, dtype=dtype)
)
ldr2 = result2["ldr_color"]
print(f" Output shape: {ldr2.shape}, range: [{ldr2.min():.3f}, {ldr2.max():.3f}]")

# Save to PNG if matplotlib available
try:
import matplotlib.pyplot as plt

fig, axes = plt.subplots(1, 2, figsize=(10, 4))
axes[0].imshow(np.clip(result["ldr_color"], 0, 1))
axes[0].set_title("Without denoiser")
axes[0].axis("off")
axes[1].imshow(np.clip(result2["ldr_color"], 0, 1))
axes[1].set_title("With denoiser")
axes[1].axis("off")
plt.tight_layout()
plt.savefig("render_graph_demo.png", dpi=150)
print("\nSaved render_graph_demo.png")
except ImportError:
print("\nmatplotlib not available, skipping image save")


if __name__ == "__main__":
main()
8 changes: 8 additions & 0 deletions rtxpy/__init__.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
from .rtx import (
RTX,

Check failure on line 2 in rtxpy/__init__.py

View workflow job for this annotation

GitHub Actions / Lint & Import Check

ruff (F401)

rtxpy/__init__.py:2:5: F401 `.rtx.RTX` imported but unused; consider removing, adding to `__all__`, or using a redundant alias help: Use an explicit re-export: `RTX as RTX`
has_cupy,

Check failure on line 3 in rtxpy/__init__.py

View workflow job for this annotation

GitHub Actions / Lint & Import Check

ruff (F401)

rtxpy/__init__.py:3:5: F401 `.rtx.has_cupy` imported but unused; consider removing, adding to `__all__`, or using a redundant alias help: Use an explicit re-export: `has_cupy as has_cupy`
get_device_count,

Check failure on line 4 in rtxpy/__init__.py

View workflow job for this annotation

GitHub Actions / Lint & Import Check

ruff (F401)

rtxpy/__init__.py:4:5: F401 `.rtx.get_device_count` imported but unused; consider removing, adding to `__all__`, or using a redundant alias help: Use an explicit re-export: `get_device_count as get_device_count`
get_device_properties,

Check failure on line 5 in rtxpy/__init__.py

View workflow job for this annotation

GitHub Actions / Lint & Import Check

ruff (F401)

rtxpy/__init__.py:5:5: F401 `.rtx.get_device_properties` imported but unused; consider removing, adding to `__all__`, or using a redundant alias help: Use an explicit re-export: `get_device_properties as get_device_properties`
list_devices,

Check failure on line 6 in rtxpy/__init__.py

View workflow job for this annotation

GitHub Actions / Lint & Import Check

ruff (F401)

rtxpy/__init__.py:6:5: F401 `.rtx.list_devices` imported but unused; consider removing, adding to `__all__`, or using a redundant alias help: Use an explicit re-export: `list_devices as list_devices`
get_current_device,

Check failure on line 7 in rtxpy/__init__.py

View workflow job for this annotation

GitHub Actions / Lint & Import Check

ruff (F401)

rtxpy/__init__.py:7:5: F401 `.rtx.get_current_device` imported but unused; consider removing, adding to `__all__`, or using a redundant alias help: Use an explicit re-export: `get_current_device as get_current_device`
get_capabilities,

Check failure on line 8 in rtxpy/__init__.py

View workflow job for this annotation

GitHub Actions / Lint & Import Check

ruff (F401)

rtxpy/__init__.py:8:5: F401 `.rtx.get_capabilities` imported but unused; consider removing, adding to `__all__`, or using a redundant alias help: Use an explicit re-export: `get_capabilities as get_capabilities`
)
from .mesh import (
triangulate_terrain,

Check failure on line 11 in rtxpy/__init__.py

View workflow job for this annotation

GitHub Actions / Lint & Import Check

ruff (F401)

rtxpy/__init__.py:11:5: F401 `.mesh.triangulate_terrain` imported but unused; consider removing, adding to `__all__`, or using a redundant alias help: Use an explicit re-export: `triangulate_terrain as triangulate_terrain`
voxelate_terrain,

Check failure on line 12 in rtxpy/__init__.py

View workflow job for this annotation

GitHub Actions / Lint & Import Check

ruff (F401)

rtxpy/__init__.py:12:5: F401 `.mesh.voxelate_terrain` imported but unused; consider removing, adding to `__all__`, or using a redundant alias help: Use an explicit re-export: `voxelate_terrain as voxelate_terrain`
add_terrain_skirt,
build_terrain_skirt,
write_stl,
Expand All @@ -25,6 +25,14 @@
)
from .analysis import viewshed, hillshade, render, flyover, view
from .engine import explore
from .render_graph import (
BufferDesc,
RenderPass,
RenderGraph,
CompiledGraph,
AllocationPlan,
GraphValidationError,
)

Check failure on line 35 in rtxpy/__init__.py

View workflow job for this annotation

GitHub Actions / Lint & Import Check

ruff (I001)

rtxpy/__init__.py:1:1: I001 Import block is un-sorted or un-formatted help: Organize imports

__version__ = "0.1.0"

Expand Down
Loading