Module: flow-graph
Package path:
packages/babylon-lite/src/flow-graph/Status: IMPLEMENTED / CURRENT COMPATIBILITY BASELINE COMPLETE. This document is the formal specification for Babylon Lite's
FlowGraphruntime, stable glTFKHR_interactivitysupport, and Babylon.js Flow Graph Editor JSON compatibility.
Purpose
The flow graph is Babylon's visual scripting / behaviour runtime: a directed
graph of nodes ("blocks") that react to engine events and drive scene behaviour
(animate properties, play sounds, branch on conditions, do math). Its primary
real-world consumer is the glTF KHR_interactivity extension, which embeds
an interactivity graph inside a .glb and expects the engine to execute it at
runtime.
Goal of this port: load and run interactive glTF assets and execute JSON saved by the Babylon.js Flow Graph Editor. The two formats are separate parser front-ends over the same pure-state runtime:
- a minimal pure-state runtime (graph + context + connection model),
- the blocks that
KHR_interactivitymaps to, - the
loader-gltfextension that parses the interactivity JSON into a Lite graph and resolves JSON pointers to scene accessors, and - an editor-JSON adapter that translates Babylon.js serialized block graphs into the same Lite graph representation.
Debugger parity and editor-only blocks outside Lite's supported block registry remain later work. Loading the editor's coordinator and legacy single-graph JSON formats is in scope; unsupported editor-only block classes fail with a precise diagnostic rather than being silently discarded.
Spec stability caveat (read before touching
flow-graph/gltf/):KHR_interactivityreached Khronos release-candidate status and was submitted for ratification in July 2026, but the checked sources do not yet establish final ratification. Babylon.js has replaced the implementation that this port originally mirrored. This port therefore continues to isolate all spec-dependent code inflow-graph/gltf/and records exact reference commits so future revisions remain localized.
Architectural Decision: Pure-State, Not Classes
Babylon.js FlowGraph is a deep OOP hierarchy
(FlowGraphBlock → FlowGraphExecutionBlock → FlowGraphAsyncExecutionBlock →
FlowGraphEventBlock), with methods on every node, a global class registry
(RegisterClass) used for deserialization, and module-level new RichType(...)
allocations. None of that is permitted in Lite (GUIDANCE §4b′ pure-state
interfaces, §4 zero module-level side effects, 100% tree-shakable).
Lite re-architects the same semantics as data + functions:
| Concern | Babylon.js (OOP) | Babylon Lite (pure-state) |
|---|---|---|
| A node | class FooBlock extends FlowGraphBlock with methods | FgBlock plain data + a FgBlockDef record of pure functions |
| Node behaviour | block._execute(ctx) method | def.execute(block, ctx, env, signal) standalone fn |
| Data output calc | block._updateOutputs(ctx) method | def.updateOutputs(block, ctx, env) standalone fn |
| Type info | class RichType instances at module scope | FgType const-object string tags + pure defaultForType() |
| className → ctor | global RegisterClass registry (side-effect) | tree-shakable getBlockDef(type) dynamic-import switch |
| Per-run state | fields on FlowGraphContext instance | FgContext plain data mutated by standalone fns |
| Scene access | block holds/queries Scene | block touches only accessors/handles wired by the loader; the scene owns and drives the graph |
The key insight that makes this clean: BJS already keeps per-instance state in
FlowGraphContext keyed by block uniqueId, not on the block. The block is
nearly a stateless definition already. Lite finishes the job — the block becomes
pure data describing topology, and all behaviour moves into a registry of pure
functions. This also means porting one block = writing one small data record +
one or two pure functions, which keeps future block ports mechanical (see the
companion skill port-flow-graph-block.md).
This mirrors two existing Lite patterns:
loader-gltf/gltf-feature-registry.ts—[trigger, () => import(...)]tuples, dynamic-imported only when needed. The block registry uses the same idea.- BJS's own
blockFactory— already a tree-shakableswitchof dynamic imports (no global side effect). We keep that shape; we only drop the class bodies behind it.
One-Way Ownership & Scene Drive Model (Critical)
GUIDANCE §4b forbids components from referencing the scene. A flow graph obviously needs to read/write node transforms, fire on scene events, and play animations — so the wiring is inverted exactly like the animation subsystem:
- The scene owns the graphs via an optional
scene._flowGraphs?: FgRuntime[]field (plain data). It is leftundefinedfor non-interactivity scenes and lazily created byattachFlowGraph()— so core stays byte-identical when no graph is attached (verified: the per-scene bundle manifest is unchanged by this subsystem's existence). - The graph is driven through the scene's existing generic seams, NOT a
hardcoded loop in
scene-core.ts(GUIDANCE §4c′):attachFlowGraph()registers aonBeforeRender(scene, cb)driver (exactly how animation groups are ticked) and anonSceneDispose(scene, cb)teardown. The flow-graph drive therefore lives entirely inflow-graph/scene-flow-graph.ts, pulled into a bundle only when something imports it (the glTF interactivity feature or user code). - Blocks never see the scene. Anything scene-dependent is pre-resolved by the
loader into plain capability objects stored in
FgEnv:- Object accessors (
FgAccessor { get, set?, target }) — closures over the already-loaded Lite scene objects, produced when a glTF JSON pointer is resolved. Apointer/getblock just callsaccessor.get(). - Animation handles — references to the
AnimationGroupplain-data objects the glTF loader already created. - Event sources — an
FgEventBusthe scene driver feeds (tick, start, pointer, key). Event blocks subscribe to the bus, not to the scene.
- Object accessors (
This keeps the dependency arrow pointing scene → flow-graph, never the reverse, preserving zero circular deps and tree-shakability.
Public API Surface
All exported from packages/babylon-lite/src/flow-graph/index.ts and re-exported
from the package index.ts. Everything is plain data + standalone functions.
Core data types (flow-graph/types.ts)
/** A value that can flow along a data edge. */export type FgValue = | number | boolean | string | Vec2 | Vec3 | Vec4 | Quat | Mat4 | FgInteger // tagged 32-bit int (see CustomTypes below) | FgMatrix2D | FgMatrix3D | Color3 | Color4 | null | undefined;
/** Type tags. */export const FgType = { Any: "any", Number: "number", Boolean: "boolean", String: "string", Integer: "FlowGraphInteger", Vector2: "Vector2", Vector3: "Vector3", Vector4: "Vector4", Quaternion: "Quaternion", Matrix: "Matrix", Matrix2D: "Matrix2D", Matrix3D: "Matrix3D", Color3: "Color3", Color4: "Color4", /** glTF JSON Pointer string. The empty string is the null reference. */ Reference: "ref",} as const;export type FgType = (typeof FgType)[keyof typeof FgType];
/** A data input/output port — plain data. */export interface FgDataSocket { readonly name: string; readonly type: FgType; /** wired source (for inputs): producing block id + its output socket name */ source?: { blockId: string; socket: string }; /** literal fallback used when `source` is undefined */ defaultValue?: FgValue;}
/** A control-flow (signal) port — plain data. Push model. */export interface FgSignalSocket { readonly name: string; /** wired targets (for outputs): consuming block id + its input signal name */ readonly targets: { blockId: string; socket: string }[];}
/** A node instance — PURE DATA describing topology + config only. */export interface FgBlock { readonly id: string; readonly type: string; // FgBlockType value or "module/Name" readonly config?: Readonly<Record<string, unknown>>; readonly dataIn: readonly FgDataSocket[]; readonly dataOut: readonly FgDataSocket[]; readonly signalIn: readonly FgSignalSocket[]; readonly signalOut: readonly FgSignalSocket[]; /** declared by event blocks; which bus event activates them */ readonly event?: FgEventType;}
/** A parsed graph — pure data. */export interface FgGraph { readonly blocks: readonly FgBlock[]; /** id → block index, for O(1) edge resolution (built by the parser) */ readonly byId: Readonly<Record<string, number>>; /** declared graph variables: name → { type, initialValue } */ readonly variables: Readonly<Record<string, { type: FgType; value: FgValue }>>;}Behaviour definitions (flow-graph/block-def.ts)
/** The shape a def declares when a block is instantiated. */export interface FgBlockShape { dataIn?: FgDataSocket[]; dataOut?: FgDataSocket[]; signalIn?: FgSignalSocket[]; signalOut?: FgSignalSocket[]; event?: FgEventType;}
/** * Pure behaviour record for one block type. No classes, no `this`. * Exactly ONE of these per block kind; this is what a porter writes. */export interface FgBlockDef { readonly type: string;
/** Declare sockets/signals from config (called once at instantiation). */ readonly build: (config: Readonly<Record<string, unknown>> | undefined) => FgBlockShape;
/** DATA blocks: compute outputs from inputs (PULL). Pure-ish: writes via setDataValue. */ readonly updateOutputs?: (block: FgBlock, ctx: FgContext, env: FgEnv) => void;
/** EXECUTION blocks: run when an input signal fires (PUSH). For async blocks * this is also where a task is started, via addPending(ctx, block). */ readonly execute?: (block: FgBlock, ctx: FgContext, env: FgEnv, incomingSignal: string) => void;
/** ASYNC blocks: advance one outstanding task each frame (e.g. delay countdown, * animation progress). The tick loop passes the specific FgPendingTask. */ readonly onTick?: (block: FgBlock, ctx: FgContext, env: FgEnv, deltaMs: number, task: FgPendingTask) => void; /** ASYNC blocks: teardown hook called on dispose/cancel; mark tasks canceled. */ readonly cancelPending?: (block: FgBlock, ctx: FgContext, env: FgEnv) => void;}Execution context & environment (flow-graph/context.ts)
/** Per-execution-instance MUTABLE state — plain data. */export interface FgContext { /** data transport slots: `${blockId}:${socket}` → last value the producer wrote. * NOT a validity cache — producers recompute on every pull (see execution model). */ readonly connectionValues: Record<string, FgValue>; /** per-block scratch: `${blockId}:${key}` → value (counter state, async tokens, resolving guard) */ readonly executionVariables: Record<string, unknown>; /** live graph variable values (seeded from FgGraph.variables) */ readonly userVariables: Record<string, FgValue>; /** async task records, ticked each frame (deduped; carry cancel tokens) */ readonly pending: FgPendingTask[]; /** glTF graphs are right-handed; drives Z/handedness coercion on read */ readonly rightHanded: boolean; /** @internal monotonic token source for addPending (unique task tokens) */ _tokenSeq: number;}
/** One outstanding async task (a delay, an animation). Token enables precise cancel. */export interface FgPendingTask { readonly blockId: string; readonly token: number; // unique per task; a block may own several concurrently canceled: boolean; /** set by onTick when finished; compacted out after the frame's pending loop */ done: boolean; state: Record<string, unknown>; // e.g. remainingMs, delayIndex, animation handle}
/** Per-graph RESOLVED capabilities, wired by the loader. Read-mostly. */export interface FgEnv { readonly graph: FgGraph; /** block defs resolved up-front (awaited dynamic imports), type → def */ readonly defs: Record<string, FgBlockDef>; /** scene-object accessors resolved from JSON pointers, keyed by pointer id */ readonly accessors: Record<string, FgAccessor>; /** animation handles by glTF animation index */ readonly animations: readonly AnimationGroup[]; /** scene-owned capabilities blocks may invoke WITHOUT a scene reference: * play/stop animation, create a temp interpolation group, subscribe to * animation-end, etc. Provided by the loader/animation subsystem. */ readonly caps: FgCapabilities; /** event bus the scene driver feeds */ readonly events: FgEventBus;}
export interface FgAccessor { readonly type: FgType; readonly get: () => FgValue; readonly set?: (value: FgValue) => void; readonly target?: unknown;}Standalone runtime functions (flow-graph/runtime.ts)
/** PULL a data input: resolve source, run its def.updateOutputs, read cache. */export function getDataValue(ctx: FgContext, env: FgEnv, block: FgBlock, socket: string): FgValue;/** Write a data output into the cache (called from def.updateOutputs). */export function setDataValue(ctx: FgContext, block: FgBlock, socket: string, value: FgValue): void;/** PUSH a signal: for each target, dispatch into its def.execute. */export function activateSignal(ctx: FgContext, env: FgEnv, block: FgBlock, socket: string): void;
/** Instantiate runtime state for a parsed graph. */export function createFgContext(graph: FgGraph, opts?: { rightHanded?: boolean }): FgContext;/** Build the resolved env (await needed defs, attach accessors/animations/bus). * Editor-authored unknown block types fail loudly. KHR unsupported operations * are translated to registered typed no-op blocks before this boundary. */export function createFgEnv(graph: FgGraph, wiring?: FgWiring): Promise<FgEnv>;
/** One graph runtime = graph + context + env, owned by the scene. */export interface FgRuntime { readonly graph: FgGraph; readonly context: FgContext; readonly env: FgEnv; started: boolean; /** @internal bus unsubscribe fns registered at start, called on dispose */ _unsub: (() => void)[];}
/** Convenience: build env (awaiting defs) + context in one call. */export function createFgRuntime(graph: FgGraph, wiring?: FgWiring, opts?: { rightHanded?: boolean }): Promise<FgRuntime>;
/** Start the graph: subscribe ALL non-start receivers first (init-priority * order), THEN fire `onStart` event blocks once. Idempotent. */export function startFlowGraph(rt: FgRuntime): void;/** Per-frame drive: pump tick events + advance pending async blocks. */export function tickFlowGraph(rt: FgRuntime, deltaMs: number): void;/** Tear down: cancel pending, clear caches, detach bus listeners. */export function disposeFlowGraph(rt: FgRuntime): void;
// Pending-task helpers used by async block defs and the tick loop:export function addPending(ctx: FgContext, block: FgBlock, state?: Record<string, unknown>): FgPendingTask;export function stillPending(ctx: FgContext, task: FgPendingTask): boolean;export function cancelPendingForBlock(ctx: FgContext, block: FgBlock): void;export function compactPending(ctx: FgContext): void;Scene attachment (flow-graph/scene-flow-graph.ts)
/** Attach a runtime to a scene: starts on the first frame, ticks every frame, * auto-disposed on scene dispose. Lazily creates `scene._flowGraphs`. Uses the * generic onBeforeRender/onSceneDispose seams (no scene-core loop). */export function attachFlowGraph(scene: SceneContext, rt: FgRuntime): void;/** Detach + dispose a previously attached runtime. */export function detachFlowGraph(scene: SceneContext, rt: FgRuntime): void;Block-type names & registry (flow-graph/block-type.ts, block-registry.ts)
/** Lite block type identifiers. */export const FgBlockType = { // Events SceneStart: "SceneReadyEvent", SceneTick: "SceneTickEvent", SendCustomEvent: "SendCustomEvent", ReceiveCustomEvent: "ReceiveCustomEvent", // Control flow Branch: "Branch", Sequence: "Sequence", Switch: "Switch", ForLoop: "ForLoop", WhileLoop: "WhileLoop", DoN: "DoN", MultiGate: "MultiGate", WaitAll: "WaitAll", Throttle: "Throttle", SetDelay: "SetDelay", CancelDelay: "CancelDelay", // Data / math (subset; see block list) Constant: "Constant", Add: "Add", Subtract: "Subtract" /* … */, // Pointer / variable / animation GetProperty: "GetProperty", SetProperty: "SetProperty", JsonPointerParser: "JsonPointerParser", GetVariable: "GetVariable", SetVariable: "SetVariable", ValueInterpolation: "ValueInterpolation", PlayAnimation: "PlayAnimation", StopAnimation: "StopAnimation", // Debug ConsoleLog: "ConsoleLog",} as const;export type FgBlockType = (typeof FgBlockType)[keyof typeof FgBlockType];
/** * Tree-shakable, side-effect-free. Returns a lazy loader for one def. * Unused cases are code-split and never fetched — zero bytes for scenes * without interactivity. Mirrors BJS blockFactory + Lite gltf-feature-registry. */export function getBlockDef(type: string): () => Promise<FgBlockDef> { switch (type) { case FgBlockType.Branch: return async () => (await import("./blocks/control-flow/branch.js")).branchDef; case FgBlockType.Add: return async () => (await import("./blocks/math/add.js")).addDef; // … one case per supported block … default: return null; // unknown type — caller decides (see note) }}Note on side effects: the
switchfunction body is pure (no module-level allocation), so the registry module is fully tree-shakable.Unknown ops:
getBlockDefreturnsnull; each parser chooses the policy. StableKHR_interactivityrequires an unsupported declaration to become an explicit typed no-op so the rest of that graph can still execute. The parser reports the unsupported operation and preserves the declaration's declared sockets on the no-op. Invalid graph structure is different: reject only that graph and continue loading the other graphs in the asset. Editor JSON is authored specifically for Babylon blocks, so an unknownclassNamefails loudly with its block id and class name.
Internal Architecture
Execution model (pull data, push signals)
Identical semantics to BJS, re-expressed functionally:
- Data edges are PULL, and recompute on every pull. When a block needs an
input,
getDataValuelooks at the socket'ssource. If wired, it finds the producing block and invokes that block'sdef.updateOutputsevery time (matching BJSFlowGraphDataConnection.getValue, which calls the owner's_updateOutputson each read).ctx.connectionValuesis the transport slot the producer writes into and the consumer reads back — not a validity cache. Do not skip recomputation based on a cached value: producers likepointer/get,GetVariable,random, and event-payload outputs would return stale data after an interveningpointer/set/SetVariablein the same cascade. (A future optimization may add explicit dirty-tracking with cascade IDs, but it must exclude all non-pure producers; the MVP recomputes.) - Cycles. Data pulls assume an acyclic data subgraph (as glTF interactivity
requires).
getDataValuecarries an in-progress guard (ctx.executionVariables["${id}:resolving"]) to break accidental data cycles and return the socket default rather than recursing infinitely. - Type coercion on read.
getDataValueapplies (a) any socket/portdataTransformerdeclared by the declaration mapper, then (b)coerceValuefor the consumer socket'sFgType— this is where BJS's RichTypetypeTransformerlives (notablyVector4/Matrix→Quaternion). Coercion happens at the boundary so block bodies stay type-clean. - Signal edges are PUSH.
activateSignaliteratessignalSocket.targets; for each, it looks up the target block's def and callsdef.execute, passing the incoming signal name. Execution blocks callactivateSignalon their own outputs to continue the cascade. - Event blocks declare
event: FgEventType. The scene driver, on receiving a bus event, finds matching event blocks and fires their output signals (out/done), starting a cascade. Listener registration ordering is significant:startFlowGraphfirst subscribes/initializes all event receivers (in init-priority order —ReceiveCustomEventbefore scene-start-like events, matching BJSinitPriority), and only then fires theonStartcascade. Otherwise a graph likeonStart → SendCustomEvent → ReceiveCustomEventwould drop the event because the receiver wasn't listening yet. Custom events are scene/coordinator-scoped, not per-graph, so multipleKHR_interactivitygraphs in one asset can communicate. - Async blocks (
SetDelay,PlayAnimation) register anFgPendingTaskviaaddPending(ctx, block)(which assigns a unique token and dedupes so a block re-entered while already pending does not double-tick), getonTick'd each frame, and fire a completion signal (done) when finished, then remove the task. A single block may own multiple concurrent tasks (BJS supports several in-flight delays per block, tracked by index) — state lives on the task record, not the block.cancelPendingmarks the matching task(s)canceled; the tick loop skips canceled/removed tasks (see below).
Async task lifecycle (ordering hazards)
The per-frame pending loop must be cancellation-safe. A task can be canceled or a new task added during the loop by another block's signal cascade. Rules:
for task of ctx.pending.slice(): // snapshot if task.canceled: continue // skip if canceled this frame if !stillPending(ctx, task): continue // skip if already removed env.defs[blockOf(task)].onTick(task, …) // may add/cancel further tasksctx.pending = ctx.pending.filter(t => !t.canceled && !t.done) // compact afterTasks added mid-loop are picked up next frame (not retro-ticked), matching BJS.
Each FgContext has its own pending list, so multiple contexts (multi-actor)
never interfere.
Per-frame flow
scene._beforeRender(deltaMs) // existing Lite hook └─ for rt of scene._flowGraphs: if !rt.started: registerEventListeners(rt) // subscribe ALL receivers first (init-priority order) startFlowGraph(rt) // THEN fire onStart event blocks once tickFlowGraph(rt, deltaMs): resetCustomEventRecursionCounters(rt.context) // guard against runaway re-entrancy env.events.pump("tick", { deltaTime: deltaMs/1000 }) // → onTick blocks for task of ctx.pending.slice(): // cancellation-safe (see async lifecycle) if task.canceled || !stillPending(ctx, task): continue env.defs[blockOf(task)].onTick?.(task, ctx, env, deltaMs) compactPending(ctx.context)Pointer/key events are fed into env.events by the picking/input layer; event
blocks simply subscribe to the corresponding bus channel. Pointer selection is
an explicit capability: call enableFlowGraphPointerPicking(scene) when a scene
uses pointer-event blocks. This keeps the GPU picker out of non-interactive glTF
bundles. Pick dispatch and selectability are scoped to the glTF asset that owns
the picked mesh, so assets may safely reuse the same node indices in one scene.
Custom math & types live IN the subsystem (bundle discipline)
Lite's core math/ module is intentionally minimal (Vec3-centric; no Vec2,
no general quaternion/matrix algebra). Flow-graph math blocks need Vec2,
quaternion mul/conjugate/slerp, matrix transpose/determinant/inverse/compose/
decompose, integer bitwise ops, etc. Per GUIDANCE §4c′ (always extensions, never
bloat the core) these helpers live inside flow-graph/ (e.g.
flow-graph/fg-math.ts, flow-graph/custom-types/), lazily imported by the
blocks that use them. Scenes without interactivity pay zero bytes. Core
math/ is reused where it already suffices (addVec3, dotVec3, crossVec3,
multiplyMat4, invertMat4, composeMat4, decomposeMat4, createMat4FromQuat).
Custom types (flow-graph/custom-types/)
FgInteger— glTF distinguishesintfromfloat; represented as a tagged plain object{ value: number; __fgInt: true }(no class) so type coercion and bitwise ops work. Pure helpersfgInt(n),isFgInt(v).FgMatrix2D/FgMatrix3D— plainFloat32Array-backed ({ m: Float32Array }) forfloat2x2/float3x3glTF types, with pure op helpers.Vec2— add toflow-graph/types.ts(or a tiny local) since core math lacks it.
Rich-type behaviour without RichType instances (flow-graph/rich-type.ts)
BJS's RichType carries three things Lite must preserve even though it drops the
class: a default value, a typeTransformer, and an animationType.
defaultForType(t: FgType): FgValue— pure switch returning the type's default (e.g.0,false, freshVec3.zero, identity quaternion). ReplacesRichType.defaultValue. Constructs values inside the function (no module-level allocation).coerceValue(value, target: FgType): FgValue— the home of BJS'stypeTransformer. Critically includesVector4/Matrix→Quaternionand numeric↔integer coercions. Invoked bygetDataValueon read (step (b) above), and by the mapper when a config/socket declares aflowGraphTypedifferent from itsgltfType.animationTypeForFgType(t: FgType): number— replacesRichType.animationType, used byValueInterpolation/animation blocks to pick the correct keyframe interpolation (float/vector/quaternion/color/matrix). Quaternion targets must resolve to slerp; the mapper'suseSlerpflag forcesFgType.Quaternion.
These three pure functions are the complete replacement for the RichType class
and its module-scope instances.
glTF KHR_interactivity Loader Extension
Package path:
packages/babylon-lite/src/loader-gltf/gltf-feature-interactivity.tsplusflow-graph/gltf/for the parser + declaration mapper.
Release-candidate spec — keep the translation layer isolated
The port originally targeted Babylon.js commit 8f728b23ea while
PR #18455 was still
open. That target is obsolete. The compatibility refresh targets:
- Khronos glTF repository commit
fdb8ce0e2e0b7ecf3466f8dacb9f1385257b8276. - Babylon.js commit
bd3837eed0890e590fdd6aeb6cc4d605e4eb8ac7.
The release candidate was submitted for ratification in July 2026; final ratification was not confirmed when these commits were recorded. The op set, object model, and validation rules can therefore still evolve.
Design rule (mandatory): the spec-volatile surface must be quarantined in
flow-graph/gltf/ and depend on the runtime, never the reverse. The runtime
core (runtime.ts, block-def.ts, context.ts, blocks) must contain zero
glTF/KHR_interactivity knowledge, so a spec revision never touches the engine —
only the gltf/ translation layer:
interactivity-parser.ts— JSON shape of nodes/declarations/variables/flows.declaration-mapper.ts— the op→block table (the part most likely to churn).path-converter.ts+object-model-mapping.ts— JSON-pointer semantics.- a recorded Khronos/Babylon.js reference commit in the mapper header and tests.
Practical guardrails so future spec changes are cheap:
- Keep the op→block mapping a plain-data table, not code, so edits are diffs to data (and so a future "import the BJS table" step stays mechanical).
- Re-diff
declaration-mapper.tsagainst the recorded Babylon.js commit when updating either reference. Keep an operation-coverage test that compares all known native operations with registered mappings. - Do not invent an asset-level version switch: the release-candidate extension has no such discriminator. If a future revision adds one, branch at this translation boundary.
- Unknown operations produce structured diagnostics and typed no-op blocks. Structural/schema violations reject only the affected graph.
- The companion skill (
port-flow-graph-block.md) is the routine for absorbing new/changed ops as the spec and the BJS PR evolve.
Registration (no side effects, lazy)
Add one tuple to gltf-feature-registry.ts, identical to every other feature:
["KHR_interactivity", () => import("./gltf-feature-interactivity.js")],The feature implements GltfFeature.applyAsset(meshes, root, ctx):
- Read
ctx._json.extensions.KHR_interactivity.graphsis required; optionalgraphselects the default graph. - For each graph, run the interactivity parser →
FgGraph(plain data). Catch parse/validation errors per graph so one invalid graph cannot invalidate the asset. - Resolve JSON pointers in the graph to
FgAccessors over the already-built Lite scene objects (nodes/meshes/cameras/materials/animations) via a path converter (Lite analogue of BJSgltfPathToObjectConverter+objectModelMapping). - Build
FgEnv(await needed block defs, attach accessors/animations/event bus). - Return the successfully parsed graphs in the
AssetContainerand contribute a deferred_sceneSetuphook.addToSceneresolves runtimes with the target scene's shared event bus, pushes them ontoscene._flowGraphs, and registers paired cleanup. Callers do not need to invokerunFlowGraphsmanually.
The loader sets the equivalent of BJS's
_skipStartAnimationStep— animations referenced by interactivity must not auto-play; the graph controls them.
Interactivity parser (flow-graph/gltf/interactivity-parser.ts)
Pure translation of each glTF interactivity graph
(types, declarations, variables, events, and nodes) into an
FgGraph. Stages mirror BJS InteractivityGraphToFlowGraphParser:
parseTypes → parseDeclarations → parseVariables → parseEvents → parseNodes → parseConnections. Output is plain data — no block instances, no class registry.
glTF→Lite type table (from BJS, kept verbatim):
| glTF type | length | FgType | element |
|---|---|---|---|
float | 1 | Number | number |
bool | 1 | Boolean | boolean |
int | 1 | Integer | number |
float2 | 2 | Vector2 | number |
float3 | 3 | Vector3 | number |
float4 | 4 | Vector4 | number |
float2x2 | 4 | Matrix2D | number |
float3x3 | 9 | Matrix3D | number |
float4x4 | 16 | Matrix | number |
ref | 1 | Reference | JSON Pointer string ("" = null) |
Release-candidate parser semantics:
- Type/default validation follows the Khronos schemas. Numeric, vector, and matrix defaults preserve the spec's NaN defaults where specified.
eventsdeclares custom-event ids and typed payload fields. Send/receive blocks use those declarations for socket types and defaults.- Event receivers expose an opaque event reference.
event/stopPropagationconsumes it and prevents later receivers in the same dispatch from running. - glTF custom-event delivery is asynchronous by default. The coordinator's
editor-JSON
dispatchEventsSynchronouslyoption remains independently configurable. - A connected animation-time input is converted from seconds to frames at runtime; constants may be pre-transformed.
- Pointer templates validate all placeholders and support dynamic segments. Virtual/object-model pointers include the release-candidate active-camera, capability/limit, event-reference, delay, and animation-state surfaces.
Declaration mapper (flow-graph/gltf/declaration-mapper.ts)
The largest single artefact (current BJS = 135 native ops). It is a data
table mapping each glTF op ("math/add", "flow/branch", "pointer/set", …)
to: target Lite block type(s), socket renames, config translation, value
transformers (e.g. seconds→frames for animation time), and multi-block expansions
(e.g. pointer/set → SetProperty + JsonPointerParser linked by an
inter-block connector). This is plain data + small pure transformer functions —
no classes. Porting entries is the bulk of ongoing work and is exactly what the
companion skill automates.
export interface FgDeclMapping { blocks: FgBlockType[]; inputs?: { values?: Record<string, FgPortMap>; flows?: Record<string, FgPortMap> }; outputs?: { values?: Record<string, FgPortMap>; flows?: Record<string, FgPortMap> }; configuration?: Record<string, FgPortMap>; interBlockConnectors?: { input: string; output: string; inBlock: number; outBlock: number }[]; extraProcessor?: (gltfBlock: unknown, /* … */) => FgBlock[];}export function getMappingForOp(op: string, extension?: string): FgDeclMapping | undefined;Block coverage (KHR_interactivity)
KHR_interactivity maps 135 native operations onto fewer Lite block types via
configuration. The refresh adds the ten operations absent from the original
126-operation target: event/stopPropagation, math/Tau,
math/smoothStep, math/rgbToOkLCh, math/rgbFromOkLCh,
math/quatSlerp, math/slerp, ref/eq, math/quatFromUpForward, and
math/quatFromAngles.
- Events (4):
onStart→SceneStart,onTick→SceneTick,event/send→SendCustomEvent,event/receive→ReceiveCustomEvent. - Flow control (11): branch, sequence, switch, while, for, doN, multiGate, waitAll, throttle, setDelay, cancelDelay.
- Math constants/arithmetic/comparison/trig/exp (~60 ops → ~40 blocks): E, Pi, Inf, NaN, random; abs/sign/trunc/floor/ceil/round/fract/neg; add/sub/mul/div/rem/min/max/clamp/saturate/mix; eq/lt/le/gt/ge/select; sin…atanh; exp/log/log2/log10/sqrt/cbrt/pow; isNaN/isInf; rad/deg.
- Vector/matrix/quaternion (~25 ops): length, normalize, dot, cross, rotate2D/3D, transform, transpose, determinant, inverse, matMul, matCompose/Decompose, combineN/extractN, quat ops.
- Integer bitwise (9): not/and/or/xor/asr/lsl/clz/ctz/popcnt.
- Type conversion (6): bool/int/float cross conversions.
- Variables (3): get, set, interpolate.
- Pointers (3): get, set, interpolate (+ JsonPointerParser).
- Animation (3): start, stop, stopAt (+ ArrayIndex / data provider).
flow/logis a Babylon extension operation rather than a native KHR operation.event/onSelectremains extension-namespaced throughKHR_node_selectability.
Each block is one small file under flow-graph/blocks/<category>/<name>.ts
exporting a FgBlockDef. See the skill doc for the exact file template.
State Machine / Lifecycle
load .glb with KHR_interactivity └─ gltf-feature-interactivity.applyAsset ├─ interactivity-parser → FgGraph (pure data) ├─ path-converter → FgAccessor map ├─ createFgEnv (await needed defs, wire bus/accessors/animations) ├─ createFgContext (seed userVariables) └─ return { flowGraphs:[ FgRuntime ] }addToScene └─ scene._flowGraphs.push(rt); onBeforeRender(scene, drive); onSceneDispose(scene, () => disposeFlowGraph(rt))first frame └─ registerEventListeners(rt) // subscribe ALL receivers first, init-priority order └─ startFlowGraph(rt) // THEN fire onStart cascade onceeach frame └─ tickFlowGraph(rt, deltaMs) // reset recursion counters → pump tick → advance pending asyncdispose └─ disposeFlowGraph(rt) // cancel pending tasks, clear ctx, detach bus listenersBabylon.js Equivalence Map
| Babylon.js | Babylon Lite |
|---|---|
FlowGraphBlock (class) | FgBlock (data) + FgBlockDef (functions) |
FlowGraphExecutionBlock._execute | FgBlockDef.execute |
FlowGraphBlock._updateOutputs | FgBlockDef.updateOutputs |
FlowGraphAsyncExecutionBlock | FgBlockDef.execute (starts task) + onTick(task) + cancelPending |
FlowGraphEventBlock | FgBlock.event + scene-driven bus dispatch |
FlowGraphDataConnection.getValue/setValue | getDataValue / setDataValue |
FlowGraphSignalConnection._activateSignal | activateSignal |
FlowGraphContext | FgContext (data) + FgEnv (resolved caps) |
FlowGraphCoordinator (multi-graph) | scene._flowGraphs + scene driver |
FlowGraphSceneEventCoordinator | FgEventBus fed by scene/input layer |
RichType instances | FgType const object + defaultForType() |
RegisterClass global registry | getBlockDef dynamic-import switch |
blockFactory (already lazy) | getBlockDef (same shape, no classes) |
gltfPathToObjectConverter + objectModelMapping | flow-graph/gltf/path-converter.ts → FgAccessor |
InteractivityGraphToFlowGraphParser | flow-graph/gltf/interactivity-parser.ts |
declarationMapper.ts table | flow-graph/gltf/declaration-mapper.ts table |
KHR_interactivity loader extension | loader-gltf/gltf-feature-interactivity.ts |
Dependencies
- Core math (
src/math/): reuseaddVec3,dotVec3,crossVec3,multiplyMat4,invertMat4,composeMat4,decomposeMat4,createMat4FromQuat. - Animation (
src/animation/):AnimationGrouphandles for animation blocks;ValueInterpolationreuses the interpolation/easing machinery where possible. - Scene (
src/scene/):onBeforeRender,onSceneDispose,addToScenedispatch, the_flowGraphsarray (new field). - Loader-gltf (
src/loader-gltf/):GltfFeaturehook + registry tuple; the node/material/camera maps the path-converter resolves against. - Picking/input: pointer & key events forwarded into
FgEventBus; the GPU pointer bridge is enabled explicitly withenableFlowGraphPointerPicking. - New, subsystem-local:
flow-graph/fg-math.ts,flow-graph/custom-types/.
Test Specification
- Unit (vitest): per-block defs — feed inputs, assert outputs/signals
(pure functions, trivial to test). Runtime: pull recompute (verify a
producer re-runs on every read, no stale cache), push cascade, async delay
countdown with cancellation mid-tick and multiple concurrent delays per
block, event dispatch with listener-before-onStart ordering (custom event
fired from
onStartis received). - Math parity tests (focused): quaternion mul/conjugate/slerp,
matrix compose/decompose/transpose/inverse,
math/transform, and the accessor-boundary handedness (right-handed glTF value → Lite LH) — these are the easiest places to diverge on multiplication order / row-vs-column layout. - Coercion tests:
Vector4/Matrix→QuaternionviacoerceValue;animationTypeForFgTypepicks slerp for quaternion targets. - Parser unit tests: representative interactivity JSON → expected
FgGraphtopology; declaration-mapper entries → expected blocks/sockets/config; unsupported op → typed no-op + diagnostic; malformed graph → only that graph rejected. - Editor JSON tests: coordinator and legacy single-graph fixtures saved by the current Babylon.js Flow Graph Editor; rich-value/default/context restore; connection reconstruction by connection-point id; execute the parsed graph; unknown editor block → precise diagnostic.
- Integration / parity: a
KHR_interactivitysample.glb(e.g. a Khronos sample like a button that animates on click, oronStart→rotate) loaded in Lite; assert the driven property changes over frames. Where a visual golden is warranted, follow §2c animated-scene golden convention (?seekTime=freeze). Add ascene-config.jsonentry + bundle-size ceiling only when a parity scene is added. - Bundle-size guard: verify a non-interactivity scene's bundle is byte-unchanged (the subsystem must be fully tree-shaken away when unused).
File Manifest (target)
packages/babylon-lite/src/flow-graph/ index.ts # public exports types.ts # FgValue, FgType, FgBlock, FgGraph, sockets block-def.ts # FgBlockDef, FgBlockShape block-type.ts # FgBlockType const object + value-union type block-registry.ts # getBlockDef() dynamic-import switch context.ts # FgContext, FgEnv, FgAccessor runtime.ts # getDataValue/setDataValue/activateSignal, FgRuntime, start/tick/dispose event-bus.ts # FgEventBus, FgEventType, subscribe/pump/clear fg-math.ts # Vec2 + quaternion/matrix/bitwise helpers (lazy) rich-type.ts # defaultForType(), coerceValue(), animationTypeForFgType() scene-flow-graph.ts # attachFlowGraph/detachFlowGraph (onBeforeRender/onSceneDispose seams) graph-builder.ts # imperative builder shared by parser adapters editor-serialization.ts # coordinator/single-graph editor JSON → FgGraph custom-types/ fg-integer.ts fg-matrix.ts blocks/ events/{scene-start,scene-tick,send-custom-event,receive-custom-event}.ts control-flow/{branch,sequence,switch,for-loop,while-loop,do-n,multi-gate,wait-all,throttle,set-delay,cancel-delay}.ts math/{add,subtract,…}.ts data/{constant,get-variable,set-variable,get-property,set-property,json-pointer-parser}.ts animation/{play-animation,stop-animation,value-interpolation}.ts debug/console-log.ts no-op.ts gltf/ interactivity-parser.ts declaration-mapper.ts path-converter.ts object-model-mapping.tspackages/babylon-lite/src/loader-gltf/ gltf-feature-interactivity.ts # GltfFeature.applyAsset gltf-feature-registry.ts # + ["KHR_interactivity", () => import(...)]packages/babylon-lite/src/scene/ scene-core.ts # + optional `_flowGraphs?` field (type-only; zero runtime cost)Note: the scene driver is NOT hardcoded in
scene-core.ts.scene-flow-graph.tsattaches via the existingonBeforeRender/onSceneDisposeseams, so non- interactivity scenes stay byte-identical (verified against the bundle manifest).
Phased Implementation Plan
Each phase is independently mergeable; engine-changing phases must pass
pnpm build:bundle-scenes+pnpm test:parityand commit the regeneratedlab/public/bundle/manifest.json(GUIDANCE §0c).
Phase 0 — Spec & skill (this doc + port-flow-graph-block.md). ✅ DONE — no code.
Phase 1 — Core runtime, no blocks. ✅ DONE. types.ts, block-def.ts,
context.ts, runtime.ts, event-bus.ts, rich-type.ts, block-type.ts,
custom-types/{fg-integer,fg-matrix}.ts, scene-flow-graph.ts, and an empty
block-registry.ts. Pure functions only. 24 unit tests cover pull recompute,
push cascade, branch routing, async pending (countdown, dedupe, cancel
mid-tick, no retro-tick), event dispatch + custom-event-during-start ordering,
tick pump, dispose, variable seeding, loud-fail on unknown ops, and rich-type
defaults/coercion. Scene drive wired via the onBeforeRender/onSceneDispose
seams. Guard met: non-interactivity bundle manifest byte-identical.
Phase 2 — Vertical slice (end-to-end EARLY). Implement the minimum set that
proves the whole pipeline before the long tail of blocks: SceneStart,
SceneTick, Branch, Sequence, one math op (Add), GetProperty/SetProperty
JsonPointerParser,PlayAnimation/StopAnimation; plus a minimal parser + declaration-mapper (just those ops) + path-converter + thegltf-feature-interactivity.tsloader hook; plus one KhronosKHR_interactivitysample running end-to-end as a parity scene. This surfaces mapper/accessor/runtime/handedness mismatches immediately rather than after the whole block library is written.
Phase 3 — Broaden block library. Fill in the rest of the ~60 blocks
(full math/trig/exp, vector/matrix/quaternion, integer bitwise, type conversion,
variables, interpolation, control-flow remainder) + fg-math.ts +
custom-types/**; register each in block-registry.ts. Unit-test each def.
Phase 4 — Complete the original declaration mapper. ✅ DONE for the old 126-operation target. Additional parity scenes remain optional coverage work.
Phase 5a — Imperative builder and scene coordinator. ✅ DONE. Builder creates the
same FgGraph plain data as parser front-ends. The coordinator owns multiple
graphs, one scene event bus, and synchronous/asynchronous custom-event policy.
Phase 5b — Release-candidate KHR refresh. ✅ DONE for the current Lite host
surface. Rebase parser/mapper behavior on
the recorded Khronos and Babylon.js commits. Add the ten operation gaps, ref,
declared events/payloads, event references and propagation, typed no-ops,
per-graph isolation, asynchronous event dispatch, dynamic pointer segments,
expanded object-model pointers, and runtime animation-time conversion.
Phase 5c — Babylon.js Flow Graph Editor JSON. ✅ DONE for serialized block
classes ported to Lite. Add a second parser front-end
alongside gltf/interactivity-parser.ts. Supported input forms:
// Coordinator form{ _flowGraphs: SerializedFlowGraph[], activeGraphIndex, dispatchEventsSynchronously, sceneSnippetId?, flowGraphSnippetId? }
// Legacy form{ allBlocks: SerializedBlock[], executionContexts: SerializedContext[], name?, uniqueId?, rightHanded?, editorData? }SerializedBlock contains className, type, config, uniqueId,
data/signal inputs and outputs, and metadata. Connections are reconstructed from
connection-point uniqueId/connectedPointIds. Values may come from a
connection's defaultValue or a context's _connectionValues; rich values use
{ value, className }. The adapter maps Babylon block class names to Lite
FgBlockType, restores user variables, and returns one graph/context record per
serialized execution context. It also recognizes serialized JSON embedded in
the Babylon-specific BABYLON_flow_graph glTF extension. Unsupported
editor-only blocks fail clearly; they are not typed no-ops.
Phase 6+ — Editor-only block parity and debugger hooks. Port blocks as needed using the companion skill. Current editor-only gaps include input, interpolation-animation construction, debounce/flip-flop, coordinate transforms, easing/context/array/code, physics, and audio blocks.
Open Questions / Risks
- Release-candidate churn. Final ratification was not confirmed at the
recorded reference commits. Mitigation remains structural: all
spec-dependent code is quarantined in
flow-graph/gltf/and mirrored against exact Khronos/Babylon.js commits; the runtime core remains spec-agnostic. - Editor format churn. Editor JSON is a Babylon.js serialization contract,
not the Khronos format. Keep it isolated in
flow-graph/editor-serialization.ts, test current official fixtures, and fail loudly when a serialized class is unknown. - Host object-model breadth. Pointer support is limited to concepts exposed
by Lite. Add accessors in
path-converter.tsas Lite gains more glTF host objects; unsupported pointers remain runtime-invalid rather than crashing.