Skip to content

Render tile-based maps efficiently with three-flatland.

What you’ll learn

  • How to load a Tiled or LDtk map and render it via TileMap2D with 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.

How TileMap2D builds a mapa tile atlas plus per-layer grid data become chunked geometry; layers add depth, markOccluders flags shadow casterstileset (atlas)gid -> source rect in image123456= wall (collision)loaded by TiledLoader orLDtkLoader into TileMapData:width, height, tileW/H,tilesets, tileLayers.Grid cells store gids thatindex into this atlas.grid layer dataone gid per cell, per layerborder ofgid 4 = thecollisionwall ring.interiorgids paintfloor andprops.markOccluders(['collision'])flips the CAST_SHADOW bit ininstanceSystem.z for every gid-4tile -> they cast SDF shadows.gid lookupchunked geometry, stacked by layereach layer batched into chunkSize x chunkSize chunks (default 512)layer 2 wallschunk grid - casts shadowslayer 1 floor / propschunk gridlayer 0 backgroundchunk grid - parallax depthdrawn backto frontsmaller chunks = finer culling, more draw calls. larger = fewer calls,coarser culling. getLayerAt(i), worldToTile / tileToWorld at runtime.batch intochunks
How TileMap2D builds a map — a tile atlas plus per-layer grid data become chunked geometry, stacked by layer for depth, with markOccluders flagging wall tiles as shadow casters.
import { TileMap2D, TiledLoader } from 'three-flatland';
// Load a Tiled map
const mapData = await TiledLoader.load('/maps/level1.json');
// Create tilemap
const tilemap = new TileMap2D({
data: mapData,
chunkSize: 512, // Tiles per chunk (default: 512)
});
scene.add(tilemap);
// In your animation loop
function animate() {
tilemap.update(deltaMs); // Update animated tiles
renderer.render(scene, camera);
}

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

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

Load maps created with LDtk :

import { TileMap2D, LDtkLoader } from 'three-flatland';
// Load a specific level by name
const mapData = await LDtkLoader.load('/maps/world.ldtk', 'Level_0');
const tilemap = new TileMap2D({ data: mapData });
scene.add(tilemap);
// List all levels in project
const levelIds = await LDtkLoader.getLevelIds('/maps/world.ldtk');

Access and manipulate tile layers:

// Get layer by index
const groundLayer = tilemap.getLayerAt(0);
const wallsLayer = tilemap.getLayerAt(1);
// Toggle visibility
groundLayer.visible = false;
// Get layer count
console.log(tilemap.layerCount);

Read and modify tiles at runtime:

// Get layer
const layer = tilemap.getLayerAt(0);
// Read tile at position
const tileGid = layer.getTileAt(x, y);
// Set tile at position
layer.setTileAt(x, y, newTileGid);

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 casters
tilemap.markOccluders(['collision'])

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 1

For 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.

Convert between world and tile coordinates:

// World position to tile position
const tilePos = tilemap.worldToTile(worldX, worldY);
console.log(tilePos.x, tilePos.y);
// Tile position to world position
const worldPos = tilemap.tileToWorld(tileX, tileY);
console.log(worldPos.x, worldPos.y);

Get information about the tilemap:

console.log('Total tiles:', tilemap.totalTileCount);
console.log('Total chunks:', tilemap.totalChunkCount);
console.log('Layer count:', tilemap.layerCount);

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.

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

Clean up resources when done:

scene.remove(tilemap);
tilemap.dispose();