C++ data model
PrismCore
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 path is the only identity. There are no UUIDs and no pointers between
nodes.
/world/lens.reflectancenames a slot as unambiguously as a file path names a file. Hierarchy lives in the path, so cycles in the tree are structurally impossible. - Everything numeric is an animatable property. A transform component, a material parameter, a node input — all are the same kind of thing: a typed value that can carry keyframes. Animation touches exactly one mechanism.
- References are connections. Material binding, node wiring, a value driving a slot — all are one thing: a connection from an output path to an input path. Rename or reparent a subtree and every endpoint is rewritten, so wiring never dangles.
- It is portable and pure. No GPU, no UI, no platform calls, no third-party dependencies — just the standard library. It compiles anywhere and is exhaustively unit-tested.
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:
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
.prismextension, 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.
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.