What you’ll learn
- How to load a Tiled or LDtk map and render it via
TileMap2Dwith chunked rendering - How to mark wall tiles as shadow occluders with
markOccluders(['collision']) - How to read and modify tiles at runtime, and convert between world / tile coordinates
Tilemaps allow you to render large tile-based environments efficiently using chunked rendering. The TileMap2D class handles maps with multiple layers and automatic chunking for performance.
For your first map, you don’t need to think about chunks, layers, or culling. Drop a Tiled or LDtk file in, point the loader at it, you have a level.
Creating a Tilemap
Section titled “Creating a Tilemap”import { TileMap2D, TiledLoader } from 'three-flatland';
// Load a Tiled mapconst mapData = await TiledLoader.load('/maps/level1.json');
// Create tilemapconst tilemap = new TileMap2D({ data: mapData, chunkSize: 512, // Tiles per chunk (default: 512)});
scene.add(tilemap);
// In your animation loopfunction animate() { tilemap.update(deltaMs); // Update animated tiles renderer.render(scene, camera);}import { Suspense, useRef } from 'react';import { extend, useFrame, useLoader } from '@react-three/fiber/webgpu';import { TileMap2D, TiledLoader } from 'three-flatland/react';
extend({ TileMap2D });
function Level() { // useLoader suspends while loading, presets applied automatically const mapData = useLoader(TiledLoader, '/maps/level1.json'); const tilemapRef = useRef<TileMap2D>(null);
useFrame((_, delta) => { tilemapRef.current?.update(delta * 1000); });
return <tileMap2D ref={tilemapRef} data={mapData} chunkSize={512} />;}
// Wrap with Suspense in parentfunction App() { return ( <Suspense fallback={null}> <Level /> </Suspense> );}Map Data Structure
Section titled “Map Data Structure”The TileMapData interface describes the map format:
interface TileMapData { width: number; // Map width in tiles height: number; // Map height in tiles tileWidth: number; // Tile width in pixels tileHeight: number; // Tile height in pixels orientation: 'orthogonal' | 'isometric' | 'staggered' | 'hexagonal'; renderOrder: 'right-down' | 'right-up' | 'left-down' | 'left-up'; infinite: boolean; // Infinite map flag tilesets: TilesetData[]; tileLayers: TileLayerData[]; objectLayers: ObjectLayerData[]; properties?: Record<string, unknown>;}Loading Maps
Section titled “Loading Maps”Tiled Editor (.json/.tmj)
Section titled “Tiled Editor (.json/.tmj)”Load maps created with the Tiled Map Editor :
import { TileMap2D, TiledLoader } from 'three-flatland';
const mapData = await TiledLoader.load('/maps/level1.json');const tilemap = new TileMap2D({ data: mapData });scene.add(tilemap);import { Suspense } from 'react';import { extend, useLoader } from '@react-three/fiber/webgpu';import { TileMap2D, TiledLoader } from 'three-flatland/react';
extend({ TileMap2D });
function TiledMap() { const mapData = useLoader(TiledLoader, '/maps/level1.json'); return <tileMap2D data={mapData} />;}
// Usage with Suspense<Suspense fallback={<LoadingIndicator />}> <TiledMap /></Suspense>LDtk Editor (.ldtk)
Section titled “LDtk Editor (.ldtk)”Load maps created with LDtk :
import { TileMap2D, LDtkLoader } from 'three-flatland';
// Load a specific level by nameconst mapData = await LDtkLoader.load('/maps/world.ldtk', 'Level_0');const tilemap = new TileMap2D({ data: mapData });scene.add(tilemap);
// List all levels in projectconst levelIds = await LDtkLoader.getLevelIds('/maps/world.ldtk');import { Suspense } from 'react';import { extend, useLoader } from '@react-three/fiber/webgpu';import { TileMap2D, LDtkLoader } from 'three-flatland/react';
extend({ TileMap2D });
function LDtkMap() { // Loads first level by default const mapData = useLoader(LDtkLoader, '/maps/world.ldtk'); return <tileMap2D data={mapData} />;}
// Specify level via extension callbackfunction SpecificLevel() { const mapData = useLoader(LDtkLoader, '/maps/world.ldtk', (loader) => { loader.levelId = 'Level_0'; }); return <tileMap2D data={mapData} />;}
// Usage with Suspense<Suspense fallback={<LoadingIndicator />}> <LDtkMap /></Suspense>Working with Layers
Section titled “Working with Layers”Access and manipulate tile layers:
// Get layer by indexconst groundLayer = tilemap.getLayerAt(0);const wallsLayer = tilemap.getLayerAt(1);
// Toggle visibilitygroundLayer.visible = false;
// Get layer countconsole.log(tilemap.layerCount);Tile Operations
Section titled “Tile Operations”Read and modify tiles at runtime:
// Get layerconst layer = tilemap.getLayerAt(0);
// Read tile at positionconst tileGid = layer.getTileAt(x, y);
// Set tile at positionlayer.setTileAt(x, y, newTileGid);Shadow Casting
Section titled “Shadow Casting”Tilemaps participate in the lighting pipeline as shadow occluders. Rather than tagging tiles one by one, you mark whole categories of tiles by their LDtk obj.type — typically the 'collision' IntGrid identifier that LDtk assigns to wall tiles by default.
Call tilemap.markOccluders(typeNames, layerIndex?) after the map data is loaded. It walks every object layer, finds objects whose type is in typeNames, and flips the per-tile cast-shadow bit for the matching tile in the target layer (default: layer 0).
import { TileMap2D, LDtkLoader } from 'three-flatland'
const mapData = await LDtkLoader.load('/maps/dungeon.ldtk', 'Level_0')const tilemap = new TileMap2D({ data: mapData })scene.add(tilemap)
// Mark all 'collision' IntGrid tiles as shadow casterstilemap.markOccluders(['collision'])import { useEffect, useRef } from 'react'import { useLoader } from '@react-three/fiber/webgpu'import { LDtkLoader, TileMap2D } from 'three-flatland/react'
function Level() { const mapData = useLoader(LDtkLoader, './maps/dungeon.ldtk') const tilemapRef = useRef<TileMap2D>(null)
useEffect(() => { // Mark all 'collision' IntGrid tiles as shadow casters tilemapRef.current?.markOccluders(['collision']) }, [mapData])
return <tileMap2D ref={tilemapRef} data={mapData} />}Marked tiles get the CAST_SHADOW_MASK bit set in their interleaved instanceSystem.z attribute. The OcclusionPass picks this up during the SDF pre-pass — flagged tiles render into the shadow silhouette mask and produce real shadows in lit fragments.
You can pass multiple type names to mark several categories at once, e.g. markOccluders(['collision', 'pillar']). Pass a layerIndex if your wall geometry lives on a layer other than the first:
tilemap.markOccluders(['collision'], 1) // mark layer index 1For the broader story on how the SDF pre-pass, occlusion mask, and shadow tracing fit together, see the Shadows guide. For the per-sprite analogue (Sprite2D instances opting in/out as occluders), see the Sprites guide.
Coordinate Conversion
Section titled “Coordinate Conversion”Convert between world and tile coordinates:
// World position to tile positionconst tilePos = tilemap.worldToTile(worldX, worldY);console.log(tilePos.x, tilePos.y);
// Tile position to world positionconst worldPos = tilemap.tileToWorld(tileX, tileY);console.log(worldPos.x, worldPos.y);Statistics
Section titled “Statistics”Get information about the tilemap:
console.log('Total tiles:', tilemap.totalTileCount);console.log('Total chunks:', tilemap.totalChunkCount);console.log('Layer count:', tilemap.layerCount);Chunked Rendering
Section titled “Chunked Rendering”Tilemaps use chunked rendering for performance. Configure the chunk size based on your needs:
const tilemap = new TileMap2D({ data: mapData, chunkSize: 512, // tiles per chunk (default)});Smaller chunks = more draw calls but finer culling. Larger chunks = fewer draw calls but coarser culling. The default of 512 keeps most maps in a single chunk; lower it only for very large maps where finer culling pays off.
Animated Tiles
Section titled “Animated Tiles”Tiles with animation data (from Tiled) are automatically animated. Call update() each frame:
function animate() { const deltaMs = /* time since last frame */; tilemap.update(deltaMs); renderer.render(scene, camera);}Disposing
Section titled “Disposing”Clean up resources when done:
scene.remove(tilemap);tilemap.dispose();