The engine never renders — it produces plain JSON, twice over. Model.toScene() is the one-shot contract: evaluate once, get back a fully tessellated scene tree. collectRenderFrame() + diffEntityFrame() is the incremental contract: snapshot the current frame, diff it against the previous one by geometry content, and only rebuild what actually changed. A custom renderer (Babylon, PlayCanvas, a Blender add-on, SketchUp Ruby) implements against one of these two — @parametron/three's Renderer is the reference consumer of the incremental path.
Two ways to render
toScene() | collectRenderFrame() + diffEntityFrame() | |
|---|---|---|
| Output | SceneJSON — tessellated meshes, ready to draw | RenderFrameSlice[] — baked entities, still domain geometry (curves/faces/points) |
| Cost | Full tessellation every call | Tessellate once, then only what the diff marks added/updated |
| Use when | One-shot: thumbnails, export, "just show me the model" | Live editing: a viewport that re-renders on every parameter tweak |
Both read the same evaluated node graph — toScene() calls model.evaluate() for you; collectRenderFrame() does not, so call model.evaluate() (first frame) or model.nodes.evaluate() (subsequent edits) yourself before collecting. See Model & pull graph for what evaluation does under the hood.
One-shot: toScene()
import { Model, createRegistry } from "@parametron/parametric"
import { nodeRegistry } from "@parametron/parametric/nodes"
const registry = createRegistry(nodeRegistry)
const model = Model.fromJSON(modelJson, { registry })
const scene = model.toScene({ width: 300 }) // runs evaluate() internally
// scene.nodes: SceneNode[]SceneJSON is a tree that mirrors the model's top-level body nodes — one SceneNode per node, each carrying its own entities (never merged across nodes, so a host can still key incremental updates on node identity if it wants to):
type SceneJSON = { nodes: SceneNode[] }
type SceneNode = {
id: string
key: string
entities: SceneEntity[]
children: SceneNode[]
material?: string // nearest-ancestor default — see below
layer?: string
label?: string
visible?: false
}
type SceneEntity = Curve | Mesh | PointThree tessellated shapes, matching the engine's three scene entities:
Curve = { type: "curve", points: Point3D[], material?, layer? }Mesh = { type: "mesh", positions, indices, normals, uvs, groups: MeshGroup[], material?, layer? }—groupsare material sub-ranges ({ start, count, material? }), the WebGL multi-material convention.Point = { type: "point", position: Point3D }
material/layer resolve nearest-ancestor, SketchUp-style: an entity's own value wins, else it inherits its SceneNode's value, else the nearest ancestor's. The engine already lifts a value shared by every entity in a node up to the node itself, so a per-entity material/layer field only ever appears as an override on an entity that actually differs from its siblings. Bodies are never embedded — materials/layers/fonts are referenced by name only (see Store for how a host resolves a name to a body).
Groups (instance placements) are already baked flat into entities by the time toScene() runs — a host consuming SceneJSON never sees group/instance structure, only concrete Curves/Meshes/Points. That flattening is what makes this contract the simplest possible integration, and also why it isn't cheap: every call re-tessellates everything.
Incremental: collectRenderFrame()
import { collectRenderFrame } from "@parametron/parametric"
model.evaluate() // first evaluation
const frame = collectRenderFrame(model.nodes) // RenderFrameSlice[]collectRenderFrame runs the root fold exactly once per call and slices the result per source node — the same per-node granularity as toScene(), but without tessellating and without flattening instances:
type RenderFrameSlice = {
key: string // source node's key (render identity)
id: string // source node's runtime id
leaves: EntityJSON[] // baked world-space entities, non-group, visible only
groups: RenderFrameGroup[] // one entry per group placement
}
type RenderFrameGroup = {
leaves: EntityJSON[] // the definition's content, baked once
placement: GeometryPlacement
}
type GeometryPlacement = {
transformation: number[] // column-major 4×4
material?: string
layer?: string
name?: string
}The instancing lane is the reason this exists as a separate contract from toScene(): placements of the same group definition share one leaves array by reference within a collect call. A host tessellates a definition once (e.g. a Three.js template Group, a SketchUp ComponentDefinition) and clones it per placement instead of re-baking N copies — and a transform-only edit (move/rotate on an arrayLinear copy) changes only placement, so it diffs as a matrix update, never a re-tessellation.
Re-evaluate and re-collect on every edit:
model.nodes.evaluate() // after a param/arg change
const next = collectRenderFrame(model.nodes)EntityJSON leaves are still domain geometry (Curve/Face/Point shape, not tessellated triangles) — a host renders them with its own producer-specific logic (see the entity API for the geometry shape each entity kind carries).
Diffing frames: contentKey, RenderCell, diffEntityFrame
import { contentKey, diffEntityFrame, toRenderCell } from "@parametron/parametric"
import type { RenderCell } from "@parametron/parametric"contentKey(entity: EntityJSON): string hashes an entity's geometry field only — never id (fresh every recompute) or material/layer/name. Two independent FNV-1a passes plus the source length give an effectively collision-free key. Because evaluation is deterministic (same params → same floats → same hash), a content-key match is a guarantee the shape is unchanged; a miss only costs a re-tessellation, it can never render stale geometry.
RenderCell<T> is what a host keeps around between frames — one per mounted render object, carrying the content key it was built from and the host payload (T — a Three Object3D, a SketchUp entity id, whatever the host's render target is):
type RenderCell<T = unknown> = {
key: string
material?: string
layer?: string
name?: string
data: T
}
toRenderCell<T>(entity: EntityJSON, key: string, data: T): RenderCell<T>diffEntityFrame<T>(previous: RenderCell<T>[], next: EntityJSON[]): FrameDiff<T> reconciles the previous frame's cells against the next frame's entities — order-independent multiset matching by content key, O(previous + next):
type FrameDiff<T = unknown> = {
kept: { cell: RenderCell<T>; entity: EntityJSON }[] // same geometry, same props — untouched
updated: { cell: RenderCell<T>; entity: EntityJSON }[] // same geometry, different material/layer/name
added: EntityJSON[] // new geometry — build it
removed: RenderCell<T>[] // gone — dispose it
}A full reconcile loop (adapted from the engine's own tests):
const diff = diffEntityFrame(previousCells, slice.leaves)
for (const cell of diff.removed) host.dispose(cell.data)
const nextCells: RenderCell<Host>[] = []
for (const { cell } of diff.kept) nextCells.push(cell)
for (const { cell, entity } of diff.updated) {
host.applyProps(cell.data, entity) // material/layer/name only
nextCells.push(toRenderCell(entity, cell.key, cell.data))
}
for (const entity of diff.added) {
const object = host.build(entity) // tessellate + mount
nextCells.push(toRenderCell(entity, contentKey(entity), object))
}
previousCells = nextCellsRun this once per slice.leaves for the leaf lane, and once more per definition (keyed by the joined content keys of group.leaves) for the group lane — @parametron/three's Renderer does exactly this split, with a separate clone/template cache for the group lane so a matrix-only edit never touches the leaf diff at all.
Building a custom renderer
- Load assets into a
Store, build the model, runmodel.evaluate()once. - First frame:
collectRenderFrame(model.nodes), build every leaf and every group placement fresh (diff.added= everything, sincepreviousCellsis empty). - On every subsequent edit: mutate the graph (an argument's
setInput, …see Model & pull graph), callmodel.nodes.evaluate(), collect again, diff against the cells you kept from the previous frame. - Only
addedentities get tessellated; onlyupdatedentities get a props pass; onlyremovedcells get disposed;keptcells are untouched. Group placements diff the same way one level up — aplacementchange alone never re-tessellates the sharedleaves.
If you don't need incrementality (a thumbnail service, a static export), skip all of this and call model.toScene() — it's the same underlying evaluation, just flattened and tessellated in one call.