Table of Contents
- Tile Atlas System (Engine Internals)
- Overview
- Six-Season System
- Subtiling System
- Subtile Size
- Subtile Grid
- Subtile Atlases
- Subtile Layout
- Subtile Offset Vectors
- Tiling Modes
- Item Image Generation from Subtiles
- Atlas Generation Process
- Item Image Generation
- Glow and Emissive Layers
- Terrain Colour Map
- Atlas Size and Limits
- Atlas Cursor and Allocation
- Retexturing System
- Performance Considerations
- Debugging
- Potential Enhancements
- See Also
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
- Lazy subtile generation — Only process subtiled blocks that exist in BlockCodex
- Parallel texture loading — Could load multiple modules concurrently
- Atlas caching — Save generated atlases to disk (not yet implemented)
- 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
- Atlas caching — Save generated atlases to disk to avoid regeneration on startup
- GPU texture compression — Use DXT/ETC formats to reduce VRAM usage
- Mipmap generation — For better filtering at distance
- Animated block textures — Frame-by-frame animation support
- Normal maps — For advanced lighting effects
- Parallax mapping — Create depth illusion
Note: Atlas expansion is already implemented (see expandAtlantes() method).
See Also
- Rendering Pipeline — How atlases are used during rendering
- Autotiling In-Depth — Detailed autotiling algorithm
- Modules:Blocks — Creating block textures for modules