Guide

GuideRenderingRetained containers

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.

Advanced~9 min read

What you'll learn

  • declare a large, mostly-static subtree as a RetainedContainer
  • pan the whole group as one GPU-matrix update instead of touching every child
  • recognise the group-local space, lifecycle, and invalidation rules the tier trades for that speed

Before you start

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.

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:

examples/guides/retained-containers/decor-layer.ts
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.
  • pixelSnapMode is resolved entirely on the GPU against the composed world origin in both modes — position rounds the origin, geometry additionally 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:

examples/guides/retained-containers/decor-layer.ts
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);
}

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.

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.

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.

Retained ContainerOpen in PlaygroundView source

Preview is paused until you click Play.

import { Application, Color, Container, FixedResolutionCanvasSizing, Rectangle, type RenderingContext, RetainedContainer, Scene, type Seconds, Sprite, Texture } from '@codexo/exojs';
import { mountControlPanel, mountControls } from '@examples/runtime';

// A decor field far larger than the viewport: thousands of sprites that are
// authored ONCE and never mutated again - the exact shape the retained tier is
// built for. Only the group as a whole ever moves (the "camera" pan below).
const FIELD_COLUMNS = 96;
const FIELD_ROWS = 60;
const TILE_SPACING = 34;
const FIELD_COUNT = FIELD_COLUMNS * FIELD_ROWS;

class RetainedContainerScene extends Scene {
  private atlas!: Texture;
  // Typed as the base Container so the same field code works whether the
  // active group is a RetainedContainer or a plain Container.
  private field!: Container;
  private retained = true;
  private elapsed = 0;
  // Exponential moving average of the wall-clock frame time, so the readout
  // is legible instead of flickering frame to frame.
  private smoothedFrameMs = 0;

  private hud!: ReturnType<typeof mountControls>;
  private panel!: ReturnType<typeof mountControlPanel>;

  override init(): void {
    this.atlas = createAtlasTexture();
    this.field = this.buildField(this.retained);

    this.hud = mountControls({
      title: 'Retained Container',
      controls: [
        { keys: ['Retained tier'], action: 'the whole static field replays as O(batches) — no per-child walk' },
        { keys: ['Plain container'], action: 'every child is walked and re-collected each frame' },
      ],
      hint: 'The field only ever moves as a whole. Toggle the tier and watch the frame-time readout.',
    });

    this.panel = mountControlPanel({ title: 'Render tier' });
    this.panel.addToggle({
      label: 'Retained tier',
      value: this.retained,
      onChange: value => {
        this.retained = value;
        // Rebuild the field into the other container type. Destroying the
        // OLD group is safe: it owns no child that outlives it, so the
        // whole subtree is torn down together (see the in-place-destroy
        // footgun in the guide).
        this.field.destroy();
        this.field = this.buildField(this.retained);
      },
    });
  }

  override update(delta: Seconds): void {
    this.elapsed += delta;
  }

  override draw(context: RenderingContext): void {
    const app = this.app;
    const { width, height } = app;

    // Pan the whole field along a slow Lissajous path, like a camera drifting
    // over a static world. For a RetainedContainer this is ONE group-matrix
    // update per frame - the retained fragment is untouched, so no descendant
    // transform is recomputed and no child is re-collected. A plain Container
    // still walks all FIELD_COUNT children every frame to place them.
    const panX = width / 2 + Math.cos(this.elapsed * 0.35) * 140;
    const panY = height / 2 + Math.sin(this.elapsed * 0.5) * 90;

    this.field.setPosition(panX, panY);
    context.render(this.field);

    // frameTimeMs is wall-clock and both tiers draw the SAME pixels with the
    // same draw calls, so the gap you see is the CPU collect/walk cost the
    // retained tier removes. The gap widens with field size and on slower
    // machines; on a fast desktop with a few thousand sprites it can be small.
    this.smoothedFrameMs += (context.stats.frameTimeMs - this.smoothedFrameMs) * 0.1;

    const tier = this.retained ? 'RetainedContainer' : 'plain Container';
    this.hud.setStatus(
      `${tier} · ${FIELD_COUNT} static sprites · submitted: ${context.stats.submittedNodes} · ` +
        `drawCalls: ${context.stats.drawCalls} · frame: ${this.smoothedFrameMs.toFixed(2)} ms`,
    );
  }

