Autotiling In-Depth (Engine Internals)
Audience: Engine maintainers implementing or modifying the autotiling algorithm.
This document describes the internal implementation of Terrarum's autotiling system, including the connection algorithms, lookup tables, and subtiling patterns.
Overview
Terrarum's autotiling system automatically selects appropriate tile sprites based on neighbouring tiles, creating seamless connections for blocks like terrain, ores, and platforms.
The system supports:
- 47-tile autotiling — Full tileset with all connection variants
- 16-tile platforms — Simplified platform connections
- Subtile autotiling — 8×8 pixel subdivisions for advanced patterns
- Connection modes — Self-connection and mutual connection
- Brick patterns — Alternative tiling modes for brick-like textures
Connection Detection
8-Neighbour System
Autotiling examines the 8 surrounding tiles (Moore neighbourhood):
┌───┬───┬───┐
│ 5 │ 6 │ 7 │
├───┼───┼───┤
│ 4 │ @ │ 0 │ @ = Current tile
├───┼───┼───┤
│ 3 │ 2 │ 1 │
└───┴───┴───┘
Each neighbour position maps to a bit in an 8-bit bitmask:
// Bit positions
val RIGHT = 0 // bit 0 (LSB)
val BOTTOM_RIGHT = 1
val BOTTOM = 2
val BOTTOM_LEFT = 3
val LEFT = 4
val TOP_LEFT = 5
val TOP = 6
val TOP_RIGHT = 7 // bit 7 (MSB)
Bitmask Generation
// Get neighbour positions (8 surrounding tiles)
private fun getNearbyTilesPos(x: Int, y: Int): Array<Point2i> {
return arrayOf(
Point2i(x + 1, y), // 0: RIGHT
Point2i(x + 1, y + 1), // 1: BOTTOM_RIGHT
Point2i(x, y + 1), // 2: BOTTOM
Point2i(x - 1, y + 1), // 3: BOTTOM_LEFT
Point2i(x - 1, y), // 4: LEFT
Point2i(x - 1, y - 1), // 5: TOP_LEFT
Point2i(x, y - 1), // 6: TOP
Point2i(x + 1, y - 1) // 7: TOP_RIGHT
)
}
// Calculate bitmask for CONNECT_SELF tiles
private fun getNearbyTilesInfoConSelf(x: Int, y: Int, mode: Int, mark: ItemID?): Int {
val nearbyTiles = getNearbyTilesPos(x, y).map { world.getTileFrom(mode, it.x, it.y) }
var ret = 0
for (i in nearbyTiles.indices) {
if (nearbyTiles[i] == mark) {
ret += (1 shl i) // add 1, 2, 4, 8, etc. for i = 0, 1, 2, 3...
}
}
return ret
}
Connection Rules
CONNECT_SELF
Tiles connect only to identical tiles. The actual implementation from BlocksDrawer.kt:
private fun getNearbyTilesInfoConSelf(x: Int, y: Int, mode: Int, mark: ItemID?): Int {
val nearbyTiles = getNearbyTilesPos(x, y).map { world.getTileFrom(mode, it.x, it.y) }
var ret = 0
for (i in nearbyTiles.indices) {
if (nearbyTiles[i] == mark) { // Only connect to identical tiles
ret += (1 shl i)
}
}
return ret
}
Use cases:
- Individual block types that don't blend with others
- Special decorative blocks
- Unique terrain features
CONNECT_MUTUAL
Tiles connect to any solid tile with the same connection tag. The actual implementation from BlocksDrawer.kt:
private fun getNearbyTilesInfoConMutual(x: Int, y: Int, mode: Int): Int {
val mode = if (mode == TERRAIN_WALLSTICKER) TERRAIN else mode
val nearbyTiles: List<ItemID> = getNearbyTilesPos(x, y).map { world.getTileFrom(mode, it.x, it.y) }
var ret = 0
for (i in nearbyTiles.indices) {
// Connect to solid tiles that are also marked CONNECT_MUTUAL
if (BlockCodex[nearbyTiles[i]].isSolidForTileCnx && isConnectMutual(nearbyTiles[i])) {
ret += (1 shl i)
}
}
return ret
}
Use cases:
- Dirt, stone, and similar terrain blocks
- All blocks tagged as "connect mutually" blend together
- Creates natural-looking transitions
47-Tile Autotiling
Tile Positions
The 112×112 texture atlas stores 47 tile variants in a 7×7 grid:
Grid layout (each cell is 16×16 pixels):
0 1 2 3 4 5 6
7 8 9 10 11 12 13
14 15 16 17 18 19 20
21 22 23 24 25 26 27
28 29 30 31 32 33 34
35 36 37 38 39 40 BB
41 42 43 44 45 46 **
Position (6,5) is reserved for the barcode. Positions 47 is unused.
Connection Lookup Table
The connectLut47 maps 8-bit connection bitmasks to tile indices:
val connectLut47 = intArrayOf(
0, 1, 2, 3, 4, 5, 6, 7, // 0x00-0x07
8, 9, 10, 11, 12, 13, 14, 15, // 0x08-0x0F
// ... 256 entries total
)
Example:
val bitmask = 0b11010110 // Connected top, top-right, left, bottom-right, bottom
val tileIndex = connectLut47[bitmask] // Returns appropriate tile variant
Pre-calculated LUT
The lookup table is pre-calculated and hard-coded in BlocksDrawer.kt. The actual 256-entry array (from BlocksDrawer.kt:188):
val connectLut47 = intArrayOf(
17,1,17,1,2,3,2,14,17,1,17,1,2,3,2,14,9,7,9,7,4,5,4,35,9,7,9,7,16,37,16,15,
17,1,17,1,2,3,2,14,17,1,17,1,2,3,2,14,9,7,9,7,4,5,4,35,9,7,9,7,16,37,16,15,
8,10,8,10,0,12,0,43,8,10,8,10,0,12,0,43,11,13,11,13,6,20,6,34,11,13,11,13,36,33,36,46,
8,10,8,10,0,12,0,43,8,10,8,10,0,12,0,43,30,42,30,42,38,26,38,18,30,42,30,42,23,45,23,31,
17,1,17,1,2,3,2,14,17,1,17,1,2,3,2,14,9,7,9,7,4,5,4,35,9,7,9,7,16,37,16,15,
17,1,17,1,2,3,2,14,17,1,17,1,2,3,2,14,9,7,9,7,4,5,4,35,9,7,9,7,16,37,16,15,
8,28,8,28,0,41,0,21,8,28,8,28,0,41,0,21,11,44,11,44,6,27,6,40,11,44,11,44,36,19,36,32,
8,28,8,28,0,41,0,21,8,28,8,28,0,41,0,21,30,29,30,29,38,39,38,25,30,29,30,29,23,24,23,22
)
Each index (0-255) represents a possible 8-bit connection bitmask, and the value at that index is the tile number (0-46) to use from the 47-tile atlas. The exact mapping was designed manually following the visual design in work_files/dynamic_shape_2_0.psd.
16-Tile Platform Autotiling
Platform Connections
Platforms only connect horizontally:
Tile layout (128×16 = 8 tiles × 16 pixels):
0: Middle segment
1: Right end
2: Left end
3: Planted on left (middle)
4: Planted on left (end)
5: Planted on right (middle)
6: Planted on right (end)
7: Single piece (isolated)
Platform Connection Algorithm
fun getPlatformTileIndex(x: Int, y: Int, blockID: ItemID): Int {
val hasLeft = isConnectedHorizontally(x - 1, y, blockID)
val hasRight = isConnectedHorizontally(x + 1, y, blockID)
val hasBlockBelow = isSolidBlock(x, y + 1)
val hasBlockBelowLeft = isSolidBlock(x - 1, y + 1)
val hasBlockBelowRight = isSolidBlock(x + 1, y + 1)
return when {
!hasLeft && !hasRight -> 7 // Single piece
hasLeft && !hasRight -> 1 // Right end
!hasLeft && hasRight -> 2 // Left end
hasBlockBelow -> when {
hasLeft && hasRight -> 0 // Middle
// ... planted variants
}
else -> 0 // Middle segment
}
}
Platform LUT
val connectLut16 = intArrayOf(
7, 2, 1, 0, // Isolated, left-connected, right-connected, both
// ... 256 entries for all combinations
)
Subtiling System
Subtile Resolution
Subtiles are 8×8 pixels (half of standard 16×16 tiles):
Standard tile subdivided:
┌────┬────┐
│ TL │ TR │ 8×8 each
├────┼────┤
│ BL │ BR │
└────┴────┘
Subtile indices: 0 = Top-Left, 1 = Top-Right, 2 = Bottom-Right, 3 = Bottom-Left
Subtile Variant LUTs
Four lookup tables determine subtile variants based on connection patterns:
val subtileVarBaseLuts = arrayOf(
// TL subtile variants (47 entries)
intArrayOf(10,2,2,2,1,1,3,1,10,1,10,3,10,3,2,1,1,2,0,3,3,10,0,0,0,0,0,3,10,0,0,0,3,3,3,1,3,1,0,0,3,10,0,10,3,0,3),
// TR subtile variants (47 entries)
intArrayOf(4,1,5,1,5,1,4,1,4,5,6,4,6,6,1,1,5,5,6,0,6,0,0,4,0,0,6,0,0,0,4,6,0,6,6,1,4,1,4,0,0,0,6,6,0,6,6),
// BR subtile variants (47 entries)
intArrayOf(4,7,4,9,4,9,4,7,8,8,7,8,9,7,0,0,4,8,0,9,9,0,0,4,9,0,9,9,7,7,8,0,0,9,0,0,4,9,4,9,0,9,7,0,7,9,0),
// BL subtile variants (47 entries)
intArrayOf(10,11,10,10,12,12,12,7,11,7,11,7,10,7,10,0,0,11,12,0,12,10,0,0,0,12,12,12,11,7,7,0,0,0,12,12,0,0,12,12,12,10,7,10,7,0,0),
)
Each array maps from the 47-tile connection index to a subtile variant (0-12).
Subtile Reorientation
Subtiles can be flipped/rotated to create more variations:
val subtileReorientLUT = arrayOf(
// For each subtile position (TL, TR, BR, BL):
// Map variant 0-15 to (rotation, flip) commands
)
Subtile Rendering
fun renderSubtiledTile(x: Int, y: Int, blockID: ItemID) {
val connectionIndex = get47TileIndex(x, y, blockID)
for (subtileIndex in 0 until 4) {
val variantIndex = subtileVarBaseLuts[subtileIndex][connectionIndex]
val (rotation, flip) = subtileReorientLUT[subtileIndex][variantIndex]
val subtileTexture = getSubtileTexture(variantIndex)
val (sx, sy) = subtileOffsets[subtileIndex]
renderSubtile(
subtileTexture,
x * TILE_SIZE + sx,
y * TILE_SIZE + sy,
rotation, flip
)
}
}
This composites four 8×8 subtiles into each 16×16 tile position.
Brick Patterns
Tiling Modes
const val TILING_FULL = 0 // Standard 47-tile autotiling
const val TILING_FULL_NOFLIP = 1 // No flipping (for asymmetric textures)
const val TILING_BRICK_SMALL = 2 // Small brick pattern
const val TILING_BRICK_SMALL_NOFLIP = 3
const val TILING_BRICK_LARGE = 4 // Large brick pattern
const val TILING_BRICK_LARGE_NOFLIP = 5
Brick Reorientation
Brick patterns use different subtile remapping:
val tilingModeReorientLUTs = arrayOf(
// TILING_FULL
arrayOf(16 to 0, 16 to 0, 16 to 0, 16 to 0),
// TILING_BRICK_SMALL
arrayOf(8 to 0, 8 to 8, 8 to 8, 8 to 0), // (modulo, offset) per subtile
// TILING_BRICK_LARGE
arrayOf(8 to 8, 8 to 0, 8 to 8, 8 to 0),
)
Brick Formula
fun getBrickSubtileVariant(baseVariant: Int, subtilePos: Int, mode: Int): Int {
val (modulo, offset) = tilingModeReorientLUTs[mode][subtilePos]
return (baseVariant % modulo) + offset
}
This creates brick-like patterns by cycling through specific subtile ranges.
Wall Stickers
4-Tile Format
Wall stickers (torches, paintings) use a simplified 64×16 atlas:
0: Free-floating
1: Planted on left wall
2: Planted on right wall
3: Planted on bottom (ceiling)
Wall Sticker Algorithm
fun getWallStickerTile(x: Int, y: Int): Int {
val hasLeft = isSolidWall(x - 1, y)
val hasRight = isSolidWall(x + 1, y)
val hasBottom = isSolidWall(x, y + 1)
return when {
hasLeft -> 1
hasRight -> 2
hasBottom -> 3
else -> 0 // Free-floating
}
}
Occlusion and Corner Darkening
NOTE: Corner occlusion now uses shader-based approach. Following section is obsolete and preserved for historical reasons.
Occlusion Tiles
Corner occlusion uses a separate tile set stored at a reserved atlas position:
val OCCLUSION_TILE_NUM_BASE = 48 // Starting position in atlas
Occlusion Bitmask
Similar to regular autotiling, but affects lighting:
fun calculateOcclusionMask(x: Int, y: Int): Int {
var mask = 0
for (i in 0 until 8) {
val (nx, ny) = getNeighbourPosition(x, y, i)
if (isOccluding(nx, ny)) {
mask = mask or (1 shl i)
}
}
return connectLut47[mask] // Reuse autotiling LUT
}
Occlusion Rendering
Rendered as a dark overlay using multiplicative blending:
batch.setBlendFunction(GL_DST_COLOR, GL_ZERO) // Multiplicative
batch.setColor(0.5f, 0.5f, 0.5f, 1f) // Darken by 50%
batch.draw(occlusionTexture, x, y)
batch.setBlendFunction(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA) // Reset
Performance Optimisations
Pre-Calculated Tile Lists
The engine pre-calculates lists of tile numbers by connection type:
lateinit var connectMutualTiles: Array<Int>
lateinit var connectSelfTiles: Array<Int>
lateinit var platformTiles: Array<Int>
lateinit var wallStickerTiles: Array<Int>
Binary search determines tile type in O(log n):
fun isConnectMutual(tileNum: Int): Boolean {
return connectMutualTiles.binarySearch(tileNum) >= 0
}
Caching
Connection bitmasks are cached per-tile:
private val connectionCache = HashMap<Long, Int>() // BlockAddress -> Bitmask
fun getCachedConnection(x: Int, y: Int): Int? {
val address = (y.toLong() shl 32) or x.toLong()
return connectionCache[address]
}
Cache invalidates when blocks change.
Shader-Based Autotiling
Future enhancement: Move autotiling to GPU shaders:
// Vertex shader passes world coordinates
v_worldPos = vec2(x, y);
// Fragment shader samples neighbours and selects tile
int connections = sampleNeighbours(v_worldPos);
int tileIndex = connectLut[connections];
vec2 atlasUV = getTileUV(tileIndex);
fragColor = texture(u_atlas, atlasUV);
Benefits:
- Offload CPU work to GPU
- Dynamic retiling without rebuilding geometry
- Per-pixel precision
Debugging
Visualise Connection Bitmask
fun debugRenderConnections(x: Int, y: Int) {
val mask = calculateConnectionBitmask(x, y, blockID)
for (i in 0 until 8) {
if ((mask and (1 shl i)) != 0) {
val (nx, ny) = getNeighbourPosition(x, y, i)
shapeRenderer.line(
x * TILE_SIZE + 8, y * TILE_SIZE + 8,
nx * TILE_SIZE + 8, ny * TILE_SIZE + 8
)
}
}
}
Verify LUT Completeness
All 256 bitmask values must have mappings:
fun verifyLUT(lut: IntArray) {
require(lut.size == 256) { "LUT must have 256 entries" }
for (i in lut.indices) {
require(lut[i] in 0 until 47) { "Invalid tile index ${lut[i]} at position $i" }
}
}
Common Pitfalls
- Incorrect bit ordering — Ensure neighbour positions match bit positions
- Self vs. Mutual confusion — Check connection type before comparing
- Cache invalidation — Update cache when blocks change
- Off-by-one in LUT — 256 entries (0-255), not 255
- Subtile index ordering — Remember: TL=0, TR=1, BR=2, BL=3
- Forgetting corners — Diagonal neighbours affect corner tiles
- Platform direction — Platforms only connect horizontally
Future Enhancements
- 3D autotiling — Connect blocks in vertical layers
- Gradient transitions — Smooth colour blending between terrain types
- Animated autotiles — Time-varying tile selection
- Smart slopes — Automatic slope generation at edges
- Isometric support — Adapt algorithm for isometric views
- Custom connection rules — Per-block connection predicates
See Also
- Tile Atlas System — Atlas generation and seasonal blending
- Rendering Pipeline — How autotiled tiles are rendered
- Modules:Blocks — Creating autotiled block textures