Skip to content

Batch Rendering

How automatic batching works and tips for optimal performance.

SpriteGroup automatically batches sprites that share the same material into single draw calls. You don’t need to do anything special—just add sprites and the batching happens behind the scenes.

The defaults are tuned for the common case (thousands of sprites, one or two materials). You almost never need to think about batching — but on the day you do, this page is the map.

When you add sprites to a SpriteGroup, they’re grouped by material and layer into efficient GPU batches. Systems run automatically during Three.js rendering — no manual update() call needed.

three-flatland sprite batching pipelinegroup by material → pack per-instance data in one interleaved VBO → flush only dirty buckets → gate effects branchlessly in the shader1 · Assignment funnel — CPU groups by materialSprite2Dlayer · materialIdzIndex… thousands moreSpriteGroupKoota worldbatchAssignSystemRunkey (layer, materialId)SpriteBatch≤ 16k slotsshared materialInstancedMesh→ 1 DRAW CALLBatches sort by (layer, batchIdx); within a batch, instancessort by zIndex — batchSortSystem, in-place swapSlots.Many sprites sharing one material collapse into one draw call.2 · Interleaved buffer — 1 drawIndexed / materialInstancedInterleavedBuffer · 1 GPU buffer · 4 views · stride 16 / slotinstanceUV 0..3 uv.x uv.y uv.w uv.hinstanceColor 4..7 r g b ainstanceSystem 8..11 flipX flipY sysFlags enableBits ★instanceExtras 12..15 shadowR res0 res1 res2effectBuf0..2 3 × vec4 · separate attrs · only if neededWebGPU budget — 8 vertex bindings:3 geom + matrix + 1 interleaved + 3 effectBuf = 8 ✓Interleaving collapsed 4 attrs → 1 binding,freeing 3 slots for effectBuf*.3 · BucketedDirtyTracker — flush only what changed16,384 slots = 64 buckets × 256 slotsb0b1•b2b3•… b63• = dirty bucketmin/max slot trackedmarkDirty(slot): b = slot >>> 8 bucket min/max ← slot ~5 ns · no allocsflush() — per attribute: decide fast vs ranged based on dirtyBucket countFAST: dirtyBuckets ≥ threshold→ one full bufferData uploadRANGED: else→ addUpdateRange / bucketThresholds: matrix 5 · interleaved 3 · custom 3 · bucket 256Most frames touch < 5 % — only dirty regions cross the bus.4 · Effect bits → shader mix — no pipeline rebuildinstanceSystem.w = one float per slot, read as a bitmask1tintbit 20outlinebit 11dissolvebit 0= 0b101 = 5.0addEffect(Dissolve) → flags |= (1 << bitIdx)removeEffect(Dissolve) → flags &= ~(1 << bitIdx)→ mesh.writeEnableBits(slot, flags) // 1 float writelet color = baseColorfor (const { effect, bitIndex } of effects) { const enabled = mod(floor(enableBits / (1<<bitIndex)), 2) const result = effect.node({ color, uv }) color = mix(color, result, enabled) // branchless gate}enabled 0 → base color · enabled 1 → effectsame compiled shader runs for the whole batch
The batch pipeline — sprites group by material and layer into instanced batches packed in one interleaved buffer, with a bucketed dirty tracker and branchless effect gating.
import { Sprite2D, SpriteGroup, Sprite2DMaterial } from 'three-flatland';
const spriteGroup = new SpriteGroup();
scene.add(spriteGroup);
// These sprites share a material, so they batch together
const material = new Sprite2DMaterial({ map: spriteSheet });
for (let i = 0; i < 1000; i++) {
const sprite = new Sprite2D({ material });
sprite.position.set(Math.random() * 100, Math.random() * 100, 0);
spriteGroup.add(sprite);
}
// In your render loop — no update() call needed
function animate() {
renderer.render(scene, camera);
requestAnimationFrame(animate);
}

expectedSprites is a constructor-time hint for scenes whose approximate sprite population is known. It reserves the hot CPU-side enrollment storage used by SpriteGroup; it does not pre-create GPU batches or limit how many sprites can be added. Flatland accepts the same option and forwards it to its internal SpriteGroup.

const spriteGroup = new SpriteGroup({ expectedSprites: 16_384 })
// Flatland accepts the same constructor option.
const flatland = new Flatland({ expectedSprites: 16_384 })

The hint is advisory, not a hard cap. Enrollment continues automatically past it with bounded storage growth. clear() keeps the reservation available for reuse; dispose() releases it.

