What you’ll learn
- How to load textures, spritesheets, Tiled
.tmj, and LDtk.ldtkfiles with the right filtering preset - How the
TextureConfig → Loader → Instancepreset hierarchy lets you set defaults once - How
normals: trueproduces a baked normal-map atlas forNormalMapProviderlighting
three-flatland provides loaders for common 2D asset formats. All loaders share a unified texture configuration system with hierarchical presets.
The default preset is pixel-art — NearestFilter, no mipmaps, clamp wrap. If you’re shipping pixel art (most three-flatland projects), you don’t have to configure anything.
Available Loaders
Section titled “Available Loaders”| Loader | Format | Use Case |
|---|---|---|
TextureLoader | PNG, JPG, SVG, etc. | Single textures with preset support |
SpriteSheetLoader | TexturePacker JSON | Spritesheets and atlases |
TiledLoader | Tiled .json/.tmj | Tile-based maps |
LDtkLoader | LDtk .ldtk | Level design with entities |
TextureLoader
Section titled “TextureLoader”Load textures with automatic preset application. Works seamlessly with R3F’s useLoader.
import { TextureLoader, Sprite2D } from 'three-flatland';
// Load texture (presets automatically applied)const texture = await TextureLoader.load('/sprites/player.png');
const sprite = new Sprite2D({ texture });import { useLoader } from '@react-three/fiber/webgpu';import { TextureLoader, Sprite2D } from 'three-flatland/react';import { extend } from '@react-three/fiber/webgpu';
extend({ Sprite2D });
function Player() { // Presets automatically applied (pixel-art by default) const texture = useLoader(TextureLoader, '/sprites/player.png');
return <sprite2D texture={texture} />;}Multiple Textures
Section titled “Multiple Textures”import { TextureLoader } from 'three-flatland';
// Preload multiple texturesconst textures = await TextureLoader.preload([ '/sprites/player.png', '/sprites/enemy.png', '/sprites/items.png',]);import { useLoader } from '@react-three/fiber/webgpu';import { TextureLoader } from 'three-flatland/react';
// Define URLs at module level for stable referencesconst TEXTURE_URLS = ['/sprites/player.png', '/sprites/enemy.png', '/sprites/items.png'];
function Game() { // Load multiple textures at once const [playerTex, enemyTex, itemsTex] = useLoader(TextureLoader, TEXTURE_URLS); // ...}Override Presets
Section titled “Override Presets”// Override preset for specific textureconst smoothTexture = await TextureLoader.load('/sprites/hd-ui.png', { texture: 'smooth'});// Override preset via extension callbackconst smoothTexture = useLoader(TextureLoader, '/sprites/hd-ui.png', (loader) => { loader.preset = 'smooth';});SpriteSheetLoader
Section titled “SpriteSheetLoader”Load spritesheets exported from TexturePacker or compatible tools.
import { SpriteSheetLoader, Sprite2D } from 'three-flatland';
const sheet = await SpriteSheetLoader.load('/sprites/player.json');
// Access framesconst idleFrame = sheet.getFrame('player_idle_0');const allFrames = sheet.getFrameNames();
// Use with Sprite2Dconst sprite = new Sprite2D({ texture: sheet.texture });sprite.setFrame(idleFrame);import { Suspense } from 'react';import { useLoader } from '@react-three/fiber/webgpu';import { SpriteSheetLoader, Sprite2D } from 'three-flatland/react';import { extend } from '@react-three/fiber/webgpu';
extend({ Sprite2D });
function Player() { // useLoader suspends while loading, presets applied automatically const sheet = useLoader(SpriteSheetLoader, '/sprites/player.json'); const frame = sheet.getFrame('player_idle_0');
return <sprite2D texture={sheet.texture} frame={frame} />;}
// Wrap with Suspense<Suspense fallback={null}> <Player /></Suspense>Multiple Spritesheets
Section titled “Multiple Spritesheets”const sheets = await SpriteSheetLoader.preload([ '/sprites/player.json', '/sprites/enemies.json',]);// Define URLs at module level for stable referencesconst SHEET_URLS = ['/sprites/player.json', '/sprites/enemies.json'];
function Game() { const [playerSheet, enemySheet] = useLoader(SpriteSheetLoader, SHEET_URLS); // ...}Supported Formats
Section titled “Supported Formats”- JSON Hash (TexturePacker default) - Frames as object properties
- JSON Array - Frames as array with
filenameproperty
TiledLoader
Section titled “TiledLoader”Load maps from Tiled Map Editor . See the Tilemaps guide for detailed usage.
import { TiledLoader, TileMap2D } from 'three-flatland';
const mapData = await TiledLoader.load('/maps/level1.json');const tilemap = new TileMap2D({ data: mapData });import { Suspense } from 'react';import { useLoader } from '@react-three/fiber/webgpu';import { TiledLoader, TileMap2D } from 'three-flatland/react';import { extend } from '@react-three/fiber/webgpu';
extend({ TileMap2D });
function Level() { const mapData = useLoader(TiledLoader, '/maps/level1.json'); return <tileMap2D data={mapData} />;}
// Wrap with Suspense<Suspense fallback={null}> <Level /></Suspense>Features
Section titled “Features”- Embedded and external tilesets
- Tile layers with flip flags
- Object layers for entities and collision
- Tile animations
- Infinite maps with chunks
LDtkLoader
Section titled “LDtkLoader”Load projects from LDtk . See the Tilemaps guide for detailed usage.
import { LDtkLoader, TileMap2D } from 'three-flatland';
// Load a specific levelconst mapData = await LDtkLoader.load('/maps/world.ldtk', 'Level_0');const tilemap = new TileMap2D({ data: mapData });
// List all levels in projectconst levelIds = await LDtkLoader.getLevelIds('/maps/world.ldtk');import { Suspense } from 'react';import { useLoader } from '@react-three/fiber/webgpu';import { LDtkLoader, TileMap2D } from 'three-flatland/react';import { extend } from '@react-three/fiber/webgpu';
extend({ TileMap2D });
function Level() { // 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_1'; }); return <tileMap2D data={mapData} />;}
// Wrap with Suspense<Suspense fallback={null}> <Level /></Suspense>Features
Section titled “Features”- Multi-level projects
- Tile layers (Tiles, AutoLayer, IntGrid)
- Entity layers with custom fields
- IntGrid collision data
- Tile flip flags
Baked Normal Pipeline
Section titled “Baked Normal Pipeline”three-flatland’s lighting wants per-fragment normal vectors (for N·L diffuse) and an elevation scalar (for elevation-aware wall caps in DefaultLightEffect). Authoring a normal-map atlas alongside the diffuse atlas covers the easy case; for tilemaps, where per-tile orientation is decided in the level editor, LDtkLoader goes a step further and bakes a per-tileset normal atlas at load time from per-tile custom data.
Both SpriteSheetLoader and LDtkLoader accept a normals: true option. The resulting texture lands on the loaded asset (sheet.normalMap, tileset.normalMap) and is consumed by NormalMapProvider — a MaterialEffect from @three-flatland/presets — which feeds the normal and elevation channels into DefaultLightEffect’s lighting math.
The atlas encoding is RGB+A: R/G hold the tangent-space normal XY in [0, 1] (decoded to [-1, 1] at sample time, with Z reconstructed as sqrt(1 − x² − y²)), B holds elevation in [0, 1], and A passes through the source alpha.
Resolution Strategy
Section titled “Resolution Strategy”Both loaders use the same resolution path for the baked atlas:
- Hash the descriptor — every tile/frame’s region geometry plus its tilt direction, cap thickness, elevation, etc. produces a stable hash.
- Probe a baked sibling — look for
<source-image>.normal.pngnext to the diffuse, with a matching hash stamp. If present, load it directly. - In-memory bake fallback — fetch the diffuse, decode pixels, run the bake, and wrap the result in a
DataTexture.
Run npx flatland-bake normal <source> ahead of time to skip the in-memory path in production. For the offline workflow — running flatland-bake ahead of time, sidecar staleness warnings, CI integration — see the Baking guide.
The normals option accepts:
true— auto-synthesize the descriptor from the asset’s frame/tile layoutNormalSourceDescriptor— pass a hand-authored descriptorfalse(default) — no normal map is loaded or generated
forceRuntime: true is a sibling option (not nested inside normals) that declares the browser is where this asset’s normal map is produced — not the CI bake step. The in-memory bake runs on every load; the sidecar probe and “no baked sibling” warn are skipped. The data is always there, the same as the default path; this flag just commits to “the browser is where it’s produced for this asset, on purpose.” Use it for procedurally varied tilesets, throwaway prototypes, or asset bundles where shipping the sidecar isn’t worth the bytes. Same flag every baked-asset loader exposes (SlugFontLoader.forceRuntime). Not a dev-iteration knob — the default path (probe → bake on miss + warn) already handles iteration.
The in-memory bake path is lazy-imported, so its bundle cost is not paid unless the fallback fires. A dev-mode warning (NODE_ENV !== 'production') points at npx flatland-bake normal when the sidecar is absent. See the Runtime bake fallback section for the full story.
SpriteSheetLoader.normals: true
Section titled “SpriteSheetLoader.normals: true”For sprite sheets, the loader synthesizes one flat region per frame rect (read from the sheet JSON) and hands the descriptor to the baker. Region-local alpha clamping in the baker keeps adjacent frames from bleeding gradients into each other. The resulting atlas is exposed as sheet.normalMap (a THREE.Texture), 1:1 co-registered with sheet.texture.
import { SpriteSheetLoader } from 'three-flatland'
const knightSheet = await SpriteSheetLoader.load('./sprites/knight.json', { normals: true,})
// sheet.normalMap is a Texture aligned to sheet.textureconsole.log(knightSheet.normalMap)const knightSheet = useLoader(SpriteSheetLoader, './sprites/knight.json', (l) => { l.normals = true})For sprites whose individual frames want different per-frame tilt/bump (rare — most character sheets stay flat with alpha-derived shading) pass a NormalSourceDescriptor instead of true and provide your own regions. For an atlas whose normal map is generated in the browser by design (procedural variation, throwaway prototype, lean bundle), set forceRuntime: true:
const sheet = await SpriteSheetLoader.load('./sprites/proc-tiles.json', { normals: true, forceRuntime: true,})
// R3F useLoaderconst sheet = useLoader(SpriteSheetLoader, './sprites/proc-tiles.json', (l) => { l.normals = true l.forceRuntime = true})LDtkLoader.normals: true
Section titled “LDtkLoader.normals: true”For LDtk projects, the loader walks every tile cell of every used tileset and reads the tile’s free-form custom data string. Where that string parses as a JSON object containing the recognized fields, the baker carves the cell into cap and face regions:
| Custom-data field | Effect |
|---|---|
tileDir | Direction the face tilts ('up', 'down', 'left', 'right', 'flat', etc.) |
tilePitch | Override the tilt angle in radians |
tileCap | Shorthand cap thickness (top edge by default) in pixels |
tileCapTop / tileCapBottom / tileCapLeft / tileCapRight | Per-edge cap strip thickness in pixels |
tileCapTopLeft / tileCapTopRight / tileCapBottomLeft / tileCapBottomRight | Square N×N corner caps for L-shaped wall-cap geometry |
tileElevation | Elevation override in [0, 1] (0 = floor plane, 1 = top of wall) |
tileBump / tileStrength | Bump source / gradient strength override |
A wall tile tagged { "tileDir": "down", "tileCap": 4 } bakes a 4px flat strip at the top of the cell at elevation 1 (the wall top), and the rest of the cell as a face region tilted “down” at the default mid-wall elevation. Untagged tiles bake as a single flat region — floors stay flat, no further work required.
The atlas lands on each tileset as tileset.normalMap:
import { LDtkLoader } from 'three-flatland'
const mapData = await LDtkLoader.load('./maps/dungeon.ldtk', undefined, { normals: true,})
// One normalMap per tileset, co-registered with tileset.textureconst tilesetNormals = mapData.tilesets[0]?.normalMapconst mapData = useLoader(LDtkLoader, './maps/dungeon.ldtk', (l) => { l.normals = true})The blue (elevation) channel feeds DefaultLightEffect’s elevation-aware diffuse — wall caps physically sit above lightHeight in the lighting math, so torches geometrically miss them. See the Shadows guide for the elevation-channel story.
Wiring to the Lighting System
Section titled “Wiring to the Lighting System”Once the normal map is loaded, attach a NormalMapProvider MaterialEffect to the consuming Sprite2D / AnimatedSprite2D / TileMap2D. The provider feeds the normal and elevation channels into the active LightEffect (typically DefaultLightEffect).
import { Flatland, Sprite2D, AnimatedSprite2D, TileMap2D, SpriteSheetLoader, LDtkLoader,} from 'three-flatland'import { DefaultLightEffect, NormalMapProvider,} from '@three-flatland/presets'
const flatland = new Flatland({ viewSize: 400 })flatland.setLighting(new DefaultLightEffect())
const [knightSheet, mapData] = await Promise.all([ SpriteSheetLoader.load('./sprites/knight.json', { normals: true }), LDtkLoader.load('./maps/dungeon.ldtk', undefined, { normals: true }),])
// Tilemap — baked tileset normals drive directional lighting// (walls tilt toward their visible face, floors stay flat).const tilemap = new TileMap2D({ data: mapData })const tilemapNormals = new NormalMapProvider()tilemapNormals.normalMap = mapData.tilesets[0]?.normalMap ?? nulltilemap.addEffect(tilemapNormals)flatland.add(tilemap)
// Hero — sheet.normalMap lights every animation frame consistently.const hero = new AnimatedSprite2D({ spriteSheet: knightSheet, /* ... */ })const heroNormals = new NormalMapProvider()heroNormals.normalMap = knightSheet.normalMap ?? nullhero.addEffect(heroNormals)flatland.add(hero)import { useLoader } from '@react-three/fiber/webgpu'import { SpriteSheetLoader, LDtkLoader, AnimatedSprite2D, TileMap2D, attachEffect, attachLighting,} from 'three-flatland/react'import { DefaultLightEffect, NormalMapProvider } from '@three-flatland/presets'import '@three-flatland/presets/react'
extend({ AnimatedSprite2D, TileMap2D, DefaultLightEffect, NormalMapProvider })
function Scene() { const knightSheet = useLoader(SpriteSheetLoader, './sprites/knight.json', (l) => { l.normals = true }) const mapData = useLoader(LDtkLoader, './maps/dungeon.ldtk', (l) => { l.normals = true })
return ( <flatland viewSize={400}> <defaultLightEffect attach={attachLighting} />
{/* Floor + walls — baked tileset normals from per-tile `tileDir` / `tileCap*` custom data. */} <tileMap2D data={mapData}> <normalMapProvider attach={attachEffect} normalMap={mapData.tilesets[0]?.normalMap ?? null} /> </tileMap2D>
{/* Hero — sheet's baked atlas lights every frame consistently. */} <animatedSprite2D texture={knightSheet.texture} spriteSheet={knightSheet} /* ... */ > <normalMapProvider attach={attachEffect} normalMap={knightSheet.normalMap ?? null} /> </animatedSprite2D> </flatland> )}For the broader lighting story, see the Lighting guide (presets and Light2D types) and the Shadows guide (elevation-aware shaping and the SDF trace). For the NormalMapProvider MaterialEffect in context with the rest of the effect system, see Pass Effects.
Texture Presets
Section titled “Texture Presets”All loaders use a unified texture configuration system. Presets control filtering, wrapping, and mipmaps.
Available Presets
Section titled “Available Presets”| Preset | Filtering | Wrap | Mipmaps | Best For |
|---|---|---|---|---|
'pixel-art' | Nearest | Clamp | Off | Pixel art, retro games, tilemaps |
'smooth' | Linear | Clamp | On | HD sprites, smooth graphics |
'none' | — | — | — | Full manual control |
Configuration Hierarchy
Section titled “Configuration Hierarchy”Texture options follow a hierarchy (highest to lowest priority):
Instance preset/options → Loader.options → TextureConfig.options → 'pixel-art' (highest) (middle) (lowest) (default)For TextureLoader with R3F’s useLoader, the instance preset is set via the extension callback:
// Instance-level override via extensionconst texture = useLoader(TextureLoader, '/sprite.png', (loader) => { loader.preset = 'smooth'; // Highest priority});Each level overrides the ones below it. If nothing is set, 'pixel-art' is used.
Global Configuration
Section titled “Global Configuration”Set a system-wide default for all loaders:
import { TextureConfig } from 'three-flatland';
// Change global defaultTextureConfig.options = 'smooth';
// All loaders now use smooth filteringconst sheet = await SpriteSheetLoader.load('/sprites/player.json');const mapData = await TiledLoader.load('/maps/level.json');
// Reset to system defaultTextureConfig.reset(); // Back to 'pixel-art'Per-Loader Configuration
Section titled “Per-Loader Configuration”Override the default for a specific loader type:
import { TextureLoader, SpriteSheetLoader, TiledLoader } from 'three-flatland';
// HD textures with smooth filteringTextureLoader.options = 'smooth';
// HD sprites with smooth filteringSpriteSheetLoader.options = 'smooth';
// Tilemaps stay pixel-perfectTiledLoader.options = 'pixel-art';
// Now these use different settingsconst texture = await TextureLoader.load('/sprites/hd-player.png'); // smoothconst sprites = await SpriteSheetLoader.load('/sprites/ui.json'); // smoothconst mapData = await TiledLoader.load('/maps/dungeon.json'); // pixel-artPer-Instance Configuration
Section titled “Per-Instance Configuration”Override for a specific load call:
// One-off overrideconst hdSheet = await SpriteSheetLoader.load('/sprites/hd-ui.json', { texture: 'smooth'});
// Custom optionsconst customSheet = await SpriteSheetLoader.load('/sprites/special.json', { texture: { minFilter: LinearFilter, magFilter: NearestFilter, // Mix filtering modes }});// Override preset via extension callbackconst hdSheet = useLoader(SpriteSheetLoader, '/sprites/hd-ui.json', (loader) => { loader.preset = 'smooth';});
// Custom optionsconst customSheet = useLoader(SpriteSheetLoader, '/sprites/special.json', (loader) => { loader.preset = { minFilter: LinearFilter, magFilter: NearestFilter, };});Full Manual Control
Section titled “Full Manual Control”Use 'none' for complete control via .then():
import { RepeatWrapping } from 'three';
const sheet = await SpriteSheetLoader.load('/sprites/tiles.json', { texture: 'none'}).then(s => { // Full manual configuration s.texture.wrapS = RepeatWrapping; s.texture.wrapT = RepeatWrapping; s.texture.anisotropy = 16; return s;});Common Patterns
Section titled “Common Patterns”Pixel Art Game (default)
// Just use defaults - pixel-art is the system defaultconst sheet = await SpriteSheetLoader.load('/sprites/player.json');const mapData = await TiledLoader.load('/maps/level.json');HD Game
// Set global default once at startupTextureConfig.options = 'smooth';
// All loads now use smooth filteringconst sheet = await SpriteSheetLoader.load('/sprites/player.json');Mixed Styles
// Configure each loader typeSpriteSheetLoader.options = 'smooth'; // HD spritesTiledLoader.options = 'pixel-art'; // Pixel tilemaps
// Or override per-instanceconst pixelSprite = await SpriteSheetLoader.load('/sprites/retro.json', { texture: 'pixel-art'});Caching
Section titled “Caching”// First load - fetches from networkconst sheet1 = await SpriteSheetLoader.load('/sprites/player.json');
// Second load - returns cached result instantlyconst sheet2 = await SpriteSheetLoader.load('/sprites/player.json');
console.log(sheet1 === sheet2); // trueDifferent texture options create separate cache entries:
// These are cached separatelyconst pixelSheet = await SpriteSheetLoader.load('/sprites/ui.json', { texture: 'pixel-art' });const smoothSheet = await SpriteSheetLoader.load('/sprites/ui.json', { texture: 'smooth' });
console.log(pixelSheet === smoothSheet); // falseClear the cache when needed:
TextureLoader.clearCache();SpriteSheetLoader.clearCache();TiledLoader.clearCache();LDtkLoader.clearCache();Preloading
Section titled “Preloading”Load multiple assets in parallel:
// Preload spritesheetsconst sheets = await SpriteSheetLoader.preload([ '/sprites/player.json', '/sprites/enemies.json', '/sprites/items.json',]);
// Preload mapsconst maps = await TiledLoader.preload([ '/maps/level1.json', '/maps/level2.json',]);With texture options:
const sheets = await SpriteSheetLoader.preload( ['/sprites/ui.json', '/sprites/hud.json'], { texture: 'smooth' });