This document shows how any renderer or engine can integrate the vrendergraph runtime.
The runtime layer has no dependency on ImGui or the editor.
It simply converts a JSON graph description into a FrameGraph execution pipeline.
Typical workflow:
Editor (optional) → JSON → vrendergraph runtime → FrameGraph → Renderer
#include <vrendergraph/vrendergraph.hpp>vrendergraph depends only on:
- C++23
- nlohmann::json
- the FrameGraph library used internally
Each application registers the passes that its renderer supports.
#include <vrendergraph/vrendergraph.hpp>
using namespace vultra::rg;
static void registerPasses(RenderGraphRegistry& registry)
{
registry.registerPass({
.type = "gbuffer",
.setup = [](FrameGraph& fg,
FrameGraphBlackboard& bb,
const ParamBlock& params)
{
// Add GBuffer pass to FrameGraph
}
});
registry.registerPass({
.type = "lighting",
.setup = [](FrameGraph& fg,
FrameGraphBlackboard& bb,
const ParamBlock& params)
{
// Add lighting pass
}
});
registry.registerPass({
.type = "present",
.setup = [](FrameGraph& fg,
FrameGraphBlackboard& bb,
const ParamBlock& params)
{
// Present to swapchain
}
});
}Each setup function translates the abstract graph node into a FrameGraph pass.
Render graphs are stored as JSON files.
#include <fstream>
#include <nlohmann/json.hpp>
RenderGraphDesc loadGraph(const std::string& path)
{
std::ifstream file(path);
nlohmann::json j;
file >> j;
return loadRenderGraph(j);
}To execute the render graph, construct the runtime object and build a FrameGraph.
RenderGraphRegistry registry;
registerPasses(registry);
RenderGraph runtime(registry, importer);
FrameGraph fg;
FrameGraphBlackboard blackboard;
RenderGraphDesc desc = loadGraph("pipeline.json");
runtime.build(fg, blackboard, desc);At this stage the FrameGraph contains all passes required by the pipeline.
The renderer executes the resulting FrameGraph normally.
fg.compile();
fg.execute();Actual execution may involve:
- command buffers
- GPU resources
- synchronization
- swapchain presentation
These details are handled by the renderer implementation.
{
"passes": [
{ "id": "gbuffer", "type": "gbuffer" },
{
"id": "lighting",
"type": "lighting",
"inputs": {
"albedo": "gbuffer.albedo",
"normal": "gbuffer.normal"
}
},
{
"id": "present",
"type": "present",
"inputs": {
"color": "lighting.output"
}
}
]
}The runtime only reads the passes array.
Any meta data (such as editor layout) is ignored.
Schema v4 replaces the old per-pass when string with first-class logic nodes, so
branching is expressed as explicit, visibly-wired nodes and a single graph can target every
backend / platform / feature tier. Logic nodes are passes whose type is a reserved
$-prefixed identifier; they are never looked up in the registry.
- Value node
$value— emits a scalar on itsvaluepin.params.keyis a predicate the host resolves (seeValueResolverFn);params.defaultis used when the resolver returns null. - Boolean combinators
$and/$or/$not— value-in → value-out; nest arbitrarily, e.g.$if( $and(featureBindless, platformDesktop) ). - Routers
$select(alias$if) and$switch— pick one of their resource inputs based on a valueselectorand republish it on theoutpin. The unselected branch's passes are culled (not added to the FrameGraph).
The host supplies values via the resolver overload of build:
runtime.build(fg, blackboard, desc, [&](std::string_view key) -> nlohmann::json {
if (key == "feature_bindless") return device.supportsBindless();
if (key == "platform_desktop") return !onMobileOrWeb();
return nullptr; // -> $value falls back to its params.default
});Example: route sceneColor to the deferred branch when bindless is available on desktop, else
the compat branch — without any when strings:
{
"version": 4,
"resources": [ { "name": "backbuffer" }, { "name": "sceneColor" } ],
"passes": [
{ "id": "featBindless", "type": "$value", "params": { "key": "feature_bindless", "default": false } },
{ "id": "platDesktop", "type": "$value", "params": { "key": "platform_desktop", "default": false } },
{ "id": "useDeferred", "type": "$and",
"inputs": { "a": "featBindless.value", "b": "platDesktop.value" } },
{ "id": "DeferredChain", "type": "...", "outputs": { "color": "DeferredChain.color" } },
{ "id": "CompatChain", "type": "...", "outputs": { "color": "CompatChain.color" } },
{ "id": "sceneColorSelect", "type": "$if",
"inputs": { "selector": "useDeferred.value",
"whenTrue": "DeferredChain.color",
"whenFalse": "CompatChain.color" },
"outputs": { "out": "sceneColor" } },
{ "id": "UiOverlay", "type": "...", "inputs": { "source": "sceneColor" } }
]
}Build semantics: value/logic DAG is evaluated, each router's chosen input is resolved, then
liveness is computed by backward reachability from roots (passes whose outputs nothing reads) —
following only the chosen input at each router — and only live passes are built. Routers do no
GPU work; they alias the chosen resource handle onto their out (and any mapped resource name).
Liveness keeps any pass whose output nothing consumes (a "root"), so a side-effect pass — e.g. a
motion-vector pass read only outside the graph — would run on every branch. To branch-gate such a
pass, wire the reserved $enable input to a value pin: when it resolves falsy the pass is culled
(like enabled:false), along with anything that was only kept alive through it.
{ "id": "MotionVectors", "type": "MotionVectors",
"inputs": { "depth": "DirectGBuffer.depth", "$enable": "useDeferred.value" } }$enable is a value pin (not a resource input); it is skipped when gathering a pass's resources.
A typical renderer integration looks like:
Renderer
│
├─ registerPasses()
│
├─ load JSON render graph
│
├─ vrendergraph.build()
│
└─ execute FrameGraph
This allows render pipelines to be fully data-driven.