Skip to content

Load spritesheets and tilemaps with configurable texture presets.

What you’ll learn

  • How to load textures, spritesheets, Tiled .tmj, and LDtk .ldtk files with the right filtering preset
  • How the TextureConfig → Loader → Instance preset hierarchy lets you set defaults once
  • How normals: true produces a baked normal-map atlas for NormalMapProvider lighting

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-artNearestFilter, no mipmaps, clamp wrap. If you’re shipping pixel art (most three-flatland projects), you don’t have to configure anything.

LoaderFormatUse Case
TextureLoaderPNG, JPG, SVG, etc.Single textures with preset support
SpriteSheetLoaderTexturePacker JSONSpritesheets and atlases
TiledLoaderTiled .json/.tmjTile-based maps
LDtkLoaderLDtk .ldtkLevel design with entities

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 { TextureLoader } from 'three-flatland';
// Preload multiple textures
const textures = await TextureLoader.preload([
'/sprites/player.png',
'/sprites/enemy.png',
'/sprites/items.png',
]);
// Override preset for specific texture
const smoothTexture = await TextureLoader.load('/sprites/hd-ui.png', {
texture: 'smooth'
});

Load spritesheets exported from TexturePacker  or compatible tools.

import { SpriteSheetLoader, Sprite2D } from 'three-flatland';
const sheet = await SpriteSheetLoader.load('/sprites/player.json');
// Access frames
const idleFrame = sheet.getFrame('player_idle_0');
const allFrames = sheet.getFrameNames();
// Use with Sprite2D
const sprite = new Sprite2D({ texture: sheet.texture });
sprite.setFrame(idleFrame);
const sheets = await SpriteSheetLoader.preload([
'/sprites/player.json',
'/sprites/enemies.json',
]);
  • JSON Hash (TexturePacker default) - Frames as object properties
  • JSON Array - Frames as array with filename property

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 });
  • Embedded and external tilesets
  • Tile layers with flip flags
  • Object layers for entities and collision
  • Tile animations
  • Infinite maps with chunks

Load projects from LDtk . See the Tilemaps guide for detailed usage.

import { LDtkLoader, TileMap2D } from 'three-flatland';
// Load a specific level
const mapData = await LDtkLoader.load('/maps/world.ldtk', 'Level_0');
const tilemap = new TileMap2D({ data: mapData });
// List all levels in project
const levelIds = await LDtkLoader.getLevelIds('/maps/world.ldtk');
  • Multi-level projects
  • Tile layers (Tiles, AutoLayer, IntGrid)
  • Entity layers with custom fields
  • IntGrid collision data
  • Tile flip flags

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.

Baked-normal asset pipeline — probe, decide, bakeLoader callSpriteSheetLoader( { normals: true })LDtkLoader(..., { normals: true })Probe for sidecar.normal.pngHEAD / range-fetchparse descriptor-hashtEXt chunkdescriptorhashmatches?Fast path — hash matchesLoad the baked atlasno in-browser bakeMissing or stale hashIn-memory bake on first loadlazy-import the bakerDev-mode warningRun: npx flatland-bake normalnormalMap atlasblue channel = elevationNormalMapProviderDefaultLightEffectforceRuntime: trueSkip the probe — always bakein browser. For procedurally-varied / throwaway assets.matchmiss / staleconsumes
The baked-normal pipeline — a hash-stamped .normal.png sidecar is probed and loaded on a match, with an in-memory bake fallback feeding NormalMapProvider.

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.

