1
Autotiling In Depth
minjaesong edited this page 2025-11-24 21:24:45 +09:00
This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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

  1. Incorrect bit ordering — Ensure neighbour positions match bit positions
  2. Self vs. Mutual confusion — Check connection type before comparing
  3. Cache invalidation — Update cache when blocks change
  4. Off-by-one in LUT — 256 entries (0-255), not 255
  5. Subtile index ordering — Remember: TL=0, TR=1, BR=2, BL=3
  6. Forgetting corners — Diagonal neighbours affect corner tiles
  7. Platform direction — Platforms only connect horizontally

Future Enhancements

  1. 3D autotiling — Connect blocks in vertical layers
  2. Gradient transitions — Smooth colour blending between terrain types
  3. Animated autotiles — Time-varying tile selection
  4. Smart slopes — Automatic slope generation at edges
  5. Isometric support — Adapt algorithm for isometric views
  6. Custom connection rules — Per-block connection predicates

See Also