Skip to content

Offline asset-baking pipeline — flatland-bake CLI, sidecar files, hash stamps, and dev-time staleness warnings.

What you'll learn

  • How flatland-bake produces sidecar PNGs (<asset>.normal.png) with stamped content hashes
  • How the runtime probe decides between the baked sidecar and an in-memory bake fallback
  • How to wire baking into a CI / build script so production never pays runtime bake cost

@three-flatland/bake is a generic asset-pipeline framework: discovery, sidecar PNGs with stamped hashes, and dev-time staleness warnings. Each bake type ships as its own small package that registers a subcommand: @three-flatland/normals contributes flatland-bake normal (tangent-space normal maps), and @three-flatland/alphamap contributes flatland-bake alpha (alpha hitmasks for pixel-perfect hit testing). Future bake types (lightmap atlases, font SDFs, occlusion atlases) plug into the same machinery — install the one you need.

You only need this page when you're ready to ship. During development, the runtime bake fallback handles everything — no CLI step, no extra files. Read on when first-paint cost shows up in production.

For the runtime side of the same pipeline — how SpriteSheetLoader and LDtkLoader consume baked siblings via normals: true — see the Baked Normal Pipeline section of the Loaders guide. This page is the workflow counterpart: how to run the baker offline, integrate it into CI, and reason about staleness.

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 — probe for a hash-stamped sidecar, load it on a match, or fall back to an in-memory bake; forceRuntime skips the probe.
  • Source asset — a sprite sheet PNG (plus its TexturePacker JSON), an LDtk tileset, or any other texture the runtime wants enriched. Lives in your project's public/ (or equivalent) directory.
  • Bake stepflatland-bake <type> <source> runs the registered baker (e.g. normal or alpha) and writes a sidecar file (<source>.normal.png, <source>.alpha.png) next to the source. The sidecar carries the descriptor's content hash stamped into a PNG tEXt chunk under the flatland keyword.
  • Runtime — a sidecar-aware loader (SpriteSheetLoader / LDtkLoader with normals: true, or resolveNormalMap directly) hashes the descriptor it would have used, range-fetches the sidecar's first ~4 KB to read the stamp, and uses the baked file when the hashes match. On miss it falls back to an in-memory bake and emits a dev-time warning.

The pipeline is fully renderer-agnostic — the baker doesn't depend on Three.js, and the sidecar is a plain RGBA PNG with metadata. Any 2D engine can consume the same files.

The flatland-bake binary is the entry point. It walks node_modules (and the cwd package), discovers any package with a flatland.bake manifest in its package.json, and dispatches <subcommand> to the matching baker.

Terminal window
# List every baker installed in the current project
npx flatland-bake --list
# Run the normal-map baker on a single PNG
npx flatland-bake normal ./public/sprites/knight.png
# Per-baker help (forwards `--help` to the registered baker)
npx flatland-bake normal --help

Contributed by @three-flatland/normals. Reads an RGBA PNG, derives a tangent-space normal map, and writes <input>.normal.png (or the second positional path) stamped with the descriptor hash.

Terminal window
npx flatland-bake normal <input.png> [output.png] [options]
# Flat-tilt convenience flags (whole-image)
--direction <dir> flat | up | down | left | right | north | (default: flat)
--pitch <radians> tilt angle from flat (default: π/4)
--bump <mode> alpha | none (default: alpha)
--strength <n> gradient multiplier (default: 1)
# Region-aware baking
--descriptor <path> JSON descriptor frames, tiles, cap/face splits, per-region tilt

Use the flat flags for simple per-frame work. For tilemaps where each tile carries different cap/face geometry, write a JSON descriptor and pass it via --descriptor. The scripts/bake-dungeon-normals.ts script in the repo is a worked example: it reads tile customData out of an LDtk project, synthesizes a region-aware descriptor with tilesetToRegions, and bakes the tileset PNG.

Contributed by @three-flatland/alphamap. Reads an RGBA PNG and writes <input>.alpha.png — the source alpha stored in the R channel (replicated to G/B for viewability), stamped with the descriptor hash. It backs hitTestMode: 'alpha' for pixel-perfect pointer picking; see the Hit Testing guide.

Terminal window
npx flatland-bake alpha <input.png> [output.png]

The baker writes a sibling PNG next to the source. Naming follows a fixed convention per baker type:

| Source | Sidecar | |--------|---------| | knight.png | knight.normal.png | | Dungeon_Tileset.png | Dungeon_Tileset.normal.png |

The hash lives inside the PNG itself as a tEXt chunk under the keyword flatland. The chunk's value is JSON: {"hash":"<fnv1a64 of the descriptor>","v":1}. Placement is intentional — the chunk is injected immediately after IHDR, near the head of the file, so a runtime probe can range-fetch the first ~4 KB and read the stamp without downloading the whole image.

