TileMap2D in Polyphase Engine
Overview
TileMap2D is a 2D tile-painting system for Polyphase that fits the engine's
asset, editor, scripting, and undo/redo model. It is designed for grid-based
2D games — top-down RPGs, platformers, dungeon crawlers, strategy maps — and
ships with a complete authoring workflow inside the editor plus a runtime Lua
query API for gameplay code.
The system is built around three pieces:
TileSetasset — owns the texture atlas, slicing, per-tile metadata (tags, collision, animation), 9-box brushes, and autotile rule sets.TileMapasset — owns the painted cell data: sparse chunked storage, layers, used-bounds bookkeeping. References aTileSet.TileMap2Dnode — aMesh3D-derived scene node that renders aTileMapasset by rebuilding per-frame chunked vertex meshes and binding the tileset's atlas texture to aMaterialLiteinstance.
In the editor, a dedicated Tile Paint mode wires all three together with
a paint manager (TilePaintManager), a tile palette, layer panel, fill
tools, freeform selection, 9-box and autotile authoring, find-uses helpers,
and overlay toggles. The editor camera auto-snaps to a top-down orthographic
view when entering tile paint mode and restores on exit.
Key features:
- Sparse chunked storage (32×32 chunks per layer) that supports negative coordinates and grows in any direction without allocating empty cells
- Multi-layer maps with visibility, lock, opacity, and Z-order
- 9 paint tools: Pencil, Eraser, Picker, Rect Fill, Flood Fill, Line, 9-Box brush, Marquee Select, Autotile
- Bresenham interpolation on pencil drag (no gaps when dragging fast)
- Live ghost-tile previews under the cursor for every paint tool
- Freeform selection (Shift+drag adds, Ctrl+drag removes); copy/cut/paste with flip-X / flip-Y / rotate-90 transforms; "Fill Selection With 9-Box Brush" applies the per-cell corner/edge/center algorithm to any shape
- Cell grid, collision, and tag overlay toggles in the View panel
- Per-tile metadata: tags, collision (None / Full Solid / Box / Boxes / Slope / Polygon), animation frames + fps
- Autotile rules with 3×3 tri-state neighbor pattern (
Don't Care/Must Be Self/Must Not Be Self) and member-tag / member-tile classification - Auto-orthographic camera + camera rotation lock in Tile Paint mode
- Editor preferences entry for grid colors at Preferences > Appearance > Viewport > Tilemap Grid
- Stroke-batched undo/redo (entire drag commits as one action)
- Asset dirty-flag wired into the existing unsaved-changes shutdown popup
- Cross-platform: Vulkan render path implemented; GameCube/Wii (
API_GX) and 3DS (API_C3D) backends are stubbed (no-op) so retro builds compile
Quick Start
Creating a TileSet
- In the asset browser, right-click → Create Asset → Tile Set.
- Open the new TileSet in the inspector and assign:
- Texture — your atlas image (e.g. a 1024×512 PNG)
- Atlas Tile Width / Atlas Tile Height — pixel size of one tile
(e.g.
32and32) - Atlas Margin X/Y — pixel border around the atlas (default
0) - Atlas Spacing X/Y — pixel gap between tiles (default
0) - The
mTilesarray auto-populates to(atlas_width / tile_width) × (atlas_height / tile_height)slots. Each slot has a stable index that stays valid even if you change the atlas later.
Creating a TileMap
- Asset browser → Create Asset → Tile Map.
- In the inspector, set TileSet to the TileSet you just made.
- Optionally adjust Cell Size X/Y — the world-space dimensions of one
cell (default
1×1). This is independent of the atlas tile pixel size; the atlas slicing controls how the texture is sampled, while the cell size controls how big each painted cell is in world units.
Spawning a TileMap2D node
- Drop a
TileMap2Dnode into your scene (right-click in the hierarchy or from a node spawn menu). - In the inspector, assign your
TileMapasset to the Tile Map field. - Switch the editor mode combo to Tile Paint, or click "Open In Tile Paint" in the inspector when a TileMap2D is selected.
- The editor camera snaps directly above the node looking down -Z, switches to orthographic projection, and locks rotation.
Painting
- In the Tile Paint panel, expand Tile Palette and click any tile to select it.
- Switch the tool radio to Pencil.
- Click + drag in the viewport — every cell along the drag path gets the selected tile, with a ghost preview leading the cursor.
- Ctrl+S (or right-click the TileMap asset → Save) to persist the
changes. The asset gets a
*prefix in the asset browser when it has unsaved edits, and the editor's existing unsaved-changes shutdown popup catches it on close.
Architecture
TileSet asset
Engine/Source/Engine/Assets/TileSet.{h,cpp}
class TileSet : public Asset
{
AssetRef mTexture;
int32_t mTileWidth, mTileHeight;
int32_t mMarginX, mMarginY;
int32_t mSpacingX, mSpacingY;
int32_t mAtlasColumns, mAtlasRows; // derived
std::vector<TileDefinition> mTiles; // one per atlas slot
std::vector<NineBoxBrushDef> mNineBoxBrushes;
std::vector<AutotileSet> mAutotileSets;
};
struct TileDefinition
{
int32_t mTileIndex;
glm::ivec2 mAtlasCoord;
std::string mName;
std::vector<std::string> mTags;
bool mHasCollision;
TileCollisionType mCollisionType;
std::vector<glm::vec4> mCollisionRects; // tile-local pixels
std::vector<glm::vec2> mCollisionPoly;
bool mIsAnimated;
std::vector<int32_t> mAnimFrames;
float mAnimFps;
};
struct NineBoxBrushDef
{
std::string mName;
int32_t mTopLeft, mTop, mTopRight;
int32_t mLeft, mCenter, mRight;
int32_t mBottomLeft, mBottom, mBottomRight;
std::vector<std::string> mTags;
};
struct AutotileSet
{
std::string mName;
std::vector<std::string> mMemberTags; // a tile is "self" if it has any of these
std::vector<int32_t> mMemberTileIndices; // ...or its index appears in this list
std::vector<AutotileRule> mRules;
};
struct AutotileRule
{
AutotileNeighborState mNeighbors[8]; // see § Autotile slot order
std::vector<int32_t> mResultTiles; // single-pick for now
};
Key methods on TileSet:
| Method | Purpose |
|---|---|
RebuildTileGrid() |
Recompute mAtlasColumns/Rows from texture + slicing parameters and resize mTiles. Called automatically when slicing properties change in the inspector. |
GetTileUVs(tileIndex, &uv0, &uv1) |
Compute the [0,1] UV rectangle for a tile, accounting for margin and spacing. Used by the node's mesh builder, the panel's tile picker, and the panel's preview overlay. |
IsTileSolid(tileIndex) |
Returns mHasCollision && mCollisionType != None. |
HasTileTag(tileIndex, tag) |
Linear scan over the tile's mTags. |
IsTileMemberOfAutotile(brushIdx, tileIdx) |
Used by the autotile evaluator to decide whether a neighbor counts as "self". |
MatchAutotileRule(brushIdx, selfMask) |
Walks the rule list in order; first matching rule wins. |
The slicing properties have a custom HandlePropChange that writes the
new value into the member directly (because the Datum framework calls the
handler before its own write — see § Gotchas) and then calls
RebuildTileGrid() so the atlas grid updates the moment you change a value
in the inspector.
TileMap asset
Engine/Source/Engine/Assets/TileMap.{h,cpp}
static constexpr int32_t kTileChunkSize = 32;
struct TileCell
{
int32_t mTileIndex = -1; // -1 = empty
uint8_t mFlags = 0; // bit 0 flipX, 1 flipY, 2 rot90, 3 hidden
uint8_t mPadding = 0;
int16_t mVariant = 0;
};
struct TileChunk
{
TileCell mCells[kTileChunkSize * kTileChunkSize];
};
struct TileMapLayer
{
std::string mName;
bool mVisible, mLocked;
float mOpacity;
TileMapLayerType mType; // Visual / Collision / Decal / Gameplay
int32_t mZOrder;
std::unordered_map<int64_t, TileChunk> mChunks; // key = pack(chunkX, chunkY)
};
class TileMap : public Asset
{
AssetRef mTileSet;
glm::ivec2 mTileSize = { 1, 1 };
glm::vec2 mOrigin = { 0, 0 };
bool mAllowNegativeCoords = true;
std::vector<TileMapLayer> mLayers;
glm::ivec2 mMinUsed, mMaxUsed; // bounding box of all painted cells
bool mHasContent = false;
std::unordered_set<int64_t> mDirtyChunks;
};
Sparse chunked storage: each layer holds an unordered_map of chunk
key → TileChunk. Chunks are 32×32 cells (1024 TileCells, 8 KB each).
Chunks are allocated lazily — the very first cell painted in a chunk
allocates it, and the chunk lives until the asset is destroyed.
Negative coordinates: the chunk key is a packed int64_t of two
int32_ts, and FloorDivChunk / FloorModChunk use proper floor-division
math so negative cells route to the correct chunk:
static int32_t FloorDivChunk(int32_t cell)
{
if (cell >= 0) return cell / kTileChunkSize;
return -((kTileChunkSize - 1 - cell) / kTileChunkSize);
}
Used bounds: mMinUsed / mMaxUsed are grown on every SetCell and
recomputed from scratch on LoadStream. Used by GetUsedBounds,
the cell-grid overlay's "anchor on the map" path, and the closest-cell
queries.
Dirty chunks: every SetCell that actually changes a value adds the
chunk key to mDirtyChunks. The TileMap2D node drains this set every Tick
to know which chunks need a vertex rebuild. Important: in the current
Phase 1 implementation the node rebuilds the entire mesh on any dirty
chunk; per-chunk meshes are a Phase 4+ optimization.
Cell mutation API:
| Method | Notes |
|---|---|
GetCell(x, y, layer) |
Returns TileCell{} for empty cells or out-of-bounds layers. |
SetCell(x, y, cell, layer) |
The canonical write path. Allocates the chunk if needed. Skips no-op writes. Sets the asset dirty flag in editor builds. |
ClearCell(x, y, layer) |
Calls SetCell with an empty TileCell. |
GetTile(x, y, layer) |
Convenience: returns mTileIndex only. |
SetTile(x, y, idx, layer) |
Convenience: writes through SetCell. |
ReplaceTile(oldIdx, newIdx, layer) |
Bulk swap; returns count. |
ReplaceTilesWithTag(tag, newIdx, layer) |
Bulk swap by TileSet tag; returns count. |
CountTileUses(idx, layer) |
Linear scan over all chunks; returns count. |
MarkAllDirty() |
Adds every existing chunk key to the dirty set (used after SetTileSet). |
Save/Load is version-gated. Versions:
- ASSET_VERSION_TILESET_BASE (28) — initial TileSet
- ASSET_VERSION_TILEMAP_BASE (29) — initial TileMap + node
- ASSET_VERSION_TILESET_METADATA (30) — per-tile tags, collision rects,
animation frames
- ASSET_VERSION_TILESET_AUTOTILE (31) — autotile sets
The TileMap save format RLE-skips empty chunks: a chunk is only written if
at least one of its cells has mTileIndex >= 0.
TileMap2D node
Engine/Source/Engine/Nodes/3D/TileMap2d.{h,cpp}
class TileMap2D : public Mesh3D
{
AssetRef mTileMap;
std::vector<VertexColor> mVertices; // CPU-side mesh
std::vector<IndexType> mIndices;
uint32_t mNumVertices, mNumIndices;
bool mMeshDirty;
bool mUploadDirty[MAX_FRAMES];
Bounds mBounds;
TileMap2DResource mResource; // VkBuffer pair
MaterialRef mDefaultMaterial;
float mLayerZSpacing = 0.01f;
};
The node walks all visible layers in Z order, iterates each layer's chunks,
and builds one big VertexColor mesh for the entire map. Each painted cell
becomes 4 vertices + 6 indices (one quad). UVs come from
TileSet::GetTileUVs and respect the mFlags flip bits. Layer Z-stacking
multiplies the layer's mZOrder by mLayerZSpacing so layers separate
slightly along Z to avoid Z-fighting.
The default material is a MaterialLite::New(LoadAsset<MaterialLite>("M_DefaultUnlit"))
with VertexColorMode::Modulate. The atlas texture is bound to slot 0
every Tick via EnsureMaterialBinding so swapping the underlying TileSet's
texture propagates without requiring a manual rebuild.
Vertex color compensation: the engine multiplies vertex colors by
GlobalUniforms::mColorScale (default 2.0) in the forward shader. Engine
code that builds vertex meshes pre-divides white. The TileMap2D builder
reads GetEngineConfig()->mColorScale and writes 0xFFFFFFFFu,
0x7F7F7F7Fu, or 0x3F3F3F3Fu per the scale value.
Mesh upload critical detail: TickCommon drains the asset's dirty
chunks and calls MarkDirty() (NOT mMeshDirty = true). MarkDirty() sets
both mMeshDirty and every entry of mUploadDirty[]. Without the upload
dirty flag, the CPU rebuild produces correct vertex data but
UploadMeshData() skips the GPU upload, and the GPU keeps drawing the
previous mesh — this caused live pencil-drag-paint to be invisible until
mouse-up release. See § Gotchas.
World layout: cell (cx, cy) maps to world position
(origin.x + cx * tileSize.x, origin.y + cy * tileSize.y, layer.zOrder * layerZSpacing).
+X is right, +Y is up, layer Z stacks toward +Z. The auto-ortho editor
camera puts itself at the node's world position + (0, 0, 50) looking
straight down -Z.
WorldToCell(worldXY) and CellToWorld(cell) round-trip these
coordinates. CellCenterToWorld(cell) returns the center of the cell in
world space — used by the cursor highlight cube and the tile preview
overlay's screen-projection math.
TilePaintManager (editor)
Engine/Source/Editor/TilePaint/TilePaintManager.{h,cpp} (#if EDITOR)
The paint manager is the editor-only state machine that handles:
- Hover detection (raycast from camera onto the layer Z plane)
- Stroke lifecycle: click → drag → release
- Per-tool dispatch (
TileSculptModeenum) - Bresenham interpolation for free-stroke pencil/eraser drags
- Marquee + freeform selection
- Clipboard (copy/cut/paste/flip/rotate)
- Cursor cube + collision/tag/grid overlays
- Action commit through
ActionPaintTiles
It is constructed in EditorState::Init() and ticked from
Viewport3D::Update() only when GetPaintMode() == PaintMode::TilePaint
and controlMode == ControlMode::Default. It mirrors VoxelSculptManager
and TerrainSculptManager in structure.
enum class TileSculptMode : uint8_t
{
Pencil, Eraser, Picker,
RectFill, FloodFill, Line,
NineBox, Select, Autotile,
Count
};
struct TileSculptOptions
{
TileSculptMode mMode = TileSculptMode::Pencil;
int32_t mSelectedTileIndex = 0;
int32_t mActiveLayer = 0;
int32_t mActiveNineBoxIndex = -1;
int32_t mActiveAutotileIndex = -1;
bool mShowCollisionOverlay = false;
bool mShowTagOverlay = false;
bool mShowCellGrid = false;
std::string mTagOverlayName;
};
Stroke state (public so the panel can read it for live previews):
bool mStrokeActive;
bool mHoverValid;
glm::ivec2 mHoverCell;
bool mPreviewActive; // drag-based tools (Rect/Line/9Box/Select)
glm::ivec2 mDragStartCell;
bool mLastStrokeCellValid; // pencil/eraser interpolation anchor
glm::ivec2 mLastStrokeCell;
bool mHasSelection;
glm::ivec2 mSelectionMin, mSelectionMax; // bounding rect of mSelectedCells
std::set<int64_t> mSelectedCells; // freeform selection set
TileClipboard mClipboard;
mPendingChanges (vector of TilePaintChange { cellX, cellY, layer, oldCell, newCell })
accumulates per-stroke changes and flushes through ActionManager::EXE_PaintTiles
on CommitStroke(). mModifiedSet is a per-stroke dedup set so tools can
visit the same cell multiple times during a stroke without spamming the
change list.
Paint Tools
| Tool | Click | Drag | Release | Notes |
|---|---|---|---|---|
| Pencil | Paints click cell | Bresenham trail of selected tile | Commits stroke action | Live painting, gap-free |
| Eraser | Clears click cell | Bresenham trail of clears | Commits stroke action | Same path as Pencil with mTileIndex = -1 |
| Picker | Reads cell into mSelectedTileIndex |
— | — | Single-click; doesn't start a stroke |
| Rect Fill | Anchor start cell | Live rect ghost preview | Fills entire rect from start to release cell | Capped at 1024×1024 cells |
| Flood Fill | BFS over connected matching cells | — | — | Cap of 16384 cells, single click |
| Line | Anchor start cell | Live Bresenham ghost preview | Stamps Bresenham line | Cap of 16384 cells |
| 9-Box | Anchor start cell | Live rect ghost preview (corners) | Per-cell corner/edge/center fill from active brush | Empty brush slots leave existing cells alone |
| Select | Anchor start cell | Live rect outline | Adds cells to mSelectedCells |
Plain = replace, Shift = add, Ctrl = remove |
| Autotile | Paint click cell + recompute 8 neighbors | Drag-paints continuously | Commits stroke action | Uses active autotile rules |
Pencil drag interpolation
StrokePencilOrEraserInterp(prev, current) walks Bresenham between the
previous frame's cell and the current frame's cell, calling
ApplyPencilOrEraser for each cell on the path. This means a fast mouse
drag that crosses 7 cells in one frame still paints all 7 cells, instead of
leaving 6 holes. Per-stroke dedup via mModifiedSet ensures cells visited
multiple times aren't re-staged.
9-Box brush algorithm
Implemented in TilePaintManager::PickNineBoxSlot(cx, cy, minX, maxX, minY, maxY).
For a rectangular drag, each cell picks the slot based on its position
within the rect:
+---+---+---+---+---+
| TL| T | T | T | TR|
+---+---+---+---+---+
| L | C | C | C | R |
+---+---+---+---+---+
| BL| B | B | B | BR|
+---+---+---+---+---+
Special-cases per spec § "Special cases": - 1×1 → Center - 1×N column → Top / Center / Bottom - N×1 row → Left / Center / Right
For freeform selection 9-box fill (DoApply9BoxToSelection), each cell
in mSelectedCells looks at its 4 cardinal neighbors. If a neighbor is NOT
in the selection, that side of the cell is "exposed":
| Exposed sides | Slot |
|---|---|
| top + left | Top-Left corner |
| top + right | Top-Right corner |
| bottom + left | Bottom-Left corner |
| bottom + right | Bottom-Right corner |
| top only | Top edge |
| bottom only | Bottom edge |
| left only | Left edge |
| right only | Right edge |
| none | Center |
This works for L-shapes, paths, S-curves, isolated regions, donut shapes —
any cell set you can build with the freeform selection tools. Empty brush
slots (-1) leave the cell untouched, so partial brushes are allowed.
Autotile
A more general version of the 9-box brush that uses arbitrary 3×3
neighborhood predicates. Each AutotileRule has 8 neighbor states
(DontCare / MustBeSelf / MustNotBeSelf) and a list of result tiles.
Slot order (matches mNeighbors[8]):
[0]=NW [1]=N [2]=NE
[3]=W [4]=E
[5]=SW [6]=S [7]=SE
(+Y is up.)
Membership classification: a tile counts as "self" for an autotile set
if its index appears in the set's mMemberTileIndices OR it carries any of
the set's mMemberTags. So you can author by tagging tiles (grass,
wall, road) or by listing specific tile indices.
Paint behavior: clicking with the autotile tool calls
CommitAutotileAt(cell) which:
- Adds the click cell to a
pendingSelfset so it counts as self even though it isn't yet committed to the asset - Recomputes the click cell's tile from the rule that matches its 8-neighbor "self mask"
- Recomputes each of the 8 cardinal/diagonal neighbors that are already members of the same autotile group (i.e., painted with a tile from the group earlier) — neighbors of foreign tiles are left alone
- Stages every result through
StageCellChangeso the entire autotile operation is one undoable action
Drag-painting works: each frame the cursor lands on a new cell,
CommitAutotileAt runs again and the prior pending-self set is preserved
so the rules see continuous edges. The Bresenham interpolation isn't used
here — autotile is single-cell per frame.
Selection / Clipboard
Selection is stored in mSelectedCells as a std::set<int64_t> of packed
cell coordinates. The Select tool's drag-rectangle commit:
- Plain drag → clears
mSelectedCells, adds every cell in the rect - Shift+drag → adds every cell in the rect (additive, freeform build-up)
- Ctrl+drag → erases every cell in the rect (carve a hole)
After every commit, RecomputeSelectionBounds() derives the bounding rect
into mSelectionMin/Max so legacy code paths (the old corner outline,
copy/cut/paste) keep working.
The persistent selection outline draws every cell in the set as a small yellow cube each frame (capped at 2048 to keep the debug-draw count manageable on huge selections).
Clipboard (TileClipboard):
struct TileClipboard
{
int32_t mWidth, mHeight;
std::vector<TileCell> mCells; // row-major
};
- Copy snapshots the bounding rect of the selection into
mCells, including empty cells. - Cut copies + clears all cells in the bounding rect (one undoable action).
- Paste at cursor stamps the clipboard at the hover cell, skipping any empty source cells so non-rectangular shapes don't erase under-painted tiles.
- Flip X/Y mirror the clipboard in place AND toggle the corresponding
TileCell::mFlagsbit so the visual flip persists when pasted. - Rotate 90 rotates clockwise + swaps width/height + toggles bit 2 in every flag byte.
Note: Copy/cut/paste currently use the bounding rect of the freeform selection. Cells outside the freeform shape but inside the rect are included as empty cells in the clipboard, then skipped on paste. A future improvement would store the clipboard as a sparse map for true freeform support.
Find All Uses + Bulk Replace
The selected tile's metadata block in the panel has a Find All Uses
button that calls TileMap::CountTileUses(tileIdx, layer) and logs the
count via LogDebug. The Lua API also exposes tileMap:CountTileUses(idx),
tileMap:ReplaceTile(oldIdx, newIdx), and
tileMap:ReplaceTilesWithTag(tag, newIdx) for bulk gameplay-side
operations.
Editor Panel UI
Engine/Source/Editor/EditorImgui.cpp, inside the paintMode == TilePaint
branch of EditorImguiDraw. The panel is created with a regular floating
ImGui window — ImGui::Begin("Tile Paint", ...) — and contains:
- Tool radio buttons: Pencil / Eraser / Picker / Rect Fill / Flood Fill / Line / 9-Box / Select / Autotile
- Selected Tile preview thumbnail
- Tile Palette collapsing header — full atlas grid via
TilePicker::DrawInlineTileGrid. Click any tile to select it. - Layers collapsing header — per-layer Active/Visible/Locked/Name with Add Layer button
- 9-Box Brushes collapsing header — list/create AutotileSets with a 3×3 grid of slot tile pickers per brush
- Autotile Brushes collapsing header — name + member tags list + rules list with the 3×3 tri-state grid + result tile picker
- Selection & Clipboard collapsing header — Copy/Cut/Paste/Clear, Flip X/Y, Rotate 90, Fill Selection With 9-Box Brush, hint text for Shift+/Ctrl+ modifiers
- View collapsing header — Cell Grid, Collision Overlay, Tag Overlay toggles + tag-to-highlight input
- Hover info — Cell, Chunk, Tile-under-cursor live readout
- Tile Metadata collapsing header (selected tile) — Find All Uses, Name, Has Collision, collision type combo, tag list with add/remove
The panel also draws the tile preview overlay at the end of the block
using ImGui::GetForegroundDrawList()->AddImageQuad. For each tool that
"draws a tile", it computes the cell's 4 world corners, projects them to
screen via Camera3D::WorldToScreenPosition, looks up the selected tile's
UVs from the tileset, and renders an AddImageQuad of the atlas texture
at that position. RectFill/Line/Pencil drag previews iterate the
appropriate cell list (rect / Bresenham / interpolated path) and ghost each
cell.
Tile Picker helper
Engine/Source/Editor/TilePaint/TilePicker.{h,cpp} is a small reusable
namespace that maintains a cache of ImTextureIDs per call site (so
multiple panels can show the atlas without leaking ImGui descriptors) and
exposes:
namespace TilePicker
{
void* GetOrCreateImTextureID(const char* cacheKey, Texture* atlas);
bool DrawTileButton(const char* id, void* atlasImId, int32_t tileIdx,
int32_t tilesX, int32_t tilesY, float size);
void DrawTilePickerPopup(const char* popupId, void* atlasImId,
int32_t tilesX, int32_t tilesY,
int32_t& outTileIdx);
bool DrawInlineTileGrid(const char* id, void* atlasImId,
int32_t tilesX, int32_t tilesY,
int32_t& selectedTileIdx,
float tileDrawSize,
float maxWidth, float maxHeight);
}
DrawTileButton is reused by the 9-box brush slot grids and the selected
tile preview. DrawInlineTileGrid is the full-atlas palette grid.
DrawTilePickerPopup is the modal grid used when clicking a 9-box slot
button.
Auto-orthographic camera
Entering Tile Paint mode (via the editor mode combo or "Open In Tile Paint"
button) calls EditorState::SetPaintMode(PaintMode::TilePaint). The
implementation stashes the previous projection mode AND the previous camera
transform:
mTilePaintProjectionStashed = true;
mTilePaintPrevWasPerspective = (cam->GetProjectionMode() == ProjectionMode::PERSPECTIVE);
mTilePaintPrevCameraPosition = cam->GetWorldPosition();
mTilePaintPrevCameraRotation = cam->GetWorldRotationQuat();
mTilePaintTransformStashed = true;
if (mTilePaintPrevWasPerspective)
{
cam->SetProjectionMode(ProjectionMode::ORTHOGRAPHIC);
ApplyEditorCameraSettings();
}
cam->SetWorldPosition(anchor + glm::vec3(0, 0, 50));
cam->SetWorldRotation(glm::vec3(0, 0, 0)); // straight down -Z
anchor is the selected TileMap2D node's world position (or world origin
if nothing is selected). Exiting tile paint restores both the projection
and the transform.
Camera rotation lock: in Viewport3D::HandleDefaultControls, the
right-mouse → Pilot mode and middle-mouse → Orbit mode triggers are gated
on paintMode != TilePaint. Pan (Shift+Middle) and scroll-wheel zoom
remain available so you can still navigate the map.
Selection click protection: the editor's Shift+click and Ctrl+click
multi-select branch in HandleDefaultControls is now also gated on
paintMode == None so that the modifiers can be repurposed for additive /
subtractive freeform selection inside Tile Paint mode without interference.
Preferences
Preferences > Appearance > Viewport > Tilemap Grid
Lives at
Engine/Source/Editor/Preferences/Appearance/Viewport/TilemapGrid/TilemapGridModule.{h,cpp},
registered in PreferencesManager::Create() as a sub-module of
ViewportModule (which is itself a sub-module of AppearanceModule).
Settings:
- Minor Line Color (
glm::vec4, default(1, 1, 1, 0.35)) — color of normal cell-boundary grid lines - Highlight World Axes (
bool, defaulttrue) — when on, draws the X=0 / Y=0 lines in a separate color - Axis Color (
glm::vec4, default(1, 1, 0.4, 0.85)) — color of the highlight lines
TilePaintManager::DrawGridOverlay reads TilemapGridModule::Get() every
frame and falls back to the original hardcoded defaults if the module
hasn't been registered yet (preferences register asynchronously during
editor startup).
The grid is rendered through World::AddLine with a positive lifetime
(0.25f) so the dedup logic in AddLine keeps the lines alive while the
toggle is on, and they automatically expire ~250 ms after the toggle is
turned off. See § Gotchas for why lifetime > 0 matters.
Lua Runtime API
Bindings live in
Engine/Source/LuaBindings/TileMap2d_Lua.{h,cpp} and are registered in
LuaBindings.cpp after Terrain3D_Lua::Bind(). TileMap2D inherits from
Mesh3D in the Lua type hierarchy, so all Node3D / Mesh3D methods are
available too.
After adding or changing bindings, run:
python Tools/generate_lua_stubs.py
The stubs land in Engine/Generated/LuaMeta/TileMap2D.lua and provide
EmmyLua-style annotations for IDE autocomplete.
Coordinate helpers
cellX, cellY = tileMap:WorldToCell(worldVec)
worldVec = tileMap:CellToWorld(cellX, cellY)
worldVec = tileMap:CellCenterToWorld(cellX, cellY)
Cell queries / mutation
tileIdx = tileMap:GetTile(cellX, cellY [, layer])
tileMap:SetTile(cellX, cellY, tileIdx [, layer])
tileMap:ClearTile(cellX, cellY [, layer])
cellInfo = tileMap:GetCell(cellX, cellY [, layer])
-- Returns a table:
-- {
-- tileIndex = integer,
-- atlasX = integer,
-- atlasY = integer,
-- cellX = integer,
-- cellY = integer,
-- worldX = number,
-- worldY = number,
-- hasCollision = boolean,
-- flags = integer,
-- tags = { string, ... } -- only if the tile has tags
-- }
bool = tileMap:IsCellOccupied(cellX, cellY [, layer])
Tile-definition queries
tagsArray = tileMap:GetTileTags(tileIdx) -- string array
bool = tileMap:HasTileTag(tileIdx, "solid")
atlasX, atlasY = tileMap:GetTileAtlasCoord(tileIdx)
Region search
cells = tileMap:GetCellsInRect(x1, y1, x2, y2 [, layer])
cells = tileMap:FindAllTiles(targetTileIdx [, layer])
cells = tileMap:FindAllTilesWithTag("ground" [, layer])
minX, minY, maxX, maxY = tileMap:GetUsedBounds()
count = tileMap:CountTileUses(tileIdx [, layer])
Closest-cell queries
Results are sorted by distance from the world position. maxDist is
optional — pass 0 or omit for unbounded.
cellInfo = tileMap:GetClosestTile(worldVec [, layer]) -- nil if map empty
cells = tileMap:GetClosestTilesWithTag(tag, worldVec [, maxDist [, layer]])
cells = tileMap:GetClosestTilesOfType(tileIdx, worldVec [, maxDist [, layer]])
Neighborhood
cells = tileMap:GetNeighborCells(cellX, cellY [, includeDiagonals [, layer]])
-- 4 cardinal cells (or 8 with diagonals=true)
Collision
bool = tileMap:IsSolidAt(worldVec [, layer])
bool = tileMap:IsSolidCell(cellX, cellY [, layer])
info = tileMap:GetCollisionAt(worldVec [, layer])
-- nil if cell is empty or non-solid, otherwise:
-- {
-- cellX, cellY = integer,
-- tileIndex = integer,
-- hasCollision = true,
-- collisionType = integer,
-- rects = { {x, y, w, h}, ... } -- if rect-based
-- }
Bulk editing
tileMap:FillRect(x1, y1, x2, y2, tileIdx [, layer])
tileMap:ClearRect(x1, y1, x2, y2 [, layer])
n = tileMap:ReplaceTile(oldIdx, newIdx [, layer]) -- returns count
n = tileMap:ReplaceTilesWithTag(tag, newIdx [, layer]) -- returns count
Navigation
hit = tileMap:RaycastTiles(startWorld, endWorld [, layer])
-- Returns { hit, cellX, cellY, worldX, worldY, tileIndex }
-- Walks 2D DDA across cell space, stops at the first cell whose tile is solid.
cells = tileMap:GetReachableCells(startX, startY, maxDistance [, passTag [, layer]])
-- BFS bounded by Manhattan distance maxDistance.
-- If passTag is given, cells without a tile OR cells whose tile carries
-- passTag are traversable; everything else blocks.
-- Capped at 4096 cells emitted.
Example: click-to-paint test
Polyphase-Examples/Tilemap2DExample/Tilemap2DExample/Scripts/TilemapClickTest.lua
demonstrates the click → cell math + bulk operations:
function TilemapClickTest:Tick(deltaTime)
if not self.tileMap or not self.tileMap:IsValid() then return end
local mouseX, mouseY = Input.GetMousePosition()
local rayOrigin = self.camera:GetWorldPosition()
local rayTarget = self.camera:ScreenToWorldPosition(mouseX, mouseY)
local rayDir = rayTarget - rayOrigin
rayDir:Normalize()
-- Project onto the tilemap's z-plane:
local nodeZ = self.tileMap:GetWorldPosition().z
local t = (nodeZ - rayOrigin.z) / rayDir.z
if t < 0 then return end
local hitX = rayOrigin.x + rayDir.x * t
local hitY = rayOrigin.y + rayDir.y * t
local cellX, cellY = self.tileMap:WorldToCell(Vector.Create(hitX, hitY, 0))
if Input.IsMouseButtonJustDown(Mouse.Left) then
local cell = self.tileMap:GetCell(cellX, cellY, self.layer)
Log.Debug(string.format("tile=%d tags=%s",
cell.tileIndex, table.concat(cell.tags or {}, ",")))
end
end
Asset versioning
#define ASSET_VERSION_TILESET_BASE 28
#define ASSET_VERSION_TILEMAP_BASE 29
#define ASSET_VERSION_TILESET_METADATA 30
#define ASSET_VERSION_TILESET_AUTOTILE 31
#define ASSET_VERSION_CURRENT 31
When loading older TileSet/TileMap assets, the load path version-gates each new field block:
if (mVersion >= ASSET_VERSION_TILESET_METADATA)
{
// read tags, collision rects, animation frames
}
if (mVersion >= ASSET_VERSION_TILESET_AUTOTILE)
{
// read autotile sets
}
Old assets load cleanly into the newer struct layouts because the new fields default to empty/false in the struct definitions.
Build system integration
When you add files for new TileMap features, all three build systems
need updating. See .claude/skills/octave/SKILL.md § "Build System
Integration" for the canonical checklist; the abbreviated version:
Engine/Engine.vcxproj—<ClCompile>and<ClInclude>entriesEngine/Engine.vcxproj.filters— matching entries with<Filter>tags; add a new<Filter Include="…">block with a GUID if the directory is newEngine/Makefile_Linux— add the directory toSOURCESIF it's a new directory. The Makefile useswildcard $(dir)/*.cppso existing directories pick up new files automatically, but new directories are silently dropped on Linux unless added toSOURCES. Editor-only directories go in theSOURCES +=line inside theifeq POLYPHASE_EDITORblock.- CMake (
CMakeLists.txt) usesfile(GLOB_RECURSE SRC *.cpp *.c *.h)so no CMake update is needed.
The TileMap system created several new directories that needed all three files updated:
Engine/Source/Editor/TilePaint/Engine/Source/Editor/Preferences/Appearance/Viewport/TilemapGrid/
And several new files in existing directories (which only needed the vcxproj + filters):
Engine/Source/Engine/Assets/TileSet.{h,cpp}Engine/Source/Engine/Assets/TileMap.{h,cpp}Engine/Source/Engine/Nodes/3D/TileMap2d.{h,cpp}Engine/Source/LuaBindings/TileMap2d_Lua.{h,cpp}
FORCE_LINK_CALL(TileSet), FORCE_LINK_CALL(TileMap), and
FORCE_LINK_CALL(TileMap2D) were added to Engine.cpp::ForceLinkage().
Gotchas
A handful of subtle bugs surfaced while building this system. Each one is
reflected in .claude/skills/octave/SKILL.md § "Things to Watch Out For"
so future engine work doesn't repeat them, but here they are with TileMap-
specific context:
1. HandlePropChange is called BEFORE the value is written
Datum::SetInteger (and the other typed setters) calls the change handler
with the new value as a void*, then only writes the value into the
member if the handler returns false. Naïve handlers that read the changed
member see the old value.
The TileSet inspector's "Atlas Tile Width / Height / Margin / Spacing"
properties needed RebuildTileGrid() to recompute mAtlasColumns/Rows.
Reading mTileWidth from RebuildTileGrid always saw the previous value,
so the palette grid rendered the OLD slicing forever. Fix: in
TileSet::HandlePropChange, dispatch on prop->mName, write the new value
into the member directly from the void* newValue pointer, then return
true so the framework skips its own write. Same pattern as
Texture::HandlePropChange.
2. Vertex color × mColorScale brightness doubling
The forward shader multiplies vertex colors by GlobalUniforms::mColorScale
(default 2.0, a Wii/GameCube TEV compatibility hack — see
ForwardVertColor.glsl). Engine code that builds vertex meshes pre-divides
white so the shader's scale brings it back to 1.0. Voxel3D works because
its colors come from MaterialIdToColor which produces non-saturated
values; my TileMap2D builder originally wrote 0xFFFFFFFFu which the
shader doubled to 2× white. Fix: read GetEngineConfig()->mColorScale and
write 0x7F7F7F7Fu (scale=2) or 0x3F3F3F3Fu (scale=4) instead of
0xFFFFFFFFu.
3. mUploadDirty[] must be set on every rebuild
TileMap2D::TickCommon drains the asset's dirty chunks and rebuilds the
mesh. RebuildMeshInternal() only touches the CPU-side mVertices/
mIndices arrays — it does not set mUploadDirty[*].
UploadMeshData() then checks mUploadDirty[frameIndex] and skips the
GPU upload if it's false. The result: the CPU vertex array is correct, the
GPU keeps drawing the previous mesh, and live pencil-drag-paint is invisible
until something else (like the action commit on mouse-up) calls
MarkDirty() which sets both flags.
Fix: in the dirty-chunk drain, call MarkDirty() instead of
mMeshDirty = true. MarkDirty() sets BOTH mMeshDirty and every entry of
mUploadDirty[].
4. INP_GetMousePosition is unreliable in editor builds
ImGui's Win32 backend handler runs first in WndProc
(System_Windows.cpp:41). When ImGui has captured input — which happens
basically any time a button is held — it intercepts WM_MOUSEMOVE and the
engine's INP_SetMousePosition rarely fires. INP_GetMousePosition
returns near-stuck coordinates throughout a held drag.
Fix: editor-only code should read from ImGui::GetIO().MousePos instead.
That value is updated every frame by ImGui's backend regardless of input
capture state. Pattern:
ImVec2 mp = ImGui::GetIO().MousePos;
int32_t mouseX = int32_t(mp.x);
int32_t mouseY = int32_t(mp.y);
5. Camera3D::ScreenToWorldPosition works differently for ortho
For perspective cameras, rayDir = normalize(screenWorld - cameraPos)
gives a correct ray. For orthographic cameras, parallel rays don't
share an origin: every ray emanates from the screen-projected near-plane
point in the camera's forward direction. Treating ortho like perspective
gives a near-zero-parallax ray that hits the wrong cell (effectively pinned
near the camera). Branch on camera->GetProjectionMode():
glm::vec3 screenWorld = camera->ScreenToWorldPosition(mouseX, mouseY);
if (camera->GetProjectionMode() == ProjectionMode::ORTHOGRAPHIC)
{
rayOrigin = screenWorld;
rayDir = camera->GetForwardVector();
}
else
{
rayOrigin = camera->GetWorldPosition();
rayDir = glm::normalize(screenWorld - rayOrigin);
}
6. Viewport3D::ShouldHandleInput() kills strokes mid-drag
ShouldHandleInput returns false if any non-dock floating ImGui window is
hovered, even if the click started on the viewport. The "Tile Paint" panel
is a floating window, so the moment the cursor drifts toward it during a
drag, ShouldHandleInput returns false and any code that early-returns on
that check stops running mid-stroke.
Fix: gate the early-return on !mStrokeActive. New strokes still need
ShouldHandleInput == true to start, but in-progress strokes keep running:
if (!mStrokeActive && !GetEditorState()->GetViewport3D()->ShouldHandleInput())
return;
7. World::AddLine lifetime semantics
World::UpdateLines decrements mLifetime every tick and erases lines at
<= 0. A line added with mLifetime = 0.0f is erased on the very next
tick before the renderer ever sees it. For per-frame "always visible while
toggled on" debug lines, use a small positive lifetime (0.05f–0.25f)
and rely on World::AddLine's dedup logic to bump the lifetime each frame
(it picks max(existing, new)).
The cell grid overlay uses 0.25f so the lines stay visible for ~250 ms
after the toggle is turned off, then expire cleanly.
8. SM_Cube is a 2-unit cube
Engine/Assets/Meshes/SM_Cube.dae has vertices at ±1, not ±0.5. Scaling by
(tileSize.x, tileSize.y, ...) produces a quad twice as big as one cell.
Halve the scale: (tileSize.x * 0.5, tileSize.y * 0.5, ...). Affected the
cursor cube, the drag-preview corners, the selection outline, and the
collision/tag overlay quads.
9. Asset::SetDirtyFlag() is what triggers the unsaved-changes popup
The editor's existing unsaved-changes shutdown popup keys off
Asset::GetDirtyFlag(). If you mutate an asset programmatically (e.g.
from a paint stroke) and forget to call SetDirtyFlag(), the editor closes
cleanly without warning the user and the changes vanish. Wrap calls in
#if EDITOR since the symbol only exists in editor builds.
TileMap::SetCell (and ReplaceTile / ReplaceTilesWithTag /
SetTileSet) set the dirty flag automatically. The Layer panel's
visible/locked/name edits and the Add Layer button also call
tileMap->SetDirtyFlag() directly.
File map
| File | Purpose |
|---|---|
Engine/Source/Engine/Assets/TileSet.{h,cpp} |
Atlas slicing, tile metadata, 9-box brushes, autotile sets |
Engine/Source/Engine/Assets/TileMap.{h,cpp} |
Sparse chunked layered cell storage |
Engine/Source/Engine/Nodes/3D/TileMap2d.{h,cpp} |
Scene node + render path |
Engine/Source/Graphics/GraphicsTypes.h |
TileMap2DResource struct |
Engine/Source/Graphics/Graphics.h |
GFX_*TileMap2D* declarations |
Engine/Source/Graphics/Vulkan/VulkanUtils.{h,cpp} |
Vulkan implementation |
Engine/Source/Graphics/Vulkan/Graphics_Vulkan.cpp |
GFX_* wrappers |
Engine/Source/Graphics/GX/Graphics_GX.cpp |
GX no-op stubs |
Engine/Source/Graphics/C3D/Graphics_C3D.cpp |
C3D no-op stubs |
Engine/Source/Editor/TilePaint/TilePaintManager.{h,cpp} |
Editor paint state machine |
Engine/Source/Editor/TilePaint/TilePicker.{h,cpp} |
Reusable atlas picker UI |
Engine/Source/Editor/EditorImgui.cpp |
Tile Paint panel UI + tile preview overlay |
Engine/Source/Editor/EditorState.{h,cpp} |
PaintMode::TilePaint, mTilePaintManager, camera stash |
Engine/Source/Editor/Viewport3d.cpp |
Camera rotation lock, Update dispatch, modifier gate |
Engine/Source/Editor/ActionManager.{h,cpp} |
ActionPaintTiles |
Engine/Source/Editor/Preferences/Appearance/Viewport/TilemapGrid/TilemapGridModule.{h,cpp} |
Grid color preferences |
Engine/Source/Editor/Preferences/PreferencesManager.cpp |
Module registration |
Engine/Source/LuaBindings/TileMap2d_Lua.{h,cpp} |
Lua runtime API |
Engine/Source/LuaBindings/LuaBindings.cpp |
Bind() registration |
Engine/Source/Engine/Engine.cpp |
FORCE_LINK_CALL registrations |
Engine/Source/Engine/Asset.h |
Version constants |
Engine/Engine.vcxproj + .filters |
Windows build registration |
Engine/Makefile_Linux |
Linux build registration (TilePaint + TilemapGrid directories) |
Engine/Generated/LuaMeta/TileMap2D.lua |
Auto-generated Lua autocomplete stubs |
Phased development history
The TileMap system was implemented in four phases following the spec at
.dev/Polyphase_TileMap_Authoring_System_Spec.md:
- Phase 1 — Foundation: TileSet/TileMap/TileMap2D, sparse chunks, atlas slicing, single-layer painting, pencil/eraser/picker, save/load, Vulkan render path, basic editor panel
- Phase 2 — Proper authoring: tile palette grid, multi-layer panel, rect/flood/line tools, per-tile tags + collision storage, core Lua query API
- Phase 3 — Advanced productivity: 9-box brushes, marquee selection with copy/cut/paste/flip/rotate, collision + tag debug overlays, Find All Uses + bulk Replace helpers, full region/closest/neighbor Lua API
- Phase 4 — Smart systems: autotile rule sets with 3×3 tri-state authoring UI, RaycastTiles + GetReachableCells nav helpers, freeform selection (Shift/Ctrl modifiers), 9-Box Fill on freeform selections, cell grid overlay with preferences entry, auto-orthographic camera, camera rotation lock
A handful of follow-up bug fixes hardened the system: vertex color scale
compensation, the mUploadDirty[] upload-flag bug, the INP_GetMousePosition
vs ImGui::GetIO().MousePos switch, the ortho ScreenToWorldPosition
branch, the ShouldHandleInput mid-stroke kill, the World::AddLine
lifetime, the SM_Cube 2-unit-cube scale, the Asset::SetDirtyFlag save
wiring, and the Datum-handler-before-write timing.