1
Tile Atlas System
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.

Tile Atlas System (Engine Internals)

Audience: Engine maintainers and advanced developers working on core rendering systems.

This document describes the internal implementation of Terrarum's tile atlas system, including six-season blending, subtiling, and dynamic atlas generation.

Overview

The tile atlas system (CreateTileAtlas) dynamically builds texture atlases from block textures across all loaded modules, supporting:

  • Six seasonal variations with temporal blending
  • Subtiling system for 8×8 pixel subdivisions
  • Multiple atlas formats (16×16, 64×16, 128×16, 112×112, 168×136, 224×224)
  • Glow and emissive layers for self-illumination
  • Dynamic retexturing via module overrides
  • Item image generation from world tiles

Six-Season System

Season Atlases

The engine maintains six separate texture atlases for seasonal variation:

lateinit var atlasPrevernal: Pixmap  // Pre-spring (late winter transitioning)
lateinit var atlasVernal: Pixmap     // Spring
lateinit var atlasAestival: Pixmap   // Summer
lateinit var atlasSerotinal: Pixmap  // Late summer
lateinit var atlasAutumnal: Pixmap   // Autumn
lateinit var atlasHibernal: Pixmap   // Winter

Season Names

Atlas Season Calendar Period
Prevernal Pre-spring Late Month 12 to Early Month 1
Vernal Spring Months 1-2
Aestival Summer Months 3-5
Serotinal Late Summer Months 6-7
Autumnal Autumn Months 8-10
Hibernal Winter Months 11-12

Seasonal Blending

The rendering shader can blend between two seasonal atlases using the tilesBlend uniform:

// In tiling.frag shader
uniform sampler2D tilesAtlas;         // Primary season atlas
uniform sampler2D tilesBlendAtlas;    // Secondary season atlas for blending
uniform float tilesBlend = 0.0;       // Blend factor [0..1]

vec4 tileCol = texture(tilesAtlas, finalUVCoordForTile);
vec4 tileAltCol = texture(tilesBlendAtlas, finalUVCoordForTile);
vec4 finalColor = mix(tileCol, tileAltCol, tilesBlend);

Game code selects which two seasonal atlases to use and sets the blend factor. By default, atlasVernal (spring) is used.

Seasonal Texture Formats

Standard Blocks (112×112)

Single-season blocks populate all six atlases with identical textures.

Seasonal Blocks (224×224 or 336×224)

Four-season format (224×224) provides four seasons in a quad layout:

┌───────────┬───────────┐
│ Aestival  │ Autumnal  │  112×112 each
│ (Summer)  │ (Autumn)  │
├───────────┼───────────┤
│ Vernal    │ Hibernal  │
│ (Spring)  │ (Winter)  │
└───────────┴───────────┘

For blocks using this format, the same texture is reused for Prevernal and Serotinal seasons

Six-Season Layout

For textures with full six-season support (336×224 for standard blocks, or 3× width for subtiled blocks):

Horizontal layout (336 × 224):
┌─────────┬─────────┬─────────┐
│Prevernal│ Vernal  │Aestival │  112×112 each (top row)
├─────────┼─────────┼─────────┤
│Hibernal │Autumnal │Serotinal│  112×112 each (bottom row)
└─────────┴─────────┴─────────┘

The atlas creation code extracts each 112×112 region and places it into the corresponding seasonal atlas. No interpolation occurs—all six seasons are explicitly provided in the source texture.

Subtiling System

Subtile Size

Subtiles are subdivisions of standard tiles:

const val TILE_SIZE = 16      // Standard tile
const val SUBTILE_SIZE = 8    // Half-tile subdivision

Subtile Grid

Each tile divides into a 2×2 subtile grid:

┌────┬────┐
│ 0  │ 1  │  8×8 each
├────┼────┤
│ 3  │ 2  │
└────┴────┘

Subtile indices: 0 (top-left), 1 (top-right), 2 (bottom-right), 3 (bottom-left).

Subtile Atlases

Two special atlas formats use subtiling:

Generic Subtile Atlas (104×136)

val W_SUBTILE_GENERIC = 104  // Width in subtiles
val H_SUBTILE = 136          // Height in subtiles

Total: 13 tiles wide × 17 tiles tall = 221 tile positions.

Grass Subtile Atlas (168×136)

val W_SUBTILE_GRASS = 168    // Width in subtiles
val H_SUBTILE = 136          // Height in subtiles

Total: 21 tiles wide × 17 tiles tall = 357 tile positions.

Subtile Layout

Subtile atlases store tile variants at subtile resolution for advanced tiling patterns.

Example grass subtiling:

  • Positions (0,0) to (3,0): Top-left subtiles for different connection patterns
  • Positions (0,1) to (3,1): Top-right subtiles
  • And so on...

Subtile Offset Vectors

val subtileOffsetVectors = arrayOf(
    Point2i(0, 0),              // Top-left
    Point2i(SUBTILE_SIZE, 0),   // Top-right
    Point2i(SUBTILE_SIZE, SUBTILE_SIZE),  // Bottom-right
    Point2i(0, SUBTILE_SIZE)    // Bottom-left
)

Used for extracting and compositing subtiles from atlases.

Tiling Modes

const val TILING_FULL = 0                // Standard autotiling with flip/rotation
const val TILING_FULL_NOFLIP = 1         // Standard autotiling without flip/rotation
const val TILING_BRICK_SMALL = 2         // Small brick pattern (4 rows per tile)
const val TILING_BRICK_SMALL_NOFLIP = 3  // Small brick without flip/rotation
const val TILING_BRICK_LARGE = 4         // Large brick pattern (2 rows per tile)
const val TILING_BRICK_LARGE_NOFLIP = 5  // Large brick without flip/rotation

Each mode defines different subtile selection patterns and whether flip/rotation variants are applied.

Item Image Generation from Subtiles

When creating item icons from subtiled blocks:

val tileOffsetsForItemImageFromSubtile = arrayOf(
    intArrayOf(4*2, 4*5, 4*8, 4*11),     // TILING_FULL
    intArrayOf(4*2, 4*5+2, 4*8+2, 4*11), // TILING_BRICK_SMALL
    intArrayOf(4*2+2, 4*5, 4*8+2, 4*11)  // TILING_BRICK_LARGE
)

These offsets select representative subtiles to composite into a 16×16 item icon.

Atlas Generation Process

Initialisation

operator fun invoke(updateExisting: Boolean = false) {
    // 1. Create six season atlases
    atlasPrevernal = Pixmap(TILES_IN_X * TILE_SIZE, TILES_IN_X * TILE_SIZE, RGBA8888)
    // ... create all six + glow + emissive

    // 2. Load init.tga (predefined tiles: air, breakage stages, etc.)
    drawInitPixmap()

    // 3. Scan all modules for block textures
    val tgaList = collectBlockTextures()

    // 4. Process each texture into atlases
    tgaList.forEach { (modname, file) ->
        fileToAtlantes(modname, file, glowFile, emissiveFile)
    }

    // 5. Generate item images
    generateItemImages()

    // 6. Create terrain colour map
    generateTerrainColourMap()
}

Texture Collection

The engine scans three directories:

  • blocks/ — Standard terrain blocks
  • ores/ — Ore overlays
  • fluid/ — Fluid textures

Only textures with corresponding codex entries are loaded:

dir.list()
    .filter { it.extension() == "tga" &&
             BlockCodex["$modname:${it.nameWithoutExtension()}"] != null }
    .forEach { fileToAtlantes(modname, it) }

File to Atlas Processing

private fun fileToAtlantes(
    modname: String,
    diffuse: FileHandle,
    glow: FileHandle?,
    emissive: FileHandle?,
    prefix: String?  // e.g., "ores" or "fluid" for non-block textures
) {
    val sourcePixmap = Pixmap(diffuse)

    // Determine atlas format from dimensions
    when {
        sourcePixmap.width == 16 && sourcePixmap.height == 16 ->
            processSingleTile(sourcePixmap)

        sourcePixmap.width == 112 && sourcePixmap.height == 112 ->
            processAutotilingBlock(sourcePixmap)

        sourcePixmap.width == 224 && sourcePixmap.height == 224 ->
            processSeasonalBlock(sourcePixmap)

        sourcePixmap.width == W_SUBTILE_GENERIC * SUBTILE_SIZE ->
            processSubtiledBlock(sourcePixmap, SUBTILE_GENERIC)

        sourcePixmap.width == W_SUBTILE_GRASS * SUBTILE_SIZE ->
            processSubtiledBlock(sourcePixmap, SUBTILE_GRASS)

        // ... other formats
    }
}

Barcode System

112×112 autotiling textures embed metadata in a "barcode" region at position (6,5):

Grid position (6, 5) = pixel column 96-111, row 80-95

Barcode Encoding

Two rows of 16 pixels encode properties as binary:

Row 1 (top): Connection type

  • Bits read left-to-right as binary number
  • 0000 = Connect mutually (CONNECT_MUTUAL)
  • 0001 = Connect to self only (CONNECT_SELF)

Row 2: Mask type

  • 0010 = Use autotiling (MASK_47)
  • Other values for special tiling modes

Reading the barcode (right-to-left):

// In fileToAtlantes() for 112×112 textures
var connectionType = 0
var maskType = 0
for (bit in 0 until TILE_SIZE) {  // TILE_SIZE = 16
    val x = (7 * TILE_SIZE - 1) - bit  // Read right-to-left: x = 111 down to 96
    val y1 = 5 * TILE_SIZE              // y1 = 80 (connection type row)
    val y2 = y1 + 1                     // y2 = 81 (mask type row)
    val pixel1 = (tilesPixmap.getPixel(x, y1).and(255) >= 128).toInt(bit)
    val pixel2 = (tilesPixmap.getPixel(x, y2).and(255) >= 128).toInt(bit)

    connectionType += pixel1
    maskType += pixel2
}
// Helper extension: fun Boolean.toInt(shift: Int) = if (this) (1 shl shift) else 0

The barcode is read right-to-left (from pixel x=111 to x=96), with each white pixel representing a binary 1.

RenderTag Generation

Each block gets a RenderTag storing atlas metadata:

data class RenderTag(
    val tileNumber: Int,      // Position in atlas (0-based index)
    val connectionType: Int,  // CONNECT_SELF, CONNECT_MUTUAL, etc.
    val maskType: Int,        // MASK_47, MASK_PLATFORM, etc.
    val tilingMode: Int,      // TILING_FULL, TILING_BRICK_SMALL, etc.
    val postProcessing: Int   // POSTPROCESS_NONE, POSTPROCESS_DEBLOCKING, etc.
)

Stored in hash maps:

lateinit var tags: HashMap<ItemID, RenderTag>        // By block ID
lateinit var tagsByTileNum: HashArray<RenderTag>     // By tile number

Item Image Generation

Terrain and Wall Images

Separate item textures for terrain and wall placement:

lateinit var itemTerrainTexture: Texture
lateinit var itemWallTexture: Texture

Generation Process

tags.forEach { (id, tag) ->
    val itemSheetNum = tileIDtoItemSheetNumber(id)

    if (tag.maskType >= 16) {
        // Subtiled block - composite from subtiles
        val subtilePositions = tileOffsetsForItemImageFromSubtile[tag.tilingMode / 2]
        compositeSub tiles(subtilePositions, itemSheetNum)
    } else {
        // Standard block - copy representative tile
        val atlasPos = tag.tileNumber + maskOffsetForItemImage(tag.maskType)
        copyTileToItemSheet(atlasPos, itemSheetNum)
    }
}

Wall Darkening

Wall item images are darkened for visual distinction:

val WALL_OVERLAY_COLOUR = Color(.72f, .72f, .72f, 1f)

for (y in 0 until itemWallPixmap.height) {
    for (x in 0 until itemWallPixmap.width) {
        val color = Color(itemWallPixmap.getPixel(x, y))
        color.mul(WALL_OVERLAY_COLOUR)
        itemWallPixmap.drawPixel(x, y, color.toRGBA())
    }
}

Glow and Emissive Layers

Separate Atlases

lateinit var atlasGlow: Pixmap      // Additive glow layer
lateinit var atlasEmissive: Pixmap  // Full-brightness layer

File Naming Convention

For a block texture 32.tga:

  • Main texture: 32.tga
  • Glow layer: 32_glow.tga (optional)
  • Emissive layer: 32_emsv.tga (optional)

Rendering

Glow and emissive layers render separately:

// 1. Render base tile (affected by lighting)
batch.draw(baseTexture, x, y)

// 2. Render glow (additive blend)
batch.setBlendFunction(GL_SRC_ALPHA, GL_ONE)
batch.draw(glowTexture, x, y)

// 3. Render emissive (full brightness)
batch.setColor(1f, 1f, 1f, 1f)  // Ignore lighting
batch.draw(emissiveTexture, x, y)
batch.setBlendFunction(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA)

Terrain Colour Map

Pre-calculated average colours for each block:

lateinit var terrainTileColourMap: HashMap<ItemID, Cvec>

Generation

for (id in itemSheetNumbers) {
    val tileNum = itemSheetNumbers[id]
    val tx = (tileNum % TILES_IN_X) * TILE_SIZE
    val ty = (tileNum / TILES_IN_X) * TILE_SIZE

    var r = 0f; var g = 0f; var b = 0f; var a = 0f

    // Average all pixels in the tile
    for (y in ty until ty + TILE_SIZE) {
        for (x in tx until tx + TILE_SIZE) {
            val pixel = itemTerrainPixmap.getPixel(x, y)
            r += extractRed(pixel)
            g += extractGreen(pixel)
            b += extractBlue(pixel)
            a += extractAlpha(pixel)
        }
    }

    val pixelCount = TILE_SIZE * TILE_SIZE
    terrainTileColourMap[id] = Cvec(
        r / pixelCount,
        g / pixelCount,
        b / pixelCount,
        a / pixelCount
    )
}

Usage

Colour maps are used for:

  • Minimap rendering
  • Particle colours when blocks break
  • LOD (Level of Detail) rendering
  • Fast block type identification

Atlas Size and Limits

Configuration

var MAX_TEX_SIZE = 2048          // Configurable atlas dimension
var TILES_IN_X = MAX_TEX_SIZE / TILE_SIZE  // = 128 tiles
var SUBTILES_IN_X = MAX_TEX_SIZE / SUBTILE_SIZE  // = 256 subtiles

Capacity

With 2048×2048 atlases:

  • Standard tiles: 128 × 128 = 16,384 tile positions
  • Subtiles: 256 × 256 = 65,536 subtile positions

Dynamic Resizing

The init.tga can be wider than MAX_TEX_SIZE. The engine tiles it vertically:

// If init.tga is 4096 pixels wide but MAX_TEX_SIZE = 2048:
// Split into two 2048×16 strips stacked vertically

val stripsNeeded = ceil(initPixmap.width / MAX_TEX_SIZE)
for (strip in 0 until stripsNeeded) {
    val srcX = strip * MAX_TEX_SIZE
    val destY = strip * TILE_SIZE
    atlas.drawPixmap(initPixmap, srcX, 0, MAX_TEX_SIZE, TILE_SIZE, 0, destY, MAX_TEX_SIZE, TILE_SIZE)
}

Atlas Cursor and Allocation

Atlas Cursor

Tracks next available tile position:

private var atlasCursor = 66  // First 66 tiles reserved

Reserved tiles (0-65):

  • Tile 0: Air (transparent)
  • Tiles 1-15: Reserved for engine use
  • Tiles 16-65: Breakage stages, update markers, etc.

Allocation

private fun allocateTileSpace(tilesNeeded: Int): Int {
    val allocated = atlasCursor
    atlasCursor += tilesNeeded

    if (atlasCursor >= TOTAL_TILES) {
        throw Error("Atlas full! Needed $tilesNeeded more tiles but only ${TOTAL_TILES - allocated} remaining.")
    }

    return allocated
}

Item Sheet Cursor

Separate cursor for item sheet allocation:

private var itemSheetCursor = 16  // Reserve first 16 item positions

Retexturing System

Alt File Paths

Modules can override textures from other modules:

val altFilePaths: HashMap<String, FileHandle> = ModMgr.GameRetextureLoader.altFilePaths

Override Resolution

val originalFile = Gdx.files.internal("basegame/blocks/32.tga")
val overrideFile = altFilePaths.getOrDefault(originalFile.path(), originalFile)
// Use overrideFile for atlas generation

Texture packs populate altFilePaths to replace textures without modifying original modules.

Performance Considerations

Memory Usage

Six season atlases + glow + emissive = 8 atlases

With 2048×2048 RGBA8888:

  • Per atlas: 2048 × 2048 × 4 bytes = 16 MB
  • Total: 8 × 16 MB = 128 MB of atlas memory

Plus item sheets (~2048×2048 × 6 = 48 MB).

Total texture memory: ~176 MB

Generation Time

Atlas generation is expensive:

  • Runs once at startup
  • Can be cached (planned feature)
  • Blocks game startup until complete

Optimisations

  1. Lazy subtile generation — Only process subtiled blocks that exist in BlockCodex
  2. Parallel texture loading — Could load multiple modules concurrently
  3. Atlas caching — Save generated atlases to disk (not yet implemented)
  4. Compression — Use texture compression (DXT/ETC) on GPUs that support it

Debugging

Export Atlases

Uncomment debug code to export atlases:

PixmapIO2.writeTGA(Gdx.files.absolute("atlas_0_prevernal.tga"), atlasPrevernal, false)
PixmapIO2.writeTGA(Gdx.files.absolute("atlas_1_vernal.tga"), atlasVernal, false)
// ... export all six

Verify Barcodes

Check barcode region is transparent except for set bits:

for (y in barcodeY until barcodeY + 2) {
    for (x in barcodeX until barcodeX + 16) {
        val pixel = pixmap.getPixel(x, y)
        if (!isTransparent(pixel) && !isWhite(pixel)) {
            println("WARNING: Barcode contains non-binary pixel at ($x, $y)")
        }
    }
}

Atlas Visualisation

Render atlas to screen with grid overlay:

batch.draw(atlasTexture, 0f, 0f)
shapeRenderer.begin(ShapeRenderer.ShapeType.Line)
for (i in 0 until TILES_IN_X) {
    shapeRenderer.line(i * TILE_SIZE, 0, i * TILE_SIZE, MAX_TEX_SIZE)
    shapeRenderer.line(0, i * TILE_SIZE, MAX_TEX_SIZE, i * TILE_SIZE)
}
shapeRenderer.end()

Potential Enhancements

  1. Atlas caching — Save generated atlases to disk to avoid regeneration on startup
  2. GPU texture compression — Use DXT/ETC formats to reduce VRAM usage
  3. Mipmap generation — For better filtering at distance
  4. Animated block textures — Frame-by-frame animation support
  5. Normal maps — For advanced lighting effects
  6. Parallax mapping — Create depth illusion

Note: Atlas expansion is already implemented (see expandAtlantes() method).

See Also