The hash itself (hashDescriptor in the public API) is an FNV-1a 64-bit hash over a canonical (sorted-keys) JSON stringification of the descriptor. Stable across browser and node, no dependencies, no cryptographic guarantees — just a cheap content fingerprint for invalidation.

When the runtime probes the sidecar:

  1. HEAD request to confirm the sibling exists.
  2. Range: bytes=0-4095 to read the PNG header chunks.
  3. Parse the flatland tEXt chunk and compare its hash to what the loader would have used.
  4. Match → load the baked PNG directly. Mismatch → re-bake in memory and warn.

devtimeWarn is the shared warning channel — gated on NODE_ENV !== 'production' and deduped per (category, url) so the same message never fires twice. Sidecar-aware loaders use it uniformly across the ecosystem (currently just normal; future bakers slot into the same surface).

Two warning shapes you'll see while iterating:

[normal] /sprites/knight.normal.png exists but its descriptor hash is stale — re-baking in memory.
Run `npx flatland-bake normal /sprites/knight.png --descriptor <descriptor>.json` to refresh.
[normal] No baked sibling at /sprites/knight.normal.png — baking in memory.
Run `npx flatland-bake normal` in production to skip this path.

The canonical fix is to re-bake — run flatland-bake with the same descriptor your loader is using, the hash matches, the warning goes away. Don't reach for forceRuntime here: that flag declares "this asset's normal map is generated in the browser as a matter of architecture" (see Browser-generated assets below), not "shut this warning up while I iterate." Silencing the warn the wrong way buries the signal — the engine still gives you the data, but future maintainers lose the breadcrumb pointing at the missing bake step.

flatland-bake is CLI-only today — there's no Vite or Rollup plugin yet. The recommended pattern is a package.json script that runs ahead of your main build:

{
"scripts": {
"bake:assets": "flatland-bake normal ./public/sprites/knight.png && flatland-bake normal ./public/sprites/enemies.png",
"build": "pnpm bake:assets && vite build"
}
}

For tilemaps and other multi-region descriptors, drive the bake from a small tsx script that synthesizes the descriptor and calls bakeNormalMapFile directly — see scripts/bake-dungeon-normals.ts in the repo for a worked LDtk example. Commit the resulting *.normal.png (and the *.normal.json descriptor, if you keep one) alongside your source assets so deploys don't have to re-bake.

The output PNGs are deterministic for a given descriptor — same input, same hash stamp, same bytes. Re-running the bake is a no-op when nothing changed, which makes incremental builds and git diff review straightforward.

The choice is about when the work happens:

| Mode | Cost | Best For | |------|------|----------| | Offline (flatland-bake in CI) | Zero startup cost, deterministic, ships a slightly larger asset bundle (the sidecar) | Production builds, demos, anything user-facing | | Runtime (in-memory fallback) | Burns first-load CPU on fetch + decode + bake; non-deterministic timing | Development iteration, tweaking descriptors, experimenting with custom data | | Browser-generated (forceRuntime: true) | Same as runtime, plus no probe round-trip and no warning | Assets whose normal map is produced in the browser by design — procedural content, throwaway prototypes, asset bundles where the sidecar isn't worth shipping |

In production, ship baked siblings and the loader becomes a single-image-fetch with no decode overhead. In development, skip the bake step entirely and let the runtime regenerate — much faster than re-running the CLI on every descriptor tweak.

forceRuntime: true declares the browser is where this asset's normal map is produced — not the CI bake step. The contract that "if you ask for normals, you get normals" doesn't change; only where the bake runs does.

It's a project-level architectural choice about a specific asset, not a dev-iteration shortcut. Typical reasons to make that choice:

  • Procedurally varied content — the descriptor changes per session, so a pre-baked sidecar wouldn't match anyway.
  • Throwaway prototypes — you don't want to add a CI step for something you'll delete next week.
  • Lean asset bundles — the runtime CPU cost on first load is cheaper than the bytes the sidecar would add.
// "This sheet's normal map is produced in the browser. No sidecar exists."
const sheet = await SpriteSheetLoader.load('./sprites/proc-tiles.json', {
normals: true,
forceRuntime: true,
})

If you just want to silence a stale-sidecar warning while iterating, re-bake instead. forceRuntime is the wrong tool for that — it silences the warn but it also tells every future maintainer "this asset's normals are produced in the browser, on purpose, forever," which is a different statement about the project's architecture.

@three-flatland/bake is the generic framework; @three-flatland/normals is the model implementation. A new bake type lives in its own package, declares a flatland.bake manifest entry in its package.json pointing at a default-exported Baker, and reuses hashDescriptor + writeSidecarPng from @three-flatland/bake/node to stamp its outputs. On the runtime side it imports bakedSiblingURL, probeBakedSibling, and devtimeWarn from @three-flatland/bake to wire up the same probe-then-fallback flow. Once published, flatland-bake --list picks up the subcommand automatically — no central registry, no version coupling.