kinogaki Home X

PrismCore

C++ data model · documentation


PrismCore is a portable, dependency-free C++ library for representing a document as data. It is shaped like OpenUSD: a tree of path-addressed prims, each holding typed, animatable properties, with connections wiring one prim's output into another's input — so the same structure that stores your scene is also a dataflow graph an evaluator can pull through.

Introduction

Most applications grow two parallel worlds: a document model for saving, and a separate graph of objects for computing. PrismCore collapses them. There is one structure — the Stage — and it is at once the saved document, the scene tree, and the node graph. Nothing is inferred and nothing is hidden: every piece of state is a named, typed value on a prim, addressed by a path.

It rests on a few deliberate choices, all borrowed from USD:

The mental model

Five types carry the whole design. Read them top-down:

Stage
The single source of truth: an ordered set of prims plus their connections, indexed by path. This is the document.
Prim
A node in the tree — a path, a type token ("group", "object", "add"…), a map of properties, and a map of string metadata.
Path
The identity — a slash-separated address with an optional trailing .slot.
Property
A typed default value plus optional time samples; resolve(t) gives the value at a time.
Value
The typed datum itself — float, int, bool, float2, float3, a 2×3 matrix, a spectrum, or an array.

Because a connection joins two property paths, a Stage of prims is also a graph. Picture a circle generator feeding a transform feeding a renderer — three prims, two connections:

/circle radius = 2 type "circle" /xform rotate = … /out render .out .in
One prim's output slot connects to the next prim's input slot — the only reference mechanism.

The tree you save and the graph you evaluate are the same prims. That is the whole idea.

Architecture

The library is a handful of small headers, each one concept:

Value / Type
A typed, animatable value built on std::variant. The type set is closed and numeric; strings live in prim metadata.
Property / TimeSamples
A value plus keyframes. TimeSamples is an ordered keyframe set with held/linear/bezier interpolation — the only thing animation touches.
Path
Parse, compose, reparent. The sole identity; provides a stable ordering for diff-clean output.
Prim
Properties (numeric, animatable) in one channel, metadata (strings) in another — exactly as USD separates attributes from metadata.
Stage
The document: add/remove/rename prims and connect/disconnect slots, with subtree extract + instantiate for templates. Every mutation keeps connections consistent.
Node / Evaluate
Node behaviour (the code) is separate from node data (the prim). A pull-based, memoized Evaluator bakes the graph at a fixed time.
Transform
2D affine compose — local and world matrices from a prim's transform properties.
Serialize
One .prism extension, three encodings (text, binary, package), all the same Stage.
C ABI
A flat C interface over the Stage, for binding from other languages and runtimes.
Data and behaviour are split on purpose. The Stage stores only data — a node instance is just a prim with a type token and some properties. The behaviour of a type (its slots and its bake()) is code registered once in a NodeRegistry. Base nodes ship in one library; you compile your own against the same API. Serialized scenes carry data, not code.

Quickstart: build a stage

A Stage starts empty. Define a prim with a path and a type, give it properties, and add it. Reading a value means resolving a property at a time (static properties ignore the time).

#include "prism/Stage.h"
using namespace prism;

Stage stage;

// define() constructs the prim, adds it (uniquifying the leaf name among
// siblings), and returns a chainable handle. set() takes the value directly
// — no Property/Value wrapping, no std::move.
stage.define(Path("/world"), Prim::Group);

auto lens = stage.define(Path("/world/lens"), Prim::Object);
lens.set("position", Float2{170, 150})
    .set("radius", 2.0f);
lens->setMetadata("label", "Front element");     // strings are metadata, not properties

// Read it back — "here's a float, give me a float".
float  r   = lens.getFloat("radius");            // 2.0
Float2 pos = lens.getFloat2("position");         // {170, 150}

// Walk the tree.
for (const Path& child : stage.children(Path("/world")))
    /* /world/lens */;

Quickstart: animate a property