Both loaders use the same resolution path for the baked atlas:

  1. Hash the descriptor — every tile/frame’s region geometry plus its tilt direction, cap thickness, elevation, etc. produces a stable hash.
  2. Probe a baked sibling — look for <source-image>.normal.png next to the diffuse, with a matching hash stamp. If present, load it directly.
  3. 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 layout
  • NormalSourceDescriptor — pass a hand-authored descriptor
  • false (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.

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.texture
console.log(knightSheet.normalMap)

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:

Three.js
const sheet = await SpriteSheetLoader.load('./sprites/proc-tiles.json', {
normals: true,
forceRuntime: true,
})
// R3F useLoader
const sheet = useLoader(SpriteSheetLoader, './sprites/proc-tiles.json', (l) => {
l.normals = true
l.forceRuntime = 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 fieldEffect
tileDirDirection the face tilts ('up', 'down', 'left', 'right', 'flat', etc.)
tilePitchOverride the tilt angle in radians
tileCapShorthand cap thickness (top edge by default) in pixels
tileCapTop / tileCapBottom / tileCapLeft / tileCapRightPer-edge cap strip thickness in pixels
tileCapTopLeft / tileCapTopRight / tileCapBottomLeft / tileCapBottomRightSquare N×N corner caps for L-shaped wall-cap geometry
tileElevationElevation override in [0, 1] (0 = floor plane, 1 = top of wall)
tileBump / tileStrengthBump 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.texture
const tilesetNormals = mapData.tilesets[0]?.normalMap

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.

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 ?? null
tilemap.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 ?? null
hero.addEffect(heroNormals)
flatland.add(hero)

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.

All loaders use a unified texture configuration system. Presets control filtering, wrapping, and mipmaps.

PresetFilteringWrapMipmapsBest For
'pixel-art'NearestClampOffPixel art, retro games, tilemaps
'smooth'LinearClampOnHD sprites, smooth graphics
'none'Full manual control

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 extension
const 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.

Set a system-wide default for all loaders:

import { TextureConfig } from 'three-flatland';
// Change global default
TextureConfig.options = 'smooth';
// All loaders now use smooth filtering
const sheet = await SpriteSheetLoader.load('/sprites/player.json');
const mapData = await TiledLoader.load('/maps/level.json');
// Reset to system default
TextureConfig.reset(); // Back to 'pixel-art'

Override the default for a specific loader type:

import { TextureLoader, SpriteSheetLoader, TiledLoader } from 'three-flatland';
// HD textures with smooth filtering
TextureLoader.options = 'smooth';
// HD sprites with smooth filtering
SpriteSheetLoader.options = 'smooth';
// Tilemaps stay pixel-perfect
TiledLoader.options = 'pixel-art';
// Now these use different settings
const texture = await TextureLoader.load('/sprites/hd-player.png'); // smooth
const sprites = await SpriteSheetLoader.load('/sprites/ui.json'); // smooth
const mapData = await TiledLoader.load('/maps/dungeon.json'); // pixel-art

Override for a specific load call:

// One-off override
const hdSheet = await SpriteSheetLoader.load('/sprites/hd-ui.json', {
texture: 'smooth'
});
// Custom options
const customSheet = await SpriteSheetLoader.load('/sprites/special.json', {
texture: {
minFilter: LinearFilter,
magFilter: NearestFilter, // Mix filtering modes
}
});

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

Pixel Art Game (default)

// Just use defaults - pixel-art is the system default
const sheet = await SpriteSheetLoader.load('/sprites/player.json');
const mapData = await TiledLoader.load('/maps/level.json');

HD Game

// Set global default once at startup
TextureConfig.options = 'smooth';
// All loads now use smooth filtering
const sheet = await SpriteSheetLoader.load('/sprites/player.json');

Mixed Styles

// Configure each loader type
SpriteSheetLoader.options = 'smooth'; // HD sprites
TiledLoader.options = 'pixel-art'; // Pixel tilemaps
// Or override per-instance
const pixelSprite = await SpriteSheetLoader.load('/sprites/retro.json', {
texture: 'pixel-art'
});
// First load - fetches from network
const sheet1 = await SpriteSheetLoader.load('/sprites/player.json');
// Second load - returns cached result instantly
const sheet2 = await SpriteSheetLoader.load('/sprites/player.json');
console.log(sheet1 === sheet2); // true

Different texture options create separate cache entries:

// These are cached separately
const pixelSheet = await SpriteSheetLoader.load('/sprites/ui.json', { texture: 'pixel-art' });
const smoothSheet = await SpriteSheetLoader.load('/sprites/ui.json', { texture: 'smooth' });
console.log(pixelSheet === smoothSheet); // false

Clear the cache when needed:

TextureLoader.clearCache();
SpriteSheetLoader.clearCache();
TiledLoader.clearCache();
LDtkLoader.clearCache();

Load multiple assets in parallel:

// Preload spritesheets
const sheets = await SpriteSheetLoader.preload([
'/sprites/player.json',
'/sprites/enemies.json',
'/sprites/items.json',
]);
// Preload maps
const 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' }
);