Scene Graph
The Isometric library is organized into three layers. Understanding these layers helps you reason about performance, debugging, and advanced usage patterns.
Three-Layer Architecture
Section titled “Three-Layer Architecture”+---------------------------------------------+| Composable API || Shape, Group, Path, Batch, CustomNode || ForEach, IsometricScene |+---------------------------------------------+ | builds / updates v+---------------------------------------------+| Node Tree || ShapeNode, GroupNode, PathNode, || BatchNode, CustomRenderNode || Dirty tracking, transform accumulation |+---------------------------------------------+ | traverses / projects v+---------------------------------------------+| Rendering Engine || IsometricEngine -> PreparedScene -> Canvas || Depth sorting, culling, path projection |+---------------------------------------------+Composable API is what you write. Shape, Group, and Path are @Composable functions that describe what to render declaratively.
Node Tree is what Compose maintains. Each composable maps to an IsometricNode subclass. The tree tracks parent-child relationships, accumulated transforms, and dirty state.
Rendering Engine is what produces pixels. IsometricEngine traverses the node tree, projects 3D geometry to 2D screen coordinates, sorts by depth, and emits a PreparedScene containing RenderCommand objects that are drawn to the canvas.
Node Types
Section titled “Node Types”Every composable maps to exactly one node type:
| Composable | Node Type | Purpose |
|---|---|---|
Shape | ShapeNode | Single 3D geometry with color, position, rotation, and scale |
Group | GroupNode | Transform container — children inherit translation, rotation, and scale |
Path | PathNode | Single 2D polygon face with color (useful for custom faces or flat overlays) |
Batch | BatchNode | Multiple shapes submitted together for efficient bulk rendering |
CustomNode | CustomRenderNode | Escape hatch — emit raw RenderCommand objects directly |
GroupNode is the only node type that has children. All others are leaf nodes. A typical scene tree looks like:
Root (GroupNode) +-- GroupNode (translated) | +-- ShapeNode (a prism) | +-- ShapeNode (a cylinder) +-- ShapeNode (standalone shape) +-- PathNode (a flat face)Dirty Tracking
Section titled “Dirty Tracking”When a node’s properties change (for example, a ShapeNode’s color or position), the node calls markDirty(). This propagates up the tree to the root:
- The changed node marks itself dirty.
- Each ancestor up to the root is marked dirty.
- The root’s
onDirtycallback fires. sceneVersionincrements.- The canvas is invalidated, triggering a redraw.
On the next frame, only the dirty subtree is re-traversed. Clean subtrees are skipped entirely. This is why animating one shape in a 200-shape scene is cheap — 199 nodes are untouched.
// Only this shape recomposes when `color` changesvar color by remember { mutableStateOf(IsoColor.BLUE) }
IsometricScene { Shape(geometry = Prism(Point.ORIGIN), color = color) // recomposes Shape(geometry = Prism(Point(2.0, 0.0, 0.0))) // untouched Shape(geometry = Prism(Point(4.0, 0.0, 0.0))) // untouched}The Compose Runtime Integration
Section titled “The Compose Runtime Integration”The library uses Compose’s tree-management runtime (the same machinery that powers Compose UI and Compose HTML). The key integration point is IsometricApplier, which implements the Applier<IsometricNode> interface.
Compose calls methods on the applier to build and update the node tree:
insertTopDown/insertBottomUp— add new nodes when composables appearremove— delete nodes when composables leave the compositionmove— reorder nodes when aForEachlist changes
This is analogous to how Compose UI uses UiApplier with LayoutNode. The key difference is that isometric nodes have no layout pass — they go straight from the node tree to projection.
Why staticCompositionLocalOf?
Section titled “Why staticCompositionLocalOf?”All Isometric CompositionLocal values use staticCompositionLocalOf rather than compositionLocalOf. Static locals do not track reads per-composable, so when the value changes, the entire subtree beneath the provider recomposes. This is the correct trade-off because:
- Engine and light direction rarely change — they are typically set once per scene.
- When they do change, every shape is affected — a new light direction reshades every face, so targeted invalidation would save no work.
- Lower overhead — static locals skip per-read tracking, reducing memory and allocation pressure during composition.
If you need a value that changes frequently and only affects a few consumers, prefer regular Compose mutableStateOf over a CompositionLocal.
High-Level vs Low-Level
Section titled “High-Level vs Low-Level”Most users work at the composable level:
IsometricScene { Group(position = Point(1.0, 0.0, 0.0)) { Shape(geometry = Prism(Point.ORIGIN), color = IsoColor.BLUE) }}Advanced users can drop to lower levels for more control:
ComposeNode— direct node manipulation viaIsometricApplier, bypassing theShape/Groupcomposables.CustomNode— emit rawRenderCommandobjects from within the composition.- Standalone engine — use
IsometricEnginewithout Compose for server-side rendering, image export, or non-Compose UI frameworks.
See Advanced Patterns for worked examples of each approach.
RenderContext
Section titled “RenderContext”RenderContext accumulates transforms as the engine traverses the node tree. When a ShapeNode sits inside a rotated GroupNode, the context carries the group’s rotation so the shape inherits it.
The context provides four methods:
withTransform(transform)— pushes a transform onto the stack and returns a new contextapplyTransformsToShape(shape)— applies all accumulated transforms to aShapeapplyTransformsToPath(path)— applies all accumulated transforms to aPathapplyTransformsToPoint(point)— applies all accumulated transforms to a singlePoint
You rarely interact with RenderContext directly. It is used internally during tree traversal and is exposed in CustomRenderNode for advanced escape-hatch rendering:
CustomNode(render = { context, nodeId -> val transformedPoint = context.applyTransformsToPoint(Point(1.0, 1.0, 0.0)) // Emit a list of RenderCommand using the transformed coordinates listOf(/* your RenderCommands here */)})