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.
How It Works
Section titled “How It Works”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.
import { Sprite2D, SpriteGroup, Sprite2DMaterial } from 'three-flatland';
const spriteGroup = new SpriteGroup();scene.add(spriteGroup);
// These sprites share a material, so they batch togetherconst 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 neededfunction animate() { renderer.render(scene, camera); requestAnimationFrame(animate);}import { useMemo } from 'react';import { extend } from '@react-three/fiber/webgpu';import { Sprite2D, SpriteGroup, Sprite2DMaterial } from 'three-flatland/react';
extend({ Sprite2D, SpriteGroup, Sprite2DMaterial });
function Particles({ count = 1000, spriteSheet }) { // Create shared material const material = useMemo(() => new Sprite2DMaterial({ map: spriteSheet }), [spriteSheet]);
// Generate random positions once const positions = useMemo( () => Array.from({ length: count }, () => [Math.random() * 100, Math.random() * 100, 0]), [count] );
return ( <spriteGroup> {positions.map((pos, i) => ( <sprite2D key={i} material={material} position={pos} /> ))} </spriteGroup> );}Capacity hints
Section titled “Capacity hints”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 })import { useMemo } from 'react'import type { SpriteGroupOptions } from 'three-flatland/react'
function World() { const groupArgs = useMemo( () => [{ expectedSprites: 16_384 }] satisfies [SpriteGroupOptions], [], )
return <spriteGroup args={groupArgs}>{/* sprites */}</spriteGroup>}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={...}>.
Renderer Options
Section titled “Renderer Options”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.
Scene-graph hierarchy
Section titled “Scene-graph hierarchy”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 = 80page.visible = false // the batch and pointer picking both follow<spriteGroup clipRect={[-160, -90, 320, 180]}> <Activity mode={active ? 'visible' : 'hidden'}> <group position={[80, 0, 0]}> <sprite2D texture={portrait} /> <sprite2D texture={button} position={[64, -40, 0]} /> </group> </Activity></spriteGroup>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.
Automatic Change Detection
Section titled “Automatic Change Detection”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 restsprite.tint = [1, 0, 0]sprite.alpha = 0.5sprite.sortLayer = SortLayers.UIsprite.position.x += 10For 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.
Performance Monitoring
Section titled “Performance Monitoring”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.
Optimization Tips
Section titled “Optimization Tips”Share Materials
Section titled “Share Materials”// Good: One material, one batchconst material = new Sprite2DMaterial({ map: atlas })sprites.forEach((s) => (s.material = material))
// Bad: Many materials, many batchessprites.forEach((s) => { s.material = new Sprite2DMaterial({ map: atlas }) // Creates new batch each time!})Use Texture Atlases
Section titled “Use Texture Atlases”Combine textures into a single atlas. All sprites using the atlas can share one material:
// Load a sprite sheet with frame dataconst { texture, frames } = await SpriteSheetLoader.load('/sprites/atlas.json')
const material = new Sprite2DMaterial({ map: texture })
// Different frames, same material = same batchconst player = new Sprite2D({ material, frame: frames.get('player') })const enemy = new Sprite2D({ material, frame: frames.get('enemy') })Organize by Sort Layer
Section titled “Organize by Sort Layer”Sprites are sorted by sortLayer before batching. Keep related sprites on the same layer:
import { SortLayers } from 'three-flatland'
// Background sprites batch togetherbackgrounds.forEach((s) => (s.sortLayer = SortLayers.BACKGROUND))
// Entity sprites batch togetherentities.forEach((s) => (s.sortLayer = SortLayers.ENTITIES))Custom Materials
Section titled “Custom Materials”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.
Interleaved instance buffer layout
Section titled “Interleaved instance buffer layout”Each SpriteBatch allocates a single InstancedInterleavedBuffer with stride = 16 floats per instance. Four named attribute views look into that buffer:
| Attribute | Type | Floats | Contents |
|---|---|---|---|
instanceUV | vec4 | 0..3 | Atlas UV rect — x, y, w, h for the sprite’s frame in the atlas. |
instanceColor | vec4 | 4..7 | Per-instance tint and alpha — r, g, b, a. |
instanceSystem | vec4 | 8..11 | [flipX, flipY, systemFlags, enableBits]. |
instanceExtras | vec4 | 12..15 | [shadowRadius, reserved, reserved, reserved]. |
flipX and flipY are +1 (unflipped) or -1 (flipped). systemFlags is an integer bitfield:
| Bit | Mask | Meaning |
|---|---|---|
| 0 | LIT_FLAG_MASK | Sprite receives lighting. |
| 1 | RECEIVE_SHADOWS_MASK | Sprite’s lit fragments may be darkened by the shadow trace. |
| 2 | CAST_SHADOW_MASK | Sprite contributes to the occlusion pre-pass. |
| 3..23 | reserved | — |
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.
TSL helpers
Section titled “TSL helpers”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:
| Helper | Returns | Reads |
|---|---|---|
readFlip() | vec2 | instanceSystem.xy (±1 per axis) |
readSystemFlags() | int | instanceSystem.z (raw bitfield) |
readEnableBits() | int | instanceSystem.w (effect-active mask) |
readShadowRadius() | float | instanceExtras.x |
readLitFlag() | bool | bit 0 of system flags |
readReceiveShadowsFlag() | bool | bit 1 of system flags |
readCastShadowFlag() | bool | bit 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.
Next Steps
Section titled “Next Steps”- Flatland Guide — Wrap SpriteGroup with camera, post-processing, and global uniforms
- Sprites Guide — Sprite2D properties and configuration
- Pass Effects Guide — MaterialEffect / colorTransform reference