Retained containers
Declare a large, mostly-static subtree as a RetainedContainer so it replays as O(batches) and moves as a single GPU-matrix update — and learn the group-local space, invalidation, and lifecycle rules the tier trades for that speed.
Retained containers
The scene graph earns its keep on content that changes: every frame the engine walks the tree, resolves transforms, culls, and rebuilds the render plan. For a subtree that is mostly static and/or moves as a whole — a decorated tilemap, a parallax backdrop, a built-once UI panel with hundreds of icons — that per-frame walk is pure overhead. You already know the subtree looks the same as last frame; the engine re-derives it anyway.
RetainedContainer is a Container that lets you declare that fact. While its subtree is unchanged, the whole previously-collected command range is spliced straight into the render plan — no walk, no per-child culling, no material keys — and moving the container (or the camera over it) changes exactly one per-group GPU matrix instead of touching every descendant.
It is the third rendering tier, sitting between the two you already have:
- Plain scene graph — the default. Flexible, re-walked every frame. Right for anything that changes.
RetainedContainer— a static subtree that replays whole and moves as a unit.- Immediate mode — no nodes at all; you draw geometry yourself each frame. Right for throwaway, procedural, or self-simulated content.
When to reach for it
A subtree is a good RetainedContainer when all of these hold:
- It is large. A handful of nodes is not worth the machinery — the per-frame walk was already cheap. The win scales with child count.
- It is static, or nearly so. Children are authored once and then left alone. The group as a whole may still move, rotate, or scale freely — that is the point.
- It moves as a unit, if it moves. Panning a camera over a static world, scrolling a background, sliding a whole panel on-screen. One matrix update covers the entire subtree.
Reach for immediate mode instead when the content is procedural or changes completely every frame; reach for a plain container when children animate independently. RetainedContainer is specifically the “thousands of things that sit still while the camera glides over them” case.
One special case: a streamed tile layer (see Infinite maps) mutates structurally every time a chunk loads or unloads. Give it its own RetainedContainer or none at all — sharing a group with unrelated static content drags that whole group through re-capture on every chunk boundary the camera crosses.
It is opt-in, and only at construction
There is no runtime toggle and no configuration. You choose the tier by constructing a RetainedContainer instead of a Container; everything else is the normal node API. If the subtree turns out to be a poor fit, swapping back is a one-word edit.
Declaring one
import { RetainedContainer } from '@codexo/exojs';
// Opt in at construction. From here it is an ordinary Container.
const decor = new RetainedContainer();
Fill it once with static children, add it to your scene, and move only the group:
override init(): void {
this.decor = new RetainedContainer();
for (const tile of this.level.decorTiles) {
const sprite = new Sprite(this.atlas);
sprite.setPosition(tile.x, tile.y);
this.decor.addChild(sprite);
}
}
override draw(context: RenderingContext): void {
// Panning the camera over the world is ONE group-matrix update - no
// descendant transform is recomputed, no child is re-collected.
this.decor.setPosition(-this.cameraX, -this.cameraY);
context.render(this.decor);
}Transform semantics: group-local space
The speed comes from a deliberate trade: descendants of an engaged group resolve their transforms in group-local space, and the group matrix is applied once, on the GPU, at playback. That is invisible to rendering — the pixels are identical to a plain container — but it changes what spatial queries return inside the group.
getBounds()and hit-testing on a child report group-local coordinates, not world coordinates.- Per-child view culling is disabled inside the group; the group is culled as a whole.
pixelSnapModeis resolved entirely on the GPU against the composed world origin in both modes —positionrounds the origin,geometryadditionally rounds the boundaries — so snapping stays correct inside a group and never opts it out of batch recording.
For a true world-space position or orientation of a node inside the group — picking, spatial audio, physics, any math against nodes outside the group — use getWorldTransform(), which composes through the group boundary and returns the real world matrix:
override update(): void {
// getBounds() here is group-local. For a real world position, compose
// through the boundary:
const worldMatrix = this.enemyInsideGroup.getWorldTransform();
this.enemyVoice.setPosition(worldMatrix.x, worldMatrix.y);
}getBounds() inside a group is group-local
This is the one behavioural surprise of the tier. If you read a child’s getBounds() and expect world coordinates, you will be off by the group’s transform. getWorldTransform() is the escape hatch, and it is exact — it accounts for group engage/disengage flips too.
The engine has no inherited alpha, so a group-wide fade is not a property you set on the container. Tint each drawable, or cache the whole group as a bitmap and fade that.
Invalidation: any mutation drops the fragment
The retained fragment is kept in lockstep with the subtree by the same revision contract every node already uses. Any mutation inside the subtree drops the fragment for one frame — that frame re-walks and re-collects normally, then the fragment is recaptured. Adding or removing a child, changing a sprite’s texture, moving a descendant: each one invalidates automatically. You never manage the cache by hand.
If you mutate through a custom Drawable backed by externally mutable data (the pattern the tilemap package uses), call invalidateContent() after the mutation so the skip does not serve a stale frame.
A group that changes every frame is pure overhead
The tier pays off only while the subtree holds still. If a child mutates on every frame, the fragment is dropped on every frame — you pay the full walk plus the retention bookkeeping, and a reference build measured the retained path ~1.5× slower than immediate mode on fully-dynamic content. In a development build the engine watches for this and warns once if a group invalidates on effectively every frame. If you see that warning, split the moving children out into a sibling plain container and keep only the static remainder retained.
From entries to recorded batches
The whole-range splice described above is the fragment’s first tier — call it entry replay. It already removes the walk, but the spliced entries are still individual draw entries that the backend processes one by one.
On the first clean frame after a capture, each backend (WebGL2 and WebGPU alike, across every renderer — sprites, nine-slices, repeating sprites, meshes, tilemap chunks, and text) uses that entry replay as the source for a second, faster tier: it records a compact, backend-native instruction set — the batches it would submit to the GPU — instead of re-deriving them from entries every frame. Every subsequent clean frame replays that recorded instruction set directly: no entries, no per-drawable material-key resolution, just the already-batched GPU work reissued with the group’s current matrix.
That is what makes the cost O(batches) rather than O(nodes) in the group. It is also why rendering the same retained subtree through more than one View at once — split-screen, a minimap, picture-in-picture — stays cheap: each additional view replays the same recorded batches, it does not re-walk or re-collect the subtree per view. A single descendant transform move still patches just that node’s row in place (see below) rather than dropping the whole recording; any structural change (add/remove/texture swap) drops the recording and the next clean frame re-records it from a fresh entry replay.
Measured, not assumed
On a real-GPU run (WebGL2 and WebGPU, both backends), a 100k-sprite retained static-heavy scene costs 0.215 ms (WebGL2) and 0.252 ms (WebGPU) per frame — the two backends within ~1.17× of each other, both near the CPU-timer floor. On batch-breaking (a scene deliberately designed to defeat sprite-batching with many distinct textures) at 25k retained nodes, WebGL2 costs 23.7 ms and WebGPU 4.6 ms — WebGPU is the faster backend on this workload. Both are the recorded-batch tier at work: cost tracks batch count, not node count.
Lifecycle
Adding, removing and destroying children all work exactly as on a plain container, and all three correctly invalidate the fragment.
The fragment holds the whole previously-collected command range, so it keeps references to the resources of every child until something drops it. That something is the structure revision: destroy() unlinks the node from its parent as its first step, and that removal bumps the revision up to the group boundary. Whether you call retained.removeChild(child) first or destroy the child in place, the next frame re-collects without it — no stale replay of freed resources either way. Destroying the whole RetainedContainer tears down its subtree and releases the retained GPU bundle together.
The reverse direction matters just as much: a render root you never destroy holds GPU memory for as long as the backend lives. From the frame it is first recorded, a root owns a group-scoped instance, transform and tint buffer, and the backend keeps that bundle in its registry until either the root is destroyed or the backend itself is. A plain Container you simply drop on the floor costs nothing GPU-side; a retained one does. Deterministic destroy() is the ownership contract here by design — the engine deliberately does not hang GPU lifetime off garbage collection, because the collector decides neither when nor in what order memory comes back, and it would reclaim last exactly under the load where you need it most. Drop a retained root the way you drop a texture: destroy it.
Effects on children: supported, with one depth rule
Nodes with filters, a mask, a clip, or cacheAsTexture are supported as direct children of a RetainedContainer. They stay in world space and re-collect every frame, layered correctly with the retained remainder.
Nesting such an effect-bearing node deeper than one level below the group boundary disengages the whole group: it falls back to rendering as an exact plain Container — correct pixels, no retention — and warns once in a development build. Keep effect-heavy nodes at the top level of the group, or move them out of it.
Performance, honestly
The retained tier removes CPU work: the per-frame subtree walk, per-child transform resolution, per-child culling, and render-plan rebuilding. It does not change the number of draw calls — batching is identical to a plain container — so a GPU-bound scene will not get faster, and a small subtree will not show a measurable difference because its walk was already cheap.
Where it wins is a large, static subtree that the camera pans across: the walk that a plain container repeats every frame collapses to an O(batches) replay plus one matrix update. The gap grows with child count and is widest on lower-end machines and mobile. Measure it in your scene rather than assuming — the Performance chapter and the debug overlay’s frame-time and submitted-node counters are the right instruments.
Worked example
The example holds a field of several thousand static sprites and pans it as a camera along a slow path. Toggle between the retained tier and a plain container holding the identical field, and watch the smoothed frame-time readout: same pixels, same draw calls, different CPU cost per frame.
Where to go next
RetainedContainer and immediate mode are the two escape hatches from the per-frame scene-graph walk — retention for static node trees, immediate mode for procedural geometry with no nodes at all. To find out whether the walk is actually your bottleneck before reaching for either, start with the Performance chapter.