  /**
   * Build the decor field into a fresh container of the requested tier.
   * Both branches produce an identical scene; only the container type differs,
   * which is the whole point of the comparison.
   */
  private buildField(retained: boolean): Container {
    // The ONLY line that opts into the retained tier. There is no runtime
    // toggle on the instance itself - the tier is chosen at construction.
    const group = retained ? new RetainedContainer() : new Container();

    const frames = [new Rectangle(0, 0, 64, 64), new Rectangle(64, 0, 64, 64), new Rectangle(0, 64, 64, 64), new Rectangle(64, 64, 64, 64)];
    const palette = [Color.white, new Color(0x87ceeb), new Color(0xffd700), new Color(0x00fa9a), new Color(0xff69b4), new Color(0x9370db)];

    let index = 0;

    for (let row = 0; row < FIELD_ROWS; row++) {
      for (let column = 0; column < FIELD_COLUMNS; column++) {
        const sprite = new Sprite(this.atlas);

        // Deterministic layout, centered on the group origin so panning the
        // group reveals the field drifting as one rigid sheet.
        sprite.setTextureFrame(frames[index % frames.length]);
        sprite.setAnchor(0.5);
        sprite.setPosition((column - (FIELD_COLUMNS - 1) / 2) * TILE_SPACING, (row - (FIELD_ROWS - 1) / 2) * TILE_SPACING);
        sprite.setScale(0.42);
        sprite.setTint(palette[index % palette.length]);

        // Authored once, never mutated again: no per-frame setter runs on a
        // child, so the retained fragment stays clean and is spliced whole.
        group.addChild(sprite);
        index++;
      }
    }

    return group;
  }

  override destroy(): void {
    this.hud?.dispose();
    this.panel?.dispose();
    this.field?.destroy();
    this.atlas?.destroy();
  }
}

const app = new Application({
  scenes: { RetainedContainerScene },
  canvas: {
    width: 1280,
    height: 720,
    mount: document.body,
    sizing: new FixedResolutionCanvasSizing(),
  },
  clearColor: new Color(6, 9, 18, 1),
});

await app.start(RetainedContainerScene);

/** Draw a small 2x2 sprite atlas procedurally so the example needs no asset load. */
function createAtlasTexture(): Texture {
  const canvas = document.createElement('canvas');
  const context = canvas.getContext('2d')!;

  canvas.width = 128;
  canvas.height = 128;

  drawAtlasCell(context, 0, 0, '#0f172a', '#ffd166', 'circle');
  drawAtlasCell(context, 64, 0, '#10243d', '#7dd3fc', 'diamond');
  drawAtlasCell(context, 0, 64, '#112b21', '#4ade80', 'square');
  drawAtlasCell(context, 64, 64, '#23163c', '#ff6b6b', 'triangle');

  return new Texture(canvas);
}

function drawAtlasCell(context: CanvasRenderingContext2D, x: number, y: number, background: string, accent: string, shape: string): void {
  context.fillStyle = background;
  context.fillRect(x, y, 64, 64);

  context.fillStyle = accent;
  context.beginPath();

  if (shape === 'circle') {
    context.arc(x + 32, y + 32, 18, 0, Math.PI * 2);
  } else if (shape === 'diamond') {
    context.moveTo(x + 32, y + 12);
    context.lineTo(x + 52, y + 32);
    context.lineTo(x + 32, y + 52);
    context.lineTo(x + 12, y + 32);
    context.closePath();
  } else if (shape === 'square') {
    context.rect(x + 16, y + 16, 32, 32);
  } else {
    context.moveTo(x + 32, y + 12);
    context.lineTo(x + 52, y + 52);
    context.lineTo(x + 12, y + 52);
    context.closePath();
  }

  context.fill();
}

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.