Any property can carry keyframes. animate() authors them inline; the per-key interp governs the segment that follows it (held, linear, or bezier — Blender's f-curve convention). A typed getter with a time reads the animated value.

auto lens = stage.edit(Path("/world/lens"));

// a full turn by frame 24, linear — no Property, no Value, no std::move.
lens.animate("rotation", {{0, 0.0f}, {24, 6.2832f}});

float mid = lens.getFloat("rotation", /*default=*/0, /*time=*/12.0);   // ≈ π

That single mechanism animates everything — a transform, a material parameter, a node input. There is no separate animation system to learn.

Quickstart: the node graph

A node's behaviour is a small class: it declares input and output slots and implements bake(), reading inputs and writing outputs. Register it once; then a prim of that type is an instance, and its properties are the instance's parameters.

#include "prism/Evaluate.h"

// A node type: out = a + b.
class AddNode : public PrismNode {
public:
    std::string typeName() const override { return "add"; }
    std::vector<SlotDef> inputs() const override {
        return {{"a", Type::Float, Value(0.0f)}, {"b", Type::Float, Value(0.0f)}};
    }
    std::vector<SlotDef> outputs() const override { return {{"out", Type::Float}}; }
    void bake(BakeContext& ctx) const override {
        ctx.setOutput("out", Value(ctx.input("a").asFloat() + ctx.input("b").asFloat()));
    }
};

Wire two instances with a connection — an output slot path to an input slot path — and pull:

Stage stage;
stage.define(Path("/n1"), "add").set("a", 2.0f).set("b", 3.0f);
stage.define(Path("/n2"), "add").set("b", 10.0f);

// n1.out drives n2.a. The single reference mechanism.
stage.connect(Path("/n1.out"), Path("/n2.a"));

NodeRegistry reg;
reg.add(std::make_unique<AddNode>());   // registering a node TYPE — library-author code

Evaluator ev(stage, reg, /*time=*/0.0);
Value out = ev.outputValue(Path("/n2.out"));   // (2+3) + 10 = 15

Evaluation is pull-based and memoized: baking /n2 first resolves the nodes feeding its inputs, so /n1 bakes on demand and exactly once even if many consumers read it. A connected input takes its upstream value; an unconnected one falls back to the property default. Cycles resolve to defaults rather than looping forever.

The value system

A Value is a closed, numeric std::variant. Keeping the union purely numeric is what lets every value animate; non-numeric attributes (asset paths, labels, free text) live in prim metadata instead. The types:

Float · Int · Bool
The scalars.
Float2 · Float3
2- and 3-component vectors (positions, RGB, anything). Both animate component-wise.
Matrix
A 2×3 affine (a b c d tx ty) — the transform datatype.
Spectrum
A 32-bin spectral power distribution — the native colour type for a spectral renderer.
Float2Array · IntArray · FloatArray
The primvar arrays — points, topology indices, and per-vertex scalars (the OpenUSD primvar model).
Value a(1.5f);                       // Float
Value c(Float3{0.9f, 0.7f, 0.3f});   // Float3 — e.g. an RGB tint
c.type();                            // Type::Float3
c.as<Float3>()[0];                    // 0.9
Value::lerp(Value(Float3{0,0,0}), c, 0.5);   // component-wise → {0.45, 0.35, 0.15}

New types append to the end of the enum so existing serialized type codes stay stable — the format is forward-compatible by construction.

Evaluation

An Evaluator is one snapshot of the stage at a fixed time. It takes the Stage and a NodeRegistry by reference — both must outlive it and stay unmutated for its lifetime, because the memo and the cached prim references assume a stable stage. Three entry points cover the common needs:

bake(primPath)
All output slots of a node, as a name → Value map.
outputValue(outputSlot)
One output slot — e.g. /n.out.
resolveInput(primPath, slot)
The value feeding an input slot — its connection's source, or the property default.

The memo is the per-evaluation cache. For animation you make a fresh Evaluator per frame at the new time; cross-frame caching by content hash is a later layer that sits over this same interface.

Editing & undo

define/set mutate the stage directly — ideal for building a scene or a test. An interactive application wants every edit to be undoable, and that is a thin layer above Core, the prismauthor library. A Document wraps a Stage (plus a sidecar asset store and a change-listener seam); a CommandBus applies commands to it, pushing each onto an undo stack. The history lives in the bus, not the document — so the model stays pure and the editing policy sits cleanly on top.

#include "prism/author/Document.h"
#include "prism/author/CommandBus.h"
#include "prism/author/StandardCommands.h"
using namespace prism;
using namespace prism::author;

Document doc;
CommandBus bus(doc);

// Every edit is a command — applied now, and undoable. run<Cmd>(args…) builds + runs it.
bus.run<AddPrim>(Path("/world/lens"), Prim::Object);
bus.run<SetProperty<float>>(Path("/world/lens"), "radius", 2.0f);
bus.run<SetProperty<Float2>>(Path("/world/lens"), "position", Float2{170, 150});

bus.undo();   // the edits roll back, in order
bus.redo();

The standard commands cover the structural edits — AddPrim, RemovePrim, RenamePrim, SetProperty<T>, Connect / Disconnect, SetMetadata, and keyframe edits — each capturing exactly what it needs to reverse itself. Wrap several in a Transaction and they collapse into one undo entry, so a mouse-down → drag → mouse-up gesture becomes a single step:

{
    Transaction tx(bus, "Move lens");      // RAII — closes on scope exit
    bus.run<SetProperty<Float2>>(path, "position", a);
    bus.run<SetProperty<Float2>>(path, "position", b);
}                                          // one "Move lens" entry, not two

Because identity is the path, a command that renames or removes a subtree rewrites every affected prim path and every connection endpoint, so undo restores the wiring intact.

Transforms

Spatial prims author standard transform properties — position, rotation, scale, pivot (each independently animatable) and a visible bool. PrismCore composes them into 2×3 affines:

#include "prism/Transform.h"

Affine2 local = localMatrix(*stage.prim(p), /*t=*/0.0);
Affine2 world = worldMatrix(stage, p, 0.0);   // product down the ancestor chain
Float2  pt    = affineApply(world, Float2{0, 0});
bool    shown = isVisible(stage, p, 0.0);      // false if any ancestor is hidden

The local matrix is T(position)·T(pivot)·R(rotation)·S(scale)·T(−pivot) — scale and rotate happen about the pivot, then translate — and the world matrix is the product from the topmost ancestor down. Visibility propagates: a hidden parent hides its whole subtree.

Serialization

A document saves as .prism — one extension with three on-disk encodings, told apart by sniffing the leading bytes rather than the suffix. Text is a USDA-flavoured, diff-clean authoring form; binary is a compact deterministic crate; package bundles a scene with its asset blobs. All three encode the same Stage.

std::string text   = serialize(stage);         // → "#prism 1.0 …" text
std::string binary = serializeBinary(stage);   // → "PRSMC\0…" crate
Encoding enc = detect(bytes);                  // sniff which one a buffer carries

The text form reads like a scene description, with connections written as .connect arrows:

#prism 1.0
def group "world" {
    def object "lens" {
        float2 position = (170, 150)
        float rotation = 0
        float rotation.timeSamples = {
            0: 0,
            24: 6.2832,
        }
        inMaterial.connect = </world/mat.out>
    }
}

Properties are sorted, default (linear) interpolation is omitted, and non-finite scalars are written as zero — so the same Stage always serialises to the same bytes, and version control sees clean diffs.

The C ABI

A flat C interface wraps the Stage for binding from other languages and runtimes — opaque stage handles and typed get/set calls keyed by path. For example, setting and reading a 3-component value:

float rgb[3] = {0.9f, 0.7f, 0.3f};
prism_prim_set_float3(stage, "/world/lens", "tint", rgb);

float out[3];
prism_prim_get_float3(stage, "/world/lens", "tint", /*time=*/0.0, out);

The C ABI is how a host application or a different-language runtime drives the same document the C++ code does — one model, many front ends.

PrismCore is the document model behind Prism, a spectral-light-tracing application; its interface is built with PrismUI. Docs for those are coming to kinogaki soon.