Class reference
Evaluator, NodeRegistry & Scene
Evaluation and the node model
A Document stores data. Evaluation turns that data into values. It is pull-based: you ask for one slot at one time, and Core walks backward through the connections that feed it, resolving each source at that time, until it reaches a concrete value. Nothing is pushed; nothing recomputes until something asks.
Two levels live here. The registry-free level reads a value through connections and time samples: a connection resolves to the upstream authored property, an unconnected slot to its own default. This is the one-line evaluate / doc.eval, and Core ships it. The node level adds behavior: a node TYPE declares input and output slots and a bake that computes its outputs from its inputs. An Evaluator carries a NodeRegistry of those behaviors, bakes nodes on demand, and memoizes — a node feeding many consumers bakes once. Compose and bake the whole stage at a time and you get a Scene: every element resolved, world transform composed, properties and baked outputs in hand.
Core registers no node behaviors of its own. Concrete nodes (Field, Clone, Add, …) live in Kinogaki Foundation and register against this same API.
#include "kinogaki/Document.h"
#include "kinogaki/Evaluate.h"
#include "kinogaki/Scene.h"
using namespace kinogaki;
// 1. A node type: declares slots, implements bake().
struct AddNode : KinogakiNode {
std::string typeName() const override { return "add"; }
std::vector<SlotDef> inputs() const override {
return {{"a", Type::Float, Float(0)}, {"b", Type::Float, Float(0)}};
}
std::vector<SlotDef> outputs() const override { return {{"out", Type::Float}}; }
void bake(BakeContext& ctx) const override {
ctx.setOutput("out", Float(ctx.input("a").asFloat() + ctx.input("b").asFloat()));
}
};
// 2. Register the behavior once.
NodeRegistry registry;
registry.add(std::make_unique<AddNode>());
// 3. Evaluate the stage at a time and read an output.
Document doc;
doc.append(Path("/sum"), "add").set("a", Float(2)).set("b", Float(3));
Evaluator ev(doc, registry, /*time*/ 0);
Value out = ev.outputValue(Path("/sum.out")); // → 5import kinogaki as kg
doc = kg.Document()
doc.append("/world/mat", "material").set("out", (0.8, 0.1, 0.1))
doc.append("/world/ball", "object").set("albedo", (1, 1, 1))
doc.connect("/world/mat.out", "/world/ball.albedo")
doc.eval("/world/ball.albedo", time=0) # → (0.8, 0.1, 0.1), pulled through the connectionThe registry-free read is bound in Python as doc.eval / evaluate. The node-registry Evaluator — registering behaviors, baking, reading node outputs — is not yet bound in Python; it lives in C++ (and the C ABI) today. Each piece below flags its Python status.
evaluate / doc.eval — read a value through the graph
The one-line read. Resolve the value at a slot (/element.property) following connections and time samples, with no node registry. A connected slot resolves to the upstream authored property; an unconnected slot resolves to its own value at time; interpolated samples are evaluated at time. This is what you want for reading a property the way the document means it, not the raw stored default.
Value evaluate(const Document& doc, const Path& slot, double time);
Value Document::eval(const Path& slot, double time) const; // member sugar
Value albedo = doc.eval(Path("/world/ball.albedo"), /*time*/ 12);
// follows the connection to /world/mat.out, resolves it at t=12
In Python, doc.eval(slot, time) and the module-level kg.evaluate(doc, slot, time) are the same call. Both return a native value — float / int / bool / a tuple — or None when the slot is unresolvable or not a float-family type.
v = doc.eval("/world/ball.albedo", time=12) # (r, g, b) tuple
v = kg.evaluate(doc, "/world/ball.albedo", 12) # identical
n = doc.eval("/missing.slot") # None
The slot must name a property (/element.property); a bare element path resolves to nothing. Internally this builds a one-shot Evaluator over an empty registry — so it never bakes a node, it only follows connections and samples. For node-baked outputs, build an Evaluator with a populated registry (C++).
Python note:
kg.evaluatereturns float-family values only —float,int,bool, and float2/float3 tuples. Other types (strings, references) come back asNone. Read those with the handle getters (doc.edit(path).get_str(...)).
SlotDef — a declared slot
A node declares what flows in and out. Each SlotDef is a name, a datatype, and — for an input — the default used when nothing is connected and the element authored no property.
struct SlotDef {
std::string name;
Type type = Type::Float;
Value def{}; // input default; ignored for outputs
};
The default matters: it is what an input means when both the connection and the property are absent. A multiply whose a and b default to 1.0 reads ones, not zeros, when nothing is wired. This is C++ only.
KinogakiNode — the behavior of a node TYPE
A node type subclasses KinogakiNode, names itself, declares its slots, and implements bake. Behavior is stateless and registered once per type. The per-instance values — the actual a, b, radius — live in the element's properties in the Document. The Document stores data; this is the code that reads it.
class KinogakiNode {
public:
virtual ~KinogakiNode() = default;
virtual std::string typeName() const = 0; // the type token, e.g. "add"
virtual std::vector<SlotDef> inputs() const { return {}; }
virtual std::vector<SlotDef> outputs() const { return {}; }
virtual void bake(BakeContext& ctx) const = 0; // compute outputs from inputs
};
typeName is the token the registry keys on and the serializer writes as the element's type. inputs and outputs declare the slots; the evaluator uses input SlotDef.def as the final fallback. bake reads inputs and time off the BakeContext and writes outputs back. Keep it pure: same inputs and time, same outputs — that is what makes the memo correct.
struct ScaleNode : KinogakiNode {
std::string typeName() const override { return "scale"; }
std::vector<SlotDef> inputs() const override {
return {{"in", Type::Float3, Float3(0, 0, 0)}, {"k", Type::Float, Float(1)}};
}
std::vector<SlotDef> outputs() const override { return {{"out", Type::Float3}}; }
void bake(BakeContext& ctx) const override {
Vec3 v = ctx.input("in").asFloat3();
float k = ctx.param("k", Float(1)).asFloat(); // read straight off this element's property
ctx.setOutput("out", Float3(v.x * k, v.y * k, v.z * k));
}
};
C++ only. Defining new node types in Python is not bound.
BakeContext — what a node sees while baking
The argument to bake. It carries the evaluation time, the element being baked, the resolved values at the node's inputs, and a sink for its outputs.
double time() const; // the evaluation time
const Element& element() const; // this node's element (its properties)
Value input(const std::string& slot) const; // upstream output, else property, else SlotDef default
Value param(const std::string& name, Value def = {}) const; // a property on THIS element, resolved at time
void setOutput(const std::string& slot, Value v); // write an output slot
input resolves an input slot through the graph: it follows the connection feeding that slot, falling back to the element's authored property, then to the slot's declared default. param reads a property on this element directly, resolved at the current time — for values an instance authors but never wires (a seed, a mode). setOutput writes the result; everything written becomes the element's baked outputs. C++ only.
NodeRegistry — a type token → its behavior
The map from a type name to its KinogakiNode. Foundation and your own libraries register here; the evaluator looks up each element's behavior by element.type().
void add(std::unique_ptr<KinogakiNode> node); // keyed by node->typeName()
const KinogakiNode* lookup(const std::string& type) const; // nullptr if unknown
bool contains(const std::string& type) const;
std::vector<std::string> typeNames() const; // every registered type
add takes ownership and keys on the node's own typeName. An element whose type is not in the registry has no behavior: it bakes to no outputs (its authored properties still resolve normally). Build the registry once and let it outlive every Evaluator you make from it.
NodeRegistry registry;
registry.add(std::make_unique<AddNode>());
registry.add(std::make_unique<ScaleNode>());
registry.contains("add"); // true
registry.lookup("nope"); // nullptr
In C++ via the C ABI, kinogaki_node_registry_new / kinogaki_node_registry_free create and free a registry; concrete registration is a Foundation entry point. Not bound in Python — kg exposes no NodeRegistry.
Evaluator — one memoized evaluation at a fixed time
Evaluator(stage, registry, time) is one snapshot. It bakes nodes on demand and memoizes the result, so a node feeding many consumers bakes exactly once, and the memo is the per-evaluation value cache.
Evaluator(const Document& stage, const NodeRegistry& registry, double time);
double time() const;
const Document& stage() const;
const std::map<std::string, Value>& bake(const Path& elementPath); // all output slots of a node
Value outputValue(const Path& outputSlot); // one output slot, e.g. /n.out
Value resolveInput(const Path& elementPath, const std::string& slot); // the value feeding an input slot
bake resolves and bakes the nodes feeding this node's inputs first, then calls its bake, and returns its full output map — cached. outputValue bakes the named element and returns one output slot; if the type has no such baked output, it falls back to an authored property of that name (a USD-style attribute connection reading a plain value). resolveInput is the input-resolution rule itself: follow an incoming connection, else the authored property, else the declared SlotDef default.
Evaluator ev(doc, registry, /*time*/ 0);
const auto& outs = ev.bake(Path("/sum")); // { "out": 5 }
Value v = ev.outputValue(Path("/sum.out")); // 5
Value in = ev.resolveInput(Path("/sum"), "a"); // 2 — the value feeding input "a"
Cycles resolve to defaults. A node caught re-entering its own bake returns an empty output map rather than looping forever; downstream inputs then fall through to their SlotDef defaults. No exception, no hang.
The contract: the Document and registry must outlive the Evaluator and must not be mutated during evaluation — the memo and cached element references assume a stable stage. One Evaluator is one moment in time; for another time, make another. Binding to a temporary Document or registry is deleted at compile time.
The C ABI mirrors this: kinogaki_evaluator_new(stage, registry, time), kinogaki_evaluator_output_float(ev, slot, out), kinogaki_evaluator_free(ev). Not bound in Python — kg exposes only the registry-free evaluate. Driving a full node graph from Python is tracked, not yet shipped.
EvaluatedPrim and Scene — the whole stage, resolved
evaluate(stage, registry, t) composes and bakes the entire Document at one time into a Scene: a read-only, derived view. The Document is the truth; the Scene is regenerated from it. It is the representation a renderer consumes — domain-agnostic, with type + properties + outputs for the renderer (which knows the node types) to interpret.
struct EvaluatedPrim {
Path path;
std::string type;
Affine2 world = makeIdentityAffine(); // hierarchy-composed world transform
bool visible = true; // hierarchy-composed visibility
std::map<std::string, Value> properties; // every property resolved at t
std::map<std::string, Value> outputs; // the node's baked output slots
};
struct Scene {
double time = 0;
std::vector<EvaluatedPrim> elements; // in stage order
};
Scene evaluate(const Document& stage, const NodeRegistry& registry, double t);
One call builds one Evaluator and walks every element in stage order: composes its world transform, composes its visibility, resolves each property at t, and bakes its node outputs through the shared memo.
Scene scene = evaluate(doc, registry, /*time*/ 24);
for (const EvaluatedPrim& p : scene.elements) {
p.path; p.type; p.world; p.visible;
p.properties.at("albedo"); // resolved at t=24
p.outputs; // baked node outputs
}
C++ only. Python has no Scene binding; to read resolved values per slot from Python, call doc.eval(slot, time).
EvalCache — memoizing a derived value across evaluations
The Evaluator memo lives for one pass. EvalCache<T> is the layer above: a named, revision-keyed cache for a derived value (an extracted Scene, say) that you want to keep between passes and recompute only when its inputs change. Pull-only and domain-agnostic — the holder instantiates it on whatever T it derives and supplies the producer at the call site.
struct EvalKey { std::uint64_t rev = 0; double time = 0; }; // equality drives hit/miss
template <class T> class EvalCache {
template <class Produce>
const T& evaluate(EvalKey key, Produce&& produce); // hit if key unchanged, else (re)derive
void invalidate(); // force the next evaluate() to re-derive
bool valid(EvalKey key) const; // would a read at key hit?
};
The key is a (rev, time) pair: a settled-edit revision plus an evaluation time. evaluate returns the cached value when the key matches, otherwise calls produce(), stores it under the new key, and returns it. A throwing producer leaves the prior entry intact. invalidate forces a re-derive for stage mutations that bypass the revision counter.
EvalCache<Scene> cache;
const Scene& s = cache.evaluate({rev, time}, [&] { return evaluate(doc, registry, time); });
// next read at the same {rev, time} hits the cache — no recompute
Two cautions. The returned reference aliases the cache's single slot; it is valid only until the next evaluate or invalidate, so copy it if it must outlive that. And time must be a real number — a NaN time never compares equal, silently forcing a re-derive on every read. C++ only.
How a value is resolved
Reading any slot follows one rule, whether through doc.eval or an Evaluator:
- A connection feeds the slot → resolve the source slot (its baked output, else its authored property) at
time. - No connection, the element authored the property → resolve that property at
time, interpolating time samples. - Neither → the node type's declared
SlotDefdefault for that input, if a registry is present. - None of the above → an empty
Value(Python:None).
def add "sum" {
float a = 2
float b = 3
# out has no authored value — it is baked by the "add" node's behavior
}
evaluate(doc, "/sum.a", 0) → 2 (step 2). With an Evaluator over a registry holding add, outputValue("/sum.out") → 5 (the node bakes a + b). With the registry-free doc.eval("/sum.out", 0) → empty, because no behavior runs to produce out. That is the line between the two levels: connections and samples are Core's; baked outputs need a registry, and that path is C++ today.