In React Three Fiber, pass a stable or memoized options tuple through args. expectedSprites is intentionally not a mutable JSX property, so changing the hint means changing the memoized constructor args and letting React Three Fiber reconstruct the object. Do not write <spriteGroup expectedSprites={...}> or <flatland expectedSprites={...}>.

The SpriteGroup constructor accepts optional batch configuration:

const spriteGroup = new SpriteGroup({
maxBatchSize: 16384, // Optional fixed size; default uses 1024 → 4096 → 16384 tiers
autoSort: true, // Enable automatic sorting (default: true)
frustumCulling: true, // Enable frustum culling (default: true)
autoInvalidateTransforms: true, // Auto-sync transforms every frame (default: true)
clipRect: [-160, -90, 320, 180], // Optional local [x, y, width, height]
})
scene.add(spriteGroup)

maxBatchSize must be a positive integer no greater than 1,048,576. The default tier ladder is tuned for normal sprite workloads; use a fixed size only when a measured scene benefits from a specific batch capacity.

Batched sprites keep the transforms and visibility of their authored parents. Put an ordinary three.js Group under a SpriteGroup when you want to move, rotate, scale, or hide a whole subtree without giving up batching:

const viewport = new SpriteGroup({ clipRect: [-160, -90, 320, 180] })
const page = new Group()
page.add(title, portrait, nextButton)
viewport.add(page)
scene.add(viewport)
page.position.x = 80
page.visible = false // the batch and pointer picking both follow

clipRect is local to the SpriteGroup, so it follows the group’s own transform. Nested SpriteGroup clip rectangles compose, and clipped pixels are excluded from pointer hits as well as rendering. Scene-graph clipping uses Three.js ClippingGroup, so the rendering half requires WebGPURenderer; CPU pointer filtering still applies independently.

Use SpriteGroup.clipRect for clipping inside a batch. An ordinary ClippingGroup inserted between a SpriteGroup and its authored sprites is not retained as a batch boundary, so it cannot clip the shared batch mesh or its pointer hits.

Setting a custom sprite.renderOrder opts that sprite out of batching so Three.js can preserve its exact render-list position. Prefer sortLayer when the sprite should remain in the batch.

Property changes on sprites are detected automatically through the ECS. When you update a sprite’s tint, alpha, flip, frame, sortLayer, position, or ancestor transform, the renderer syncs only the changed data to the GPU on the next frame. No manual invalidation is needed with the default autoInvalidateTransforms: true.

// Just change properties — the renderer handles the rest
sprite.tint = [1, 0, 0]
sprite.alpha = 0.5
sprite.sortLayer = SortLayers.UI
sprite.position.x += 10

For a static scene with autoInvalidateTransforms: false, call spriteGroup.invalidateTransforms() after changing a parent transform or parent visibility. Direct sprite.visible changes invalidate their batch slot automatically.

Retained hierarchy mode checks the authored subtree on each matrix update so arbitrary Three.js reparenting is detected without a Flatland-specific mutation API. For very large flat populations, add sprites directly to SpriteGroup or use automatic batching; those paths avoid the retained-subtree walk.

Check batch efficiency with the stats property:

const stats = spriteGroup.stats
console.log(`Sprites: ${stats.spriteCount}`)
console.log(`Batches: ${stats.batchCount}`)
console.log(`Visible: ${stats.visibleSprites}`)
// Draw calls aren't part of stats — read them from the renderer:
console.log(`Draw calls: ${renderer.info.render.calls}`)

Goal: Keep batchCount low relative to spriteCount. If you have 1000 sprites but 100 batches, you’re not batching efficiently.

// Good: One material, one batch
const material = new Sprite2DMaterial({ map: atlas })
sprites.forEach((s) => (s.material = material))
// Bad: Many materials, many batches
sprites.forEach((s) => {
s.material = new Sprite2DMaterial({ map: atlas }) // Creates new batch each time!
})

Combine textures into a single atlas. All sprites using the atlas can share one material:

// Load a sprite sheet with frame data
const { texture, frames } = await SpriteSheetLoader.load('/sprites/atlas.json')
const material = new Sprite2DMaterial({ map: texture })
// Different frames, same material = same batch
const player = new Sprite2D({ material, frame: frames.get('player') })
const enemy = new Sprite2D({ material, frame: frames.get('enemy') })

Sprites are sorted by sortLayer before batching. Keep related sprites on the same layer:

