Poly-Gone Spheres


A procedural planet editor for building a base shape with nodes, sculpting it non-destructively, and rendering it through a sparse GPU SDF.

Why the pipeline changed

The first pipeline was configured directly with YAML. I had considered defining the planet in C++, but a data format gave me hot reloading without waiting for a C++ compilation cycle after every authoring change, additionally, I was considering maybe adding a frontend for the graph later, this would be a lot easier if it's defined in a configuration language, rather than C++. Nodes were still defined and registered manually, which was manageable while the set of operations was small.

YAML was useful to the runtime, but it was not the best interface for the people who would actually build planets. I tested several configuration and editor approaches, and a visual node editor was the clear favorite. That introduced a different problem: every new node needed repetitive code for its typed properties, pins, widgets, serialization, and registration.

I redesigned that boundary around a small KDL metadata file. It now acts as the source of truth for generated node code, while I only write the operation-specific processing logic by hand. The editor still serializes its graph to YAML, so the human-facing editor and the runtime-friendly data format do not have to be the same interface. The parser is quite simple to replace. For example; changing the graph format to Binary, would only require you to implement the parser interface, which converts the stored graph, to an intermediate representation.

  1. Describe the node's inputs, outputs, properties, defaults, and controls.
  2. Generate its typed structures, UI, serialization, and registration.
  3. Author a visual graph and serialize it as YAML.
  4. Compile that editable graph into a validated execution graph.
  5. Execute it sequentially or through the worker-thread policy.

One definition, several surfaces

A node declaration describes information that would otherwise be repeated across several parts of the application. From it, the generator produces typed input, output, and property structures; a stable type identifier; ImGui controls and pins; YAML property serialization; and the registry entry used when a saved graph is loaded. Some node families also generate catalog and shader-side data.

This is a representative declaration. The important part is not KDL itself, but that the property name and type are stated once instead of being copied by hand into the editor, serializer, and executor, as that would be very error prone. I have personally made the error of misspelling the name in the executor multiple times, before making this change.

Curated SDF node definition - KDL
node "SdfBooleanNode" type="Boolean" namespace="pgs" {
    input "SdfValue"
    output "SdfValue"
    properties "SdfBooleanParams" {
        sphere_radius type="float" default="1.0" {
            imgui "drag" label="Sphere Radius" min=0.01 max=100.0
        }
        operation type="uint32" default="0" { imgui "drag" label="Operation" }
    }
}

The handwritten portion stays narrow: implement the typed process specialization. A facade provides capabilities such as compute dispatch, keeping the graph compiler independent from the concrete CPU or GPU execution backend. The template is more visible in the API than I would ideally like, but it preserves that separation well. An alternative would be type-erasure, but the benefit this provides, is that the type-system/compiler will tell you if an implementation for a certain backend is missing, rather than it being a runtime error.

Curated process specialization - C++
template <>
Result<SdfVolumeData, std::string>
SdfBooleanNode::process<ComputeFacade>(
    ExecutionContext<ComputeFacade>& context,
    const SdfBooleanParams& properties,
    const SdfVolumeData& input
);
Node editor graph with generated properties and a resulting planet
The picker, pins, property controls, and serialization behavior are generated from the same metadata as the execution types.

Compiling an editable graph

An editor graph is untrusted input. Before it can regenerate a planet, the compiler checks that every node type is registered, identifiers are unique, required inputs are present, and linked outputs exist and have compatible types. A missing plug-in or an invalid link therefore rejects the graph with an error instead of reaching node execution.

The compiler starts from the outputs marked for export and walks backwards through their dependencies. Nodes that cannot contribute to an exported result are removed. It then uses a deterministic topological sort to produce the execution order and rejects any remaining cycle. The reachability pass is O(V + E); compilation is approximately O(V + E log V) because the ready set uses a priority queue. Where V is the number of nodes (Vertices), and E is the number of links (Dependency Edges).

Temporary compiler data lives in reusable buffers and a monotonic arena, which avoids turning every graph rebuild into many small allocations. The completed graph is immutable and can be executed repeatedly with new run state and a new seed.

Execution policies

Compilation describes dependencies; an execution policy decides how to schedule them. The sequential policy visits the topological order directly. The parallel policy submits ready work to a thread pool, waits for the dependencies of a node to complete, and schedules newly unblocked dependants. Nodes are isolated from one another, so both policies operate on the same compiled representation.

Execution creates a per-run store for internal values and a separate collection for exported values. It fails fast on the first node error and only publishes a new snapshot after the whole requested result succeeds. The render feature can keep using its previous snapshot if a newly edited graph fails to compile or execute, rather than making the viewport disappear.

Sequential execution policy excerpt - C++
[[nodiscard]] aurora::Result<PipelineSnapshot, std::string>
execute(TFacade &facade)
{
    if (!compiled_)
    {
        return aurora::Err("Pipeline instance has no compiled pipeline.");
    }

    std::random_device rd;
    const uint32_t run_seed = rd();

    std::string error;
    typename CompiledPipeline<TFacade>::Store store(compiled_->nodes().size());
    std::vector<ExportedRecord> exported;

    const uint32_t fingerprint = compiled_->pipeline_fingerprint();
    std::span<const Node> nodes = compiled_->nodes();

    compiled_->graph().execute(
        [&](uint32_t index) noexcept
        {
            if (!error.empty())
            {
                return;
            }

            const auto &node = nodes[index];
            auto result = node.invoke(node, store, exported, facade, fingerprint, run_seed);
            if (!result)
            {
                error = std::move(result).error();
            }
        }
    );

    if (!error.empty())
    {
        return aurora::Err(std::move(error));
    }

    return PipelineSnapshot(std::move(exported));
}

Snapshots, memory, and outputs

Most node outputs are internal: downstream nodes can consume them, but they are not retained as part of the public result. An exported output stays in the snapshot so systems such as the renderer or exporter can request it by node type or identifier. Export-only nodes omit the output pin because their purpose is to cross that boundary.

A node type may occur more than once, so a typed lookup returns a collection of ExportViews rather than one value. These immutable views borrow their data from the snapshot: storing a view is safe only while its snapshot remains alive. Lookup scans the exported records and is therefore O(N) in the number of exported values, which is kept very small compared with the internal graph, and should in small datasets (the usual case) be comparable to or faster than the speed of a multi hashmap based approach.

Request an exported output - C++
auto exports = snapshot.from_node<MarchingCubesNode>();
if (exports.empty())
{
    return Err("BrickPool pipeline did not export a Draw payload.");
}
Typed snapshot lookup - C++
template <typename Node>
[[nodiscard]] std::vector<ExportView<typename Node::Output>>
from_node() const

Serialization and practical limits

Serialization stores node identities, links, and generated property values. Loading maps those records back to registered types, so a project cannot be imported if it depends on node types that are not installed. That failure is reported cleanly rather than crashing or silently dropping work.

The visual editor currently writes its graph to YAML, but the reverse path is not yet complete: it cannot reconstruct the editor layout from an arbitrary YAML pipeline. The runtime pipeline itself remains portable, and volume export can package it with the sculpt journal, but a full round trip through the visual authoring surface still needs that importer.