Parametron

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()
OutputSceneJSON — tessellated meshes, ready to drawRenderFrameSlice[] — baked entities, still domain geometry (curves/faces/points)
CostFull tessellation every callTessellate once, then only what the diff marks added/updated
Use whenOne-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 | Point

Three 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? }groups are 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 = nextCells

Run 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

  1. Load assets into a Store, build the model, run model.evaluate() once.
  2. First frame: collectRenderFrame(model.nodes), build every leaf and every group placement fresh (diff.added = everything, since previousCells is empty).
  3. On every subsequent edit: mutate the graph (an argument's setInput, …see Model & pull graph), call model.nodes.evaluate(), collect again, diff against the cells you kept from the previous frame.
  4. Only added entities get tessellated; only updated entities get a props pass; only removed cells get disposed; kept cells are untouched. Group placements diff the same way one level up — a placement change alone never re-tessellates the shared leaves.

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.

📖 5 min read