Skip to content

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);
}

The SpriteGroup constructor accepts optional batch configuration:

const spriteGroup = new SpriteGroup({
maxBatchSize: 8192, // Max sprites per batch (default: 8192)
autoSort: true, // Enable automatic sorting (default: true)
frustumCulling: true, // Enable frustum culling (default: true)
autoInvalidateTransforms: true, // Auto-sync transforms every frame (default: true)
});
scene.add(spriteGroup);

Property changes on sprites are detected automatically through the ECS. When you update a sprite’s tint, alpha, flip, frame, layer, or position, the renderer syncs only the changed data to the GPU on the next frame. No manual invalidation needed.

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

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 layer before batching. Keep related sprites on the same layer:

import { Layers } from 'three-flatland';
// Background sprites batch together
backgrounds.forEach(s => s.layer = Layers.BACKGROUND);
// Entity sprites batch together
entities.forEach(s => s.layer = Layers.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.