Skip to content
Back to Examples

Batch Rendering

Render thousands of sprites efficiently with automatic batching.

import { WebGPURenderer } from 'three/webgpu'
import { Scene, OrthographicCamera, Vector2, Raycaster, Plane, Vector3 } from 'three'
import {
Sprite2D,
Sprite2DMaterial,
SpriteGroup,
Layers,
TextureLoader,
SpriteSheetLoader,
createDevtoolsProvider,
} from 'three-flatland'
import { createPane } from '@three-flatland/devtools'
import { gemGradientNode } from './GemBackground'
import { GEM } from './gem'
// Configuration
const TILE_SIZE = 64
const GRID_WIDTH = 12
const GRID_HEIGHT = 8
const ASSET_BASE = './assets/'
// Grass tilemap UV size (32x32 tiles in 640x256 texture)
const TILE_UV_SIZE = { width: 32 / 640, height: 32 / 256 }
const SLICE_TILES = 6; // 6x6 region for 9-slice
const SLICE_START_X = 0; // in tiles
const SLICE_START_Y = 0; // in tiles
// Building definitions — frame names refer to the shared sprites atlas
// (sprites.png + sprites.atlas.json). All buildings load from one
// texture → one material → one SpriteBatch, so per-sprite zIndex
// Y-sort works across building types (tree-in-front-of-house etc.).
interface BuildingDef {
name: string
frame: string
width: number
height: number
shadowScale: number
}
// Render dimensions = atlas sourceSize so manual scale and Sprite2D's
// auto-sizing (setFrame → updateSize on first frame) agree. Without
// this, hover sprite (reuses one Sprite2D across buildings, so
// updateSize fires only the first time) diverges from placed sprites
// (one Sprite2D per placement, updateSize always fires on init). The
// bottom-anchor position math `+ height/2 - TILE_SIZE/2` is invariant
// under height changes so sprite ground-anchoring stays correct.
const BUILDINGS: BuildingDef[] = [
{ name: 'house', frame: 'house', width: 108, height: 148, shadowScale: 1.2 },
{ name: 'tower', frame: 'tower_0', width: 114, height: 183, shadowScale: 1.0 },
{ name: 'tree', frame: 'tree_0', width: 111, height: 174, shadowScale: 1.5 },
]
// Placed entity
interface PlacedEntity {
sprite: Sprite2D
shadow: Sprite2D
gridX: number
gridY: number
}
/* HMR-tracked teardown state. Without this, every dev save accumulates
* a fresh renderer + animate() loop while the previous one keeps
* RAFing forever. Dev-only — `import.meta.hot` is undefined in prod. */
let rafId = 0
let activeRenderer: WebGPURenderer | null = null
async function main() {
// Scene setup
const scene = new Scene()
;(scene as any).backgroundNode = gemGradientNode({ gem: GEM })
// Calculate view size based on grid
const viewWidth = TILE_SIZE * (GRID_WIDTH + 2)
const viewHeight = TILE_SIZE * (GRID_HEIGHT + 4)
// Orthographic camera
const camera = new OrthographicCamera(
-viewWidth / 2,
viewWidth / 2,
viewHeight / 2,
-viewHeight / 2,
0.1,
1000
)
camera.position.z = 100
// WebGPU Renderer
const renderer = new WebGPURenderer({ antialias: false })
activeRenderer = renderer
renderer.setSize(window.innerWidth, window.innerHeight)
renderer.setPixelRatio(1) // Pixel-perfect for pixel art
renderer.domElement.style.imageRendering = 'pixelated'
document.body.appendChild(renderer.domElement)
await renderer.init()
// Load textures (uses 'pixel-art' preset by default with NearestFilter + SRGBColorSpace)
const [grassTex, shadowTex, spritesSheet] = await Promise.all([
TextureLoader.load(ASSET_BASE + 'terrain/Tilemap_Flat.png'),
TextureLoader.load(ASSET_BASE + 'terrain/Shadows.png'),
SpriteSheetLoader.load(ASSET_BASE + 'buildings/sprites.atlas.json'),
])
// Create materials
const grassMaterial = new Sprite2DMaterial({ map: grassTex })
const shadowMaterial = new Sprite2DMaterial({ map: shadowTex })
// ONE material for ALL buildings + trees → ONE batch → per-sprite
// zIndex Y-sort works across building types.
const spritesMaterial = new Sprite2DMaterial({ map: spritesSheet.texture })
// Create the 2D renderer
const spriteGroup = new SpriteGroup()
scene.add(spriteGroup)
// Grid offset to center the grid
const gridOffsetX = (-GRID_WIDTH * TILE_SIZE) / 2 + TILE_SIZE / 2
const gridOffsetY = (-GRID_HEIGHT * TILE_SIZE) / 2 + TILE_SIZE / 2
// Helper to get the correct grass tile UV based on grid position
function getGrassTileUV(x: number, y: number) {
// Map grid position to 9-slice tile index (0-5 for 6x6 region)
const maxX = GRID_WIDTH - 1;
const maxY = GRID_HEIGHT - 1;
// Determine which 9-slice index this tile is (0, 1, ..., 5)
let sliceCol = 0;
let sliceRow = 0;
if (x === 0) sliceCol = 0;
else if (x === maxX) sliceCol = SLICE_TILES - 1;
else sliceCol = 1 + ((x - 1) % (SLICE_TILES - 2));
if (y === 0) sliceRow = 0;
else if (y === maxY) sliceRow = SLICE_TILES - 1;
else sliceRow = 1 + ((y - 1) % (SLICE_TILES - 2));
// Compute UVs (flip y so v=0 is top of texture)
// Invert sliceRow so top row in grid maps to top row in texture
const flippedSliceRow = SLICE_TILES - 1 - sliceRow;
const uv = {
x: (SLICE_START_X + sliceCol) * TILE_UV_SIZE.width,
y: 1 - ((SLICE_START_Y + flippedSliceRow + 1) * TILE_UV_SIZE.height),
};
return uv;
}
// Create ground tiles using 9-slice pattern
for (let y = 0; y < GRID_HEIGHT; y++) {
for (let x = 0; x < GRID_WIDTH; x++) {
const tile = new Sprite2D({ material: grassMaterial })
const uv = getGrassTileUV(x, y)
tile.setFrame({
name: 'grass',
x: uv.x,
y: uv.y,
width: TILE_UV_SIZE.width,
height: TILE_UV_SIZE.height,
sourceWidth: TILE_SIZE,
sourceHeight: TILE_SIZE,
})
tile.position.set(gridOffsetX + x * TILE_SIZE, gridOffsetY + y * TILE_SIZE, 0)
tile.layer = Layers.GROUND
tile.zIndex = 0
spriteGroup.add(tile)
}
}
// Placed entities
const entities: PlacedEntity[] = []
const occupiedCells = new Set<string>()
// Currently selected building
let selectedBuilding = 0
// Hover indicator (preview sprite for placing buildings)
// Added directly to scene (not SpriteGroup) so it's not batched with other sprites
// renderOrder set high to ensure it renders above all batched sprites
const hoverSprite = new Sprite2D({ material: spritesMaterial })
hoverSprite.alpha = 0.5
hoverSprite.layer = Layers.FOREGROUND
hoverSprite.visible = false
hoverSprite.renderOrder = 1000 // Render above all batched sprites
const building = BUILDINGS[selectedBuilding]!
// Same order as placeBuilding (scale.set BEFORE setFrame) so that
// setFrame's first-frame updateSize() wins → render dimensions match
// the atlas frame's sourceSize, matching what R3F does for the React
// version's <sprite2D> in declaration order.
hoverSprite.scale.set(building.width, building.height, 1)
hoverSprite.setFrame(spritesSheet.getFrame(building.frame))
scene.add(hoverSprite)
// Helper to convert screen to world position
const raycaster = new Raycaster()
const groundPlane = new Plane(new Vector3(0, 0, 1), 0)
const mouse = new Vector2()
const worldPos = new Vector3()
function screenToWorld(clientX: number, clientY: number): Vector3 {
mouse.x = (clientX / window.innerWidth) * 2 - 1
mouse.y = -(clientY / window.innerHeight) * 2 + 1
raycaster.setFromCamera(mouse, camera)
raycaster.ray.intersectPlane(groundPlane, worldPos)
return worldPos
}
function worldToGrid(worldX: number, worldY: number): { x: number; y: number } | null {
const gx = Math.floor((worldX - gridOffsetX + TILE_SIZE / 2) / TILE_SIZE)
const gy = Math.floor((worldY - gridOffsetY + TILE_SIZE / 2) / TILE_SIZE)
if (gx >= 0 && gx < GRID_WIDTH && gy >= 0 && gy < GRID_HEIGHT) {
return { x: gx, y: gy }
}
return null
}
function gridToWorld(gx: number, gy: number): { x: number; y: number } {
return {
x: gridOffsetX + gx * TILE_SIZE,
y: gridOffsetY + gy * TILE_SIZE,
}
}
function placeBuilding(gridX: number, gridY: number) {
const key = `${gridX},${gridY}`
if (occupiedCells.has(key)) return
const buildingDef = BUILDINGS[selectedBuilding]!
const pos = gridToWorld(gridX, gridY)
// Create shadow
const shadow = new Sprite2D({ material: shadowMaterial })
shadow.scale.set(TILE_SIZE * buildingDef.shadowScale, TILE_SIZE * buildingDef.shadowScale * 0.5, 1)
shadow.position.set(pos.x, pos.y - TILE_SIZE * 0.3, 0)
shadow.layer = Layers.SHADOWS
shadow.zIndex = 0
shadow.alpha = 0.5
spriteGroup.add(shadow)
// Create building sprite — all buildings share spritesMaterial, so
// they batch together and zIndex Y-sort works across types.
//
// scale.set BEFORE setFrame: Sprite2D.setFrame runs updateSize() on
// the first frame and overwrites .scale with the atlas frame's
// sourceSize. Setting scale first lets updateSize win — render size
// matches the atlas source dimensions (the natural pixel-art size).
// This matches what R3F does automatically because its declaration-
// order prop application puts scale before frame.
const sprite = new Sprite2D({ material: spritesMaterial })
sprite.scale.set(buildingDef.width, buildingDef.height, 1)
sprite.setFrame(spritesSheet.getFrame(buildingDef.frame))
// Position with anchor at bottom center
sprite.position.set(pos.x, pos.y + buildingDef.height / 2 - TILE_SIZE / 2, 0)
sprite.layer = Layers.ENTITIES
// Y-sort: use zIndex for depth sorting (lower Y = higher zIndex = renders in front)
sprite.zIndex = -Math.floor(pos.y)
spriteGroup.add(sprite)
entities.push({ sprite, shadow, gridX, gridY })
occupiedCells.add(key)
}
// Mouse events
let lastHoverGrid: { x: number; y: number } | null = null
renderer.domElement.addEventListener('mousemove', (e) => {
const world = screenToWorld(e.clientX, e.clientY)
const grid = worldToGrid(world.x, world.y)
if (grid && !occupiedCells.has(`${grid.x},${grid.y}`)) {
const pos = gridToWorld(grid.x, grid.y)
const buildingDef = BUILDINGS[selectedBuilding]!
hoverSprite.position.set(pos.x, pos.y + buildingDef.height / 2 - TILE_SIZE / 2, 0)
hoverSprite.visible = true
lastHoverGrid = grid
} else {
hoverSprite.visible = false
lastHoverGrid = null
}
})
renderer.domElement.addEventListener('mouseleave', () => {
hoverSprite.visible = false
lastHoverGrid = null
})
renderer.domElement.addEventListener('click', () => {
if (lastHoverGrid) {
placeBuilding(lastHoverGrid.x, lastHoverGrid.y)
}
})
// Building selector buttons
function updateHoverSprite() {
const buildingDef = BUILDINGS[selectedBuilding]!
hoverSprite.scale.set(buildingDef.width, buildingDef.height, 1)
hoverSprite.setFrame(spritesSheet.getFrame(buildingDef.frame))
}
document.querySelectorAll('.building-btn').forEach((btn, index) => {
btn.addEventListener('click', () => {
selectedBuilding = index
updateHoverSprite()
document.querySelectorAll('.building-btn').forEach((b, i) => {
b.classList.toggle('selected', i === index)
})
})
})
// Place some initial entities
placeBuilding(2, 3)
placeBuilding(5, 5)
placeBuilding(8, 2)
selectedBuilding = 2 // Switch to tree
updateHoverSprite()
placeBuilding(1, 6)
placeBuilding(10, 4)
placeBuilding(7, 6)
selectedBuilding = 0 // Back to house
updateHoverSprite()
// Tweakpane debug UI
const { pane, update: updateDevtools } = createPane({ driver: 'manual' })
const devtools = createDevtoolsProvider({ name: 'batch-demo' })
const exampleStats = { sprites: 0, batches: 0 }
const statsFolder = pane.addFolder({ title: 'Batching', expanded: false })
statsFolder.addBinding(exampleStats, 'sprites', { readonly: true, format: (v: number) => v.toFixed(0) })
statsFolder.addBinding(exampleStats, 'batches', { readonly: true, format: (v: number) => v.toFixed(0) })
// Handle resize
function handleResize() {
const aspect = window.innerWidth / window.innerHeight
const viewAspect = viewWidth / viewHeight
if (aspect > viewAspect) {
// Window is wider - fit to height
camera.top = viewHeight / 2
camera.bottom = -viewHeight / 2
camera.left = (-viewHeight * aspect) / 2
camera.right = (viewHeight * aspect) / 2
} else {
// Window is taller - fit to width
camera.left = -viewWidth / 2
camera.right = viewWidth / 2
camera.top = viewWidth / aspect / 2
camera.bottom = -viewWidth / aspect / 2
}
camera.updateProjectionMatrix()
renderer.setSize(window.innerWidth, window.innerHeight)
}
window.addEventListener('resize', handleResize)
handleResize()
// Animation loop
function animate() {
rafId = requestAnimationFrame(animate)
devtools.beginFrame(performance.now(), renderer)
renderer.render(scene, camera)
devtools.endFrame(renderer)
updateDevtools()
// Update stats monitors
const groupStats = spriteGroup.stats
exampleStats.sprites = groupStats.spriteCount
exampleStats.batches = groupStats.batchCount
pane.refresh()
}
animate()
}
main()
if (import.meta.hot) {
import.meta.hot.dispose(() => {
if (rafId) {
cancelAnimationFrame(rafId)
rafId = 0
}
if (activeRenderer) {
activeRenderer.dispose?.()
activeRenderer.domElement.remove()
activeRenderer = null
}
})
}
import { useState, useMemo, useCallback, useEffect, useRef } from 'react'
import { Canvas, useFrame, useLoader, useThree, extend } from '@react-three/fiber/webgpu'
import { Vector2, Raycaster, Plane, Vector3, type OrthographicCamera } from 'three'
import {
Sprite2D,
Sprite2DMaterial,
SpriteGroup,
Layers,
TextureLoader,
SpriteSheetLoader,
type SpriteFrame,
type SpriteSheet,
type RenderStats,
} from 'three-flatland/react'
import { DevtoolsProvider, usePane } from '@three-flatland/devtools/react'
import type { Pane } from 'tweakpane'
import { GemBackground } from './GemBackground'
import { GEM } from './gem'
// Extend R3F with our custom classes
extend({ SpriteGroup, Sprite2D, Sprite2DMaterial })
// Letterboxed orthographic camera that fits viewWidth × viewHeight in the canvas
function FitOrthoCamera({ viewWidth, viewHeight }: { viewWidth: number; viewHeight: number }) {
const set = useThree((s) => s.set)
const size = useThree((s) => s.size)
const aspect = size.width / size.height
const viewAspect = viewWidth / viewHeight
return (
<orthographicCamera
ref={(cam: OrthographicCamera | null) => {
if (!cam) return
if (aspect > viewAspect) {
// Window wider — fit to height
cam.top = viewHeight / 2
cam.bottom = -viewHeight / 2
cam.left = (-viewHeight * aspect) / 2
cam.right = (viewHeight * aspect) / 2
} else {
// Window taller — fit to width
cam.left = -viewWidth / 2
cam.right = viewWidth / 2
cam.top = viewWidth / aspect / 2
cam.bottom = -viewWidth / aspect / 2
}
cam.updateProjectionMatrix()
set({ camera: cam })
}}
position={[0, 0, 100]}
near={0.1}
far={1000}
manual
/>
)
}
// Configuration
const TILE_SIZE = 64
const GRID_WIDTH = 12
const GRID_HEIGHT = 8
const ASSET_BASE = './assets/'
// Grass tilemap UV size (32x32 tiles in 640x256 texture)
const TILE_UV_SIZE = { width: 32 / 640, height: 32 / 256 }
const SLICE_TILES = 6
function getGrassTileUV(x: number, y: number) {
const maxX = GRID_WIDTH - 1
const maxY = GRID_HEIGHT - 1
let sliceCol = x === 0 ? 0 : x === maxX ? SLICE_TILES - 1 : 1 + ((x - 1) % (SLICE_TILES - 2))
let sliceRow = y === 0 ? 0 : y === maxY ? SLICE_TILES - 1 : 1 + ((y - 1) % (SLICE_TILES - 2))
const flippedSliceRow = SLICE_TILES - 1 - sliceRow
return {
x: sliceCol * TILE_UV_SIZE.width,
y: 1 - (flippedSliceRow + 1) * TILE_UV_SIZE.height,
}
}
// Building definitions — frame names refer to the shared sprites atlas
// (sprites.png + sprites.atlas.json). All buildings load from one
// texture → one material → one SpriteBatch, so per-sprite zIndex
// Y-sort works across building types.
// Two paths for the same content, by design:
// `frame` → atlas frame in sprites.png — used by the GAME RENDER so
// all buildings share one material and batch together
// `texture` → individual PNG — used by the PICKER UI `<img>` element
// (atlas frames packed without padding bleed when scaled
// via CSS background-size; HTML img tags don't benefit
// from atlasing anyway since there's nothing to batch)
// Render dimensions = atlas sourceSize so manual scale and Sprite2D's
// auto-sizing (setFrame → updateSize on first frame) agree. Without
// this, hover sprite (reuses one Sprite2D across building switches, so
// updateSize fires only the first time) diverges from placed sprites
// (one Sprite2D per placement, updateSize always fires on init).
const BUILDINGS = [
{ name: 'house', frame: 'house', texture: 'buildings/House_Blue.png', width: 108, height: 148, shadowScale: 1.2 },
{ name: 'tower', frame: 'tower_0', texture: 'buildings/Tower_Blue.png', width: 114, height: 183, shadowScale: 1.0 },
{ name: 'tree', frame: 'tree_0', texture: 'deco/Tree.png', width: 111, height: 174, shadowScale: 1.5 },
] as const
// Entity state
interface PlacedEntity {
id: number
buildingIndex: number
gridX: number
gridY: number
}
// Grid positions for ground tiles (computed once)
const GROUND_POSITIONS = Array.from({ length: GRID_HEIGHT * GRID_WIDTH }, (_, i) => ({
x: i % GRID_WIDTH,
y: Math.floor(i / GRID_WIDTH),
}))
// ============================================
// DECLARATIVE COMPONENTS
// ============================================
interface GroundTileProps {
gridX: number
gridY: number
material: Sprite2DMaterial
gridOffsetX: number
gridOffsetY: number
}
function GroundTile({ gridX, gridY, material, gridOffsetX, gridOffsetY }: GroundTileProps) {
const uv = getGrassTileUV(gridX, gridY)
const frame: SpriteFrame = {
name: 'grass',
x: uv.x,
y: uv.y,
width: TILE_UV_SIZE.width,
height: TILE_UV_SIZE.height,
sourceWidth: TILE_SIZE,
sourceHeight: TILE_SIZE,
}
return (
<sprite2D
material={material}
position={[gridOffsetX + gridX * TILE_SIZE, gridOffsetY + gridY * TILE_SIZE, 0]}
layer={Layers.GROUND}
zIndex={0}
frame={frame}
/>
)
}
interface EntitySpritesProps {
entity: PlacedEntity
spritesMaterial: Sprite2DMaterial
spritesSheet: SpriteSheet
shadowMaterial: Sprite2DMaterial
gridOffsetX: number
gridOffsetY: number
}
function EntitySprites({ entity, spritesMaterial, spritesSheet, shadowMaterial, gridOffsetX, gridOffsetY }: EntitySpritesProps) {
const building = BUILDINGS[entity.buildingIndex]!
const posX = gridOffsetX + entity.gridX * TILE_SIZE
const posY = gridOffsetY + entity.gridY * TILE_SIZE
const frame = spritesSheet.getFrame(building.frame)
return (
<>
{/* Shadow */}
<sprite2D
material={shadowMaterial}
position={[posX, posY - TILE_SIZE * 0.3, 0]}
scale={[TILE_SIZE * building.shadowScale, TILE_SIZE * building.shadowScale * 0.5, 1]}
layer={Layers.SHADOWS}
zIndex={0}
alpha={0.5}
/>
{/* Building — shared spritesMaterial so all building types batch
together → cross-entity zIndex Y-sort works */}
<sprite2D
material={spritesMaterial}
position={[posX, posY + building.height / 2 - TILE_SIZE / 2, 0]}
scale={[building.width, building.height, 1]}
layer={Layers.ENTITIES}
zIndex={-Math.floor(posY)}
frame={frame}
/>
</>
)
}
interface HoverPreviewProps {
visible: boolean
position: [number, number, number]
material: Sprite2DMaterial
spritesSheet: SpriteSheet
building: typeof BUILDINGS[number]
}
function HoverPreview({ visible, position, material, spritesSheet, building }: HoverPreviewProps) {
const frame: SpriteFrame = spritesSheet.getFrame(building.frame)
return (
<sprite2D
visible={visible}
material={material}
position={position}
scale={[building.width, building.height, 1]}
alpha={0.5}
layer={Layers.FOREGROUND}
renderOrder={1000}
frame={frame}
/>
)
}
// ============================================
// MAIN SCENE
// ============================================
function StatsMonitor({ pane, spriteStats }: { pane: Pane; spriteStats: RenderStats }) {
const statsObjRef = useRef({ sprites: 0, batches: 0 })
const folderRef = useRef<ReturnType<Pane['addFolder']> | null>(null)
useEffect(() => {
const statsFolder = pane.addFolder({ title: 'Batching', expanded: false })
statsFolder.addBinding(statsObjRef.current, 'sprites', { readonly: true, format: (v: number) => v.toFixed(0) })
statsFolder.addBinding(statsObjRef.current, 'batches', { readonly: true, format: (v: number) => v.toFixed(0) })
folderRef.current = statsFolder
return () => {
statsFolder.dispose()
}
}, [pane])
// Update stats values each frame via useFrame is not possible here (outside Canvas),
// so we update on each render
statsObjRef.current.sprites = spriteStats.spriteCount
statsObjRef.current.batches = spriteStats.batchCount
pane.refresh()
return null
}
interface VillageSceneProps {
entities: PlacedEntity[]
selectedBuilding: number
onPlaceBuilding: (gridX: number, gridY: number) => void
onStats: (stats: RenderStats) => void
}
function VillageScene({ entities, selectedBuilding, onPlaceBuilding, onStats }: VillageSceneProps) {
const { camera, gl } = useThree()
// Load textures (presets are automatically applied - NearestFilter + SRGBColorSpace)
const grassTex = useLoader(TextureLoader, ASSET_BASE + 'terrain/Tilemap_Flat.png')
const shadowTex = useLoader(TextureLoader, ASSET_BASE + 'terrain/Shadows.png')
const spritesSheet = useLoader(SpriteSheetLoader, ASSET_BASE + 'buildings/sprites.atlas.json')
// Create materials (stable - each texture is a stable reference)
const grassMaterial = useMemo(() => new Sprite2DMaterial({ map: grassTex }), [grassTex])
const shadowMaterial = useMemo(() => new Sprite2DMaterial({ map: shadowTex }), [shadowTex])
// ONE material for ALL buildings + trees → ONE batch → cross-entity
// zIndex Y-sort works correctly.
const spritesMaterial = useMemo(
() => new Sprite2DMaterial({ map: spritesSheet.texture }),
[spritesSheet]
)
// Grid calculations
const gridOffsetX = (-GRID_WIDTH * TILE_SIZE) / 2 + TILE_SIZE / 2
const gridOffsetY = (-GRID_HEIGHT * TILE_SIZE) / 2 + TILE_SIZE / 2
// Occupied cells
const occupiedCells = useMemo(() => {
const cells = new Set<string>()
entities.forEach((e) => cells.add(`${e.gridX},${e.gridY}`))
return cells
}, [entities])
// Hover state
const [hoverGrid, setHoverGrid] = useState<{ x: number; y: number } | null>(null)
const hoverGridRef = useRef(hoverGrid)
hoverGridRef.current = hoverGrid
const hoverVisible = hoverGrid !== null && !occupiedCells.has(`${hoverGrid.x},${hoverGrid.y}`)
// Mouse helpers
const raycaster = useMemo(() => new Raycaster(), [])
const groundPlane = useMemo(() => new Plane(new Vector3(0, 0, 1), 0), [])
const screenToGrid = useCallback(
(clientX: number, clientY: number) => {
const rect = gl.domElement.getBoundingClientRect()
const mouse = new Vector2(
((clientX - rect.left) / rect.width) * 2 - 1,
-((clientY - rect.top) / rect.height) * 2 + 1
)
raycaster.setFromCamera(mouse, camera)
const worldPos = new Vector3()
raycaster.ray.intersectPlane(groundPlane, worldPos)
const gx = Math.floor((worldPos.x - gridOffsetX + TILE_SIZE / 2) / TILE_SIZE)
const gy = Math.floor((worldPos.y - gridOffsetY + TILE_SIZE / 2) / TILE_SIZE)
if (gx >= 0 && gx < GRID_WIDTH && gy >= 0 && gy < GRID_HEIGHT) {
return { x: gx, y: gy }
}
return null
},
[camera, gl, raycaster, groundPlane, gridOffsetX, gridOffsetY]
)
// Mouse events - use useEffect for proper cleanup
useEffect(() => {
const canvas = gl.domElement
const onMouseMove = (e: MouseEvent) => setHoverGrid(screenToGrid(e.clientX, e.clientY))
const onMouseLeave = () => setHoverGrid(null)
const onClick = () => {
const grid = hoverGridRef.current
if (grid && !occupiedCells.has(`${grid.x},${grid.y}`)) {
onPlaceBuilding(grid.x, grid.y)
}
}
canvas.addEventListener('mousemove', onMouseMove)
canvas.addEventListener('mouseleave', onMouseLeave)
canvas.addEventListener('click', onClick)
return () => {
canvas.removeEventListener('mousemove', onMouseMove)
canvas.removeEventListener('mouseleave', onMouseLeave)
canvas.removeEventListener('click', onClick)
}
}, [gl, screenToGrid, occupiedCells, onPlaceBuilding])
// SpriteGroup ref for stats
const spriteGroupRef = useRef<SpriteGroup>(null)
// Surface SpriteGroup batching stats to the parent each frame
useFrame(() => {
if (spriteGroupRef.current) {
onStats(spriteGroupRef.current.stats)
}
}, { priority: -Infinity })
// Hover position
const hoverPosition: [number, number, number] = hoverGrid
? [
gridOffsetX + hoverGrid.x * TILE_SIZE,
gridOffsetY + hoverGrid.y * TILE_SIZE + BUILDINGS[selectedBuilding]!.height / 2 - TILE_SIZE / 2,
0,
]
: [0, 0, 0]
return (
<>
{/* Batched sprites */}
<spriteGroup ref={spriteGroupRef}>
{/* Ground tiles */}
{GROUND_POSITIONS.map(({ x, y }) => (
<GroundTile
key={`ground-${x}-${y}`}
gridX={x}
gridY={y}
material={grassMaterial}
gridOffsetX={gridOffsetX}
gridOffsetY={gridOffsetY}
/>
))}
{/* Entity sprites */}
{entities.map((entity) => (
<EntitySprites
key={entity.id}
entity={entity}
spritesMaterial={spritesMaterial}
spritesSheet={spritesSheet}
shadowMaterial={shadowMaterial}
gridOffsetX={gridOffsetX}
gridOffsetY={gridOffsetY}
/>
))}
</spriteGroup>
{/* Hover preview - NOT batched, renders separately with high renderOrder */}
<HoverPreview
visible={hoverVisible}
position={hoverPosition}
material={spritesMaterial}
spritesSheet={spritesSheet}
building={BUILDINGS[selectedBuilding]!}
/>
</>
)
}
// ============================================
// UI STYLES
// ============================================
const styles = {
ui: {
position: 'fixed',
bottom: 32,
left: '50%',
transform: 'translateX(-50%)',
display: 'flex',
gap: 6,
padding: 8,
background: 'rgba(0, 0, 0, 0.7)',
borderRadius: 10,
zIndex: 100,
} as React.CSSProperties,
button: (selected: boolean) =>
({
width: 40,
height: 40,
border: `2px solid ${selected ? '#4a9eff' : 'transparent'}`,
borderRadius: 6,
backgroundColor: selected ? 'rgba(74, 158, 255, 0.2)' : 'rgba(255, 255, 255, 0.1)',
cursor: 'pointer',
transition: 'all 0.15s ease',
overflow: 'hidden',
position: 'relative',
padding: 0,
}) as React.CSSProperties,
credits: {
position: 'fixed',
bottom: 8,
left: '50%',
transform: 'translateX(-50%)',
color: 'rgba(0, 0, 0, 0.5)',
fontSize: 9,
whiteSpace: 'nowrap',
zIndex: 100,
} as React.CSSProperties,
}
// ============================================
// APP
// ============================================
// Initial entities
const INITIAL_ENTITIES: PlacedEntity[] = [
{ id: 1, buildingIndex: 0, gridX: 2, gridY: 3 },
{ id: 2, buildingIndex: 0, gridX: 5, gridY: 5 },
{ id: 3, buildingIndex: 1, gridX: 8, gridY: 2 },
{ id: 4, buildingIndex: 2, gridX: 1, gridY: 6 },
{ id: 5, buildingIndex: 2, gridX: 10, gridY: 4 },
{ id: 6, buildingIndex: 2, gridX: 7, gridY: 6 },
]
export default function App() {
const [entities, setEntities] = useState<PlacedEntity[]>(INITIAL_ENTITIES)
const [selectedBuilding, setSelectedBuilding] = useState(0)
const [spriteStats, setSpriteStats] = useState<RenderStats>({ spriteCount: 0, batchCount: 0, visibleSprites: 0 })
const { pane } = usePane()
const viewWidth = TILE_SIZE * (GRID_WIDTH + 2)
const viewHeight = TILE_SIZE * (GRID_HEIGHT + 4)
const handlePlaceBuilding = useCallback(
(gridX: number, gridY: number) => {
setEntities((prev) => {
if (prev.some((e) => e.gridX === gridX && e.gridY === gridY)) return prev
// Derive next ID from existing entities to survive HMR
const nextId = Math.max(0, ...prev.map((e) => e.id)) + 1
return [...prev, { id: nextId, buildingIndex: selectedBuilding, gridX, gridY }]
})
},
[selectedBuilding]
)
return (
<>
<Canvas
dpr={1}
style={{ background: "#16191e" }}
renderer={{ antialias: false }}
onCreated={({ gl }) => {
gl.domElement.style.imageRendering = 'pixelated'
}}
>
{/* L1 + L2 — gem-tinted clear color + lit radial gradient. */}
<GemBackground gem={GEM} />
<FitOrthoCamera viewWidth={viewWidth} viewHeight={viewHeight} />
<DevtoolsProvider name="batch-demo" />
<VillageScene
entities={entities}
selectedBuilding={selectedBuilding}
onPlaceBuilding={handlePlaceBuilding}
onStats={setSpriteStats}
/>
</Canvas>
<StatsMonitor pane={pane} spriteStats={spriteStats} />
{/* TODO: migrate game UI to three-flatland events */}
<div style={styles.ui}>
{BUILDINGS.map((building, index) => {
const isTree = building.name === 'tree'
return (
<button
key={building.name}
style={styles.button(index === selectedBuilding)}
onClick={() => setSelectedBuilding(index)}
title={building.name}
>
<img
src={`${ASSET_BASE}${building.texture}`}
alt={building.name}
style={isTree
? { position: 'absolute', inset: 0, width: '400%', height: '300%', maxWidth: 'none', objectFit: 'cover', objectPosition: '0 0', pointerEvents: 'none' }
: { position: 'absolute', inset: 0, width: '100%', height: '100%', objectFit: 'contain', pointerEvents: 'none' }
}
/>
</button>
)
})}
</div>
<div style={styles.credits}>
Assets from{' '}
<a href="https://pixelfrog-assets.itch.io/tiny-swords" target="_blank" style={{ color: 'rgba(0, 0, 0, 0.6)' }}>
Tiny Swords
</a>{' '}
by Pixel Frog
</div>
</>
)
}

SpriteGroup automatically batches sprites that share the same material, minimizing draw calls:

const spriteGroup = new SpriteGroup();
scene.add(spriteGroup);
// All sprites with the same material are batched together
for (let i = 0; i < 1000; i++) {
const sprite = new Sprite2D({ material: sharedMaterial });
sprite.position.set(x, y, 0);
spriteGroup.add(sprite);
}

Use layers and zIndex for proper depth sorting:

import { Layers } from 'three-flatland';
sprite.layer = Layers.ENTITIES;
sprite.zIndex = -Math.floor(sprite.position.y); // Y-sort

Monitor batching performance with the stats object:

const { spriteCount, batchCount, visibleSprites } = spriteGroup.stats;
console.log(`Sprites: ${spriteCount}, Batches: ${batchCount}, Visible: ${visibleSprites}`);