import { SortLayers } from 'three-flatland'
// Background sprites batch together
backgrounds.forEach((s) => (s.sortLayer = SortLayers.BACKGROUND))
// Entity sprites batch together
entities.forEach((s) => (s.sortLayer = SortLayers.ENTITIES))

If you’re authoring a custom Sprite2DMaterial subclass — or a stand-alone TSL shader that consumes the same per-instance data — you read from the interleaved instance buffer that every batch shares.

Per-instance sprite data: CPU → GPU buffer → TSL accessorsCPU — Sprite2Dsprite.tint = #5544ffsprite.lit = falsesprite.frame = idle_3ECS traitsColor · Frame · EffectFlagsbatchSystemwrites 16 floatsper instanceGPU — InstancedInterleavedBuffer · stride 16instanceUV vec4floats 0..3 · atlas x, y, w, hinstanceColor vec4floats 4..7 · r, g, b, ainstanceSystem vec4floats 8..11flipX, flipY, sysFlags, enableBitsinstanceExtras vec4floats 12..15 · shadowRadius, reserved…TSL accessors — Sprite2DMaterialreadFlip -> vec2readSystemFlags -> intreadEnableBits -> intreadLitFlag -> boolreadShadowRadius -> float
Per-instance data flow: CPU → GPU → TSL accessors

Each SpriteBatch allocates a single InstancedInterleavedBuffer with stride = 16 floats per instance. Four named attribute views look into that buffer:

AttributeTypeFloatsContents
instanceUVvec40..3Atlas UV rect — x, y, w, h for the sprite’s frame in the atlas.
instanceColorvec44..7Per-instance tint and alpha — r, g, b, a.
instanceSystemvec48..11[flipX, flipY, systemFlags, enableBits].
instanceExtrasvec412..15[shadowRadius, reserved, reserved, reserved].

flipX and flipY are +1 (unflipped) or -1 (flipped). systemFlags is an integer bitfield:

BitMaskMeaning
0LIT_FLAG_MASKSprite receives lighting.
1RECEIVE_SHADOWS_MASKSprite’s lit fragments may be darkened by the shadow trace.
2CAST_SHADOW_MASKSprite contributes to the occlusion pre-pass.
3..23reserved

enableBits is the active-MaterialEffect bitmask: bit N is set while the Nth registered effect is active on the instance (24 slots, see EFFECT_BIT_OFFSET). System bits and effect-enable bits live in different vec4 components on purpose so adding system flags never costs an effect slot.

Effect-specific data — the per-instance uniform fields declared in a MaterialEffect schema — lives on separate instanced attributes named effectBuf0, effectBuf1, … allocated only when at least one registered effect declares per-instance fields. This is a change from the previous layout where system data and effect data shared effectBuf0.

packages/three-flatland/src/materials/instanceAttributes.ts exports the recommended accessors for use inside TSL nodes. They hide the packed layout and stay forward-compatible if the buffer is rearranged:

HelperReturnsReads
readFlip()vec2instanceSystem.xy (±1 per axis)
readSystemFlags()intinstanceSystem.z (raw bitfield)
readEnableBits()intinstanceSystem.w (effect-active mask)
readShadowRadius()floatinstanceExtras.x
readLitFlag()boolbit 0 of system flags
readReceiveShadowsFlag()boolbit 1 of system flags
readCastShadowFlag()boolbit 2 of system flags

Don’t call attribute('instanceSystem', 'vec4') directly unless you specifically need the raw vec4 — the helpers keep the call site readable and survive layout changes.

Example — fade unlit sprites with colorTransform

Section titled “Example — fade unlit sprites with colorTransform”

A custom colorTransform that crossfades between two tints based on each sprite’s lit flag, applied per-instance from the same batch:

import { vec4 } from 'three/tsl'
import { Sprite2DMaterial, readLitFlag } from 'three-flatland'
const material = new Sprite2DMaterial({
map: atlas,
colorTransform: (ctx) => {
const litColor = ctx.color.rgb
const dimColor = ctx.color.rgb.mul(0.4)
const isLit = readLitFlag()
return vec4(isLit.select(litColor, dimColor), ctx.color.a)
},
})

Lit sprites in this batch render at full color; sprites with lit = false render dimmed — all from the same draw call.

For the broader colorTransform / MaterialEffect API and where it sits in the pipeline, see the Pass Effects guide. For the Sprite2D-side accessors that drive these flags (lit, receiveShadows, castsShadow, shadowRadius), see the Sprites guide.