Skip to content

Scene Graph

The Isometric library is organized into three layers. Understanding these layers helps you reason about performance, debugging, and advanced usage patterns.

+---------------------------------------------+
| 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.

Every composable maps to exactly one node type:

ComposableNode TypePurpose
ShapeShapeNodeSingle 3D geometry with color, position, rotation, and scale
GroupGroupNodeTransform container — children inherit translation, rotation, and scale
PathPathNodeSingle 2D polygon face with color (useful for custom faces or flat overlays)
BatchBatchNodeMultiple shapes submitted together for efficient bulk rendering
CustomNodeCustomRenderNodeEscape 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)

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:

  1. The changed node marks itself dirty.
  2. Each ancestor up to the root is marked dirty.
  3. The root’s onDirty callback fires.
  4. sceneVersion increments.
  5. 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` changes
var 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 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 appear
  • remove — delete nodes when composables leave the composition
  • move — reorder nodes when a ForEach list 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.

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:

  1. Engine and light direction rarely change — they are typically set once per scene.
  2. When they do change, every shape is affected — a new light direction reshades every face, so targeted invalidation would save no work.
  3. 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.

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 via IsometricApplier, bypassing the Shape/Group composables.
  • CustomNode — emit raw RenderCommand objects from within the composition.
  • Standalone engine — use IsometricEngine without Compose for server-side rendering, image export, or non-Compose UI frameworks.

See Advanced Patterns for worked examples of each approach.

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 context
  • applyTransformsToShape(shape) — applies all accumulated transforms to a Shape
  • applyTransformsToPath(path) — applies all accumulated transforms to a Path
  • applyTransformsToPoint(point) — applies all accumulated transforms to a single Point

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 */)
})