Table of Contents
- Lighting Engine
- Overview
- Architecture
- Light Sources
- Light Propagation Algorithm
- Transmittance and Opacity
- Global Illumination
- Shadow System
- Corner Occlusion
- Performance Optimisations
- 1. Unsafe Memory Arrays
- 2. Overscan Region
- 3. Pre-Calculation
- 4. Static Local Variables
- 5. Multi-Pass Tuning
- Performance Metrics
- Technical Details
- Creating Light Sources
- Best Practises
- Common Pitfalls
- See Also
Lighting Engine
Audience: Engine maintainers and module developers working with lighting systems.
Terrarum features a sophisticated real-time lighting engine with RGB+UV colour channels, light propagation, transmittance, and global illumination. This guide covers the lighting system architecture, algorithms, and implementation.
Overview
The lighting engine provides:
- RGB+UV colour channels — Four-channel lighting (Red, Green, Blue, UV)
- Dynamic light propagation — Real-time spreading across the world
- Per-channel transmittance — Light absorption by blocks and fluids
- Global illumination — Reflected light from surfaces
- Multiple light sources — Sun, blocks, actors, items, fluids
- Corner occlusion — Visual effect via simple shader
- HDR ready — High dynamic range lighting
- Performant — Optimised for large visible areas
Architecture
Cvec (Colour Vector)
The lighting system uses Cvec — a four-component floating-point colour vector:
class Cvec(
var r: Float = 0f, // Red channel (0.0-1.0)
var g: Float = 0f, // Green channel (0.0-1.0)
var b: Float = 0f, // Blue channel (0.0-1.0)
var a: Float = 0f // UV/Alpha channel (0.0-1.0)
)
The fourth channel (A/W) represents UV light:
- Used for fluorescent effects
- Affects glow sprites
- Independent from RGB channels
Lightmap Structure
The lighting engine maintains a lightmap covering the visible area:
object LightmapRenderer {
private var lightmap = UnsafeCvecArray(LIGHTMAP_WIDTH, LIGHTMAP_HEIGHT)
private var _mapLightLevelThis = UnsafeCvecArray(LIGHTMAP_WIDTH, LIGHTMAP_HEIGHT)
private var _mapThisTileOpacity = UnsafeCvecArray(LIGHTMAP_WIDTH, LIGHTMAP_HEIGHT)
private var _mapThisTileOpacity2 = UnsafeCvecArray(LIGHTMAP_WIDTH, LIGHTMAP_HEIGHT)
}
Lightmap properties:
- Dynamic size based on screen resolution and zoom
- Includes overscan region for smooth light propagation
- Stored in unsafe memory arrays for performance
- Updated every frame
Coordinate Systems
// World coordinates → Array coordinates
private inline fun Int.convX() = this - for_x_start + overscan_open
private inline fun Int.convY() = this - for_y_start + overscan_open
// Check if world coordinate is in lightmap bounds
private fun inBounds(x: Int, y: Int) =
(y - for_y_start + overscan_open in 0 until LIGHTMAP_HEIGHT &&
x - for_x_start + overscan_open in 0 until LIGHTMAP_WIDTH)
Light Sources
1. Sunlight (Global Light)
The world has a global ambient light level:
val sunLight = world.globalLight // Cvec
Sunlight properties:
- Fills all open-air tiles
- Penetrates through non-solid blocks
- Blocked by solid terrain and walls
- Changes with time of day and weather
Sunlight application:
// Open air || luminous tile backed by sunlight
if ((!terrainProp.isSolid && !wallProp.isSolid) ||
(tileLuminosity.nonZero() && !wallProp.isSolid)) {
lightLevel.set(sunLight)
}
2. Block Luminosity
Blocks can emit light:
class BlockProp {
internal var baseLumColR = 0f
internal var baseLumColG = 0f
internal var baseLumColB = 0f
internal var baseLumColA = 0f
fun getLumCol(x: Int, y: Int): Cvec
}
Example luminous blocks:
- Lava:
(0.7664, 0.2032, 0.0, 0.0)— Orange-red - Glowstone:
(0.8, 0.8, 0.6, 0.0)— Warm yellow - Torches: Dynamic flicker via
dynamicLuminosityFunction
Dynamic luminosity:
Blocks with dynamicLuminosityFunction > 0 have animated lighting:
var dynamicLuminosityFunction: Int = 0 // 0=static, 1-7=various patterns
The engine pre-generates 64 virtual tiles per dynamic light block with randomised variations, creating effects such as per-tile randomised flickering.
3. Fluid Luminosity
Fluids can emit light based on fluid amount:
if (fluid.type != Fluid.NULL) {
val fluidAmount = fluid.amount.coerceIn(0f, 1f).pow(0.5f)
tileLuminosity.maxAndAssign(fluidProp.lumCol.mul(fluidAmount))
}
Fluid light properties:
- Scales with fluid level (square root for better visual)
- Blends with terrain luminosity (max of both)
- Full block of lava = full brightness
- Partial block = reduced brightness
4. Actor Lights
Actors can have lightboxes:
class ActorWithBody {
val lightBoxList: MutableList<Lightbox> // Emitted light
val shadeBoxList: MutableList<Shadebox> // Shadows cast
}
data class Lightbox(val box: Hitbox, val colour: Cvec)
Actor light sources:
- Defined via lightboxes attached to actor
- Move with actor position
- Scale with actor scale
- Multiple lightboxes per actor supported
Example: Glowing actor
actor.lightBoxList.add(Lightbox(
Hitbox(0.0, 0.0, 32.0, 32.0),
Cvec(0.8f, 0.4f, 0.1f, 0.0f) // Orange glow
))
5. Item Lights
Held items emit light:
// Held item automatically creates lightbox
val heldItem = actor.inventory.itemEquipped[GameItem.EquipPosition.HAND_GRIP]
val light = ItemCodex[heldItem]?.getLumCol() ?: Cvec(0)
Items define luminosity via:
abstract class GameItem {
open fun getLumCol(): Cvec = Cvec(0)
}
Light Propagation Algorithm
The "Light Swiper"
The engine uses a multi-pass directional sweeping algorithm:
// Pass 1: Precalculation of tile luminosity/opacity before swiping
precalculate(lightmap, x, y)
// Pass 2: Four directional sweeps
r1(lightmap) // Horizontal ↔
r2(lightmap) // Vertical ↕
r3(lightmap) // Diagonal ⤡
r4(lightmap) // Diagonal ⤢
// Pass 3: Precalculation of tile reflectance before secondary swiping
precalculate2(lightmap, x, y)
// Pass 4: Second sweep pass
r1(lightmap); r2(lightmap); r3(lightmap); r4(lightmap)
// Optional additional passes for quality
for (pass in 3..lightpasses) {
r1(lightmap); r2(lightmap); r3(lightmap); r4(lightmap)
}
Algorithm complexity: O(8n) per pass, where n is lightmap size
Directional Sweeps
Each sweep propagates light in one direction:
Horizontal Sweep (r1)
fun r1(lightmap: UnsafeCvecArray) {
for (line in 1 until LIGHTMAP_HEIGHT - 1) {
swipeLight(
1, line, // Start: left edge
LIGHTMAP_WIDTH - 2, line, // End: right edge
1, 0, // Direction: ↔
lightmap, false // Not diagonal
)
}
}
Propagates light left-to-right across each row.
Vertical Sweep (r2)
fun r2(lightmap: UnsafeCvecArray) {
for (line in 1 until LIGHTMAP_WIDTH - 1) {
swipeLight(
line, 1, // Start: top edge
line, LIGHTMAP_HEIGHT - 2, // End: bottom edge
0, 1, // Direction: ↕
lightmap, false
)
}
}
Propagates light top-to-bottom down each column.
Diagonal Sweeps (r3, r4)
// Diagonal ⤡ (top-left to bottom-right)
fun r3(lightmap: UnsafeCvecArray) {
for (i in 0 until LIGHTMAP_WIDTH + LIGHTMAP_HEIGHT - 5) {
swipeLight(
max(1, i - LIGHTMAP_HEIGHT + 4),
max(1, LIGHTMAP_HEIGHT - 2 - i),
min(LIGHTMAP_WIDTH - 2, i + 1),
min(LIGHTMAP_HEIGHT - 2, (LIGHTMAP_WIDTH + LIGHTMAP_HEIGHT - 5) - i),
1, 1, // Direction: ⤡
lightmap, true // Diagonal
)
}
}
// Diagonal ⤢ (bottom-left to top-right)
fun r4(lightmap: UnsafeCvecArray) {
// Similar but with direction (1, -1)
}
Propagates light diagonally across the map.
Bidirectional Swipe
Each sweep runs bidirectionally:
fun swipeLight(sx: Int, sy: Int, ex: Int, ey: Int, dx: Int, dy: Int, lightmap: UnsafeCvecArray, swipeDiag: Boolean) {
// Forward pass: start → end
var x = sx, y = sy
while (x*dx <= ex*dx && y*dy <= ey*dy) {
swipeTask(x, y, x-dx, y-dy, lightmap, swipeDiag)
x += dx; y += dy
}
// Backward pass: end → start
x = ex; y = ey
while (x*dx >= sx*dx && y*dy >= sy*dy) {
swipeTask(x, y, x+dx, y+dy, lightmap, swipeDiag)
x -= dx; y -= dy
}
}
Why bidirectional?
- Ensures light propagates in both directions
- Eliminates directional bias
- Produces uniform light spread
Transmittance and Opacity
Per-Channel Opacity
Blocks and fluids have opacity per colour channel:
class BlockProp {
var opacity = Cvec() // Per-channel opacity (0.0-1.0)
}
// Examples:
stoneOpacity = Cvec(0.629, 0.629, 0.629, 0.629) // Solid blocks light uniformly
waterOpacity = Cvec(0.102, 0.074, 0.051, 0.083) // Blue-tinted
Opacity values:
0.0— Fully transparent (no light absorption)0.5— Half absorption1.0— Fully opaque (complete absorption)
Light Attenuation
Light passing through a tile is attenuated per channel:
fun darkenColoured(x: Int, y: Int, opacity: Cvec, lightmap: UnsafeCvecArray): Cvec {
return lightmap.getVec(x, y).lanewise { light, channel ->
light * (1f - opacity.lane(channel) * lightScalingMagic)
}
}
private const val lightScalingMagic = 1.0f / 8.0f // Tuning constant
Attenuation formula per channel:
newLight = oldLight × (1 - opacity × scalingFactor)
Exponential decay:
Recursive application creates exponential decay curve:
- Light halves every ~5.5 tiles through stone
- More transparent materials allow light further
- Per-channel opacity creates coloured shadows
Diagonal Attenuation
Diagonal propagation uses increased opacity:
val diagonalOpacity = opacity * sqrt(2) // ≈ 1.414
Why? Light travels ~1.414× farther diagonally, requiring proportional attenuation.
Opacity Blending
Multiple opacity sources blend:
// Terrain opacity
tileOpacity.set(terrainProp.opacity)
// Blend fluid opacity
if (fluid.type != Fluid.NULL) {
val fluidAmount = fluid.amount.coerceIn(0f, 1f).pow(0.5f)
tileOpacity.max(fluidProp.opacity.mul(fluidAmount))
}
// Blend shadow opacity
tileOpacity.max(shadowMap[blockAddress] ?: colourNull)
Uses max() to blend — most opaque channel wins.
Global Illumination
Reflectance
Blocks reflect nearby light based on surface colour and reflectance:
fun precalculate2(lightmap: UnsafeCvecArray, x: Int, y: Int) {
// Blend nearby 4 lights to get ambient intensity
ambientLight.set(0)
.maxAndAssign(lightmap.getVec(x - 1, y))
.maxAndAssign(lightmap.getVec(x + 1, y))
.maxAndAssign(lightmap.getVec(x, y - 1))
.maxAndAssign(lightmap.getVec(x, y + 1))
// Calculate reflected colour
val tileColour = App.tileMaker.terrainTileColourMap[terrainProp.id]
val reflectedLight = tileColour
.mul(terrainProp.reflectance)
.mul(giScale) // 0.35 global scaling
// Add to tile light
lightLevel.max(reflectedLight)
}
Reflectance properties:
0.0— Fully absorbing (no reflection)0.5— Moderate reflection1.0— Fully reflective (mirror-like)
Common reflectance values:
- Stone: 0.0-0.3 (low)
- Metal: 0.7-0.9 (high)
- Mirrors: 1.0 (perfect)
Global Illumination Scale
private const val giScale = 0.35f
Reduces reflected light intensity to prevent over-brightening.
Shadow System
Actors can cast shadows:
data class Shadebox(val box: Hitbox, val opacity: Cvec)
actor.shadeBoxList.add(Shadebox(
Hitbox(0.0, 0.0, 32.0, 32.0),
Cvec(0.5f, 0.5f, 0.5f, 0.0f) // 50% shadow
))
Shadow blending:
// Shadows blend with tile opacity
tileOpacity.max(shadowMap[blockAddress] ?: colourNull)
Shadows increase tile opacity, darkening areas.
Corner Occlusion
Note: Corner occlusion is a visual effect implemented via a simple shader, not part of the lighting calculation.
The shader darkens tile corners based on adjacent tile solidity, creating the illusion of ambient occlusion without expensive ray tracing.
Performance Optimisations
1. Unsafe Memory Arrays
class UnsafeCvecArray(width: Int, height: Int) {
private val ptr: Long = UnsafeHelper.allocate(width * height * 16) // 4 floats × 4 bytes
}
Benefits:
- Direct memory access
- No GC pressure
- Cache-friendly layout
- Great performance improvement
2. Overscan Region
const val overscan_open: Int = 40
const val overscan_opaque: Int = 10
Lightmap extends beyond visible area:
- Prevents edge artefacts
- Allows smooth light propagation
- Different sizes for open vs opaque areas
3. Pre-Calculation
fun precalculate(x: Int, y: Int) {
// Cache tile properties
_thisTerrain = world.getTileFromTerrainRaw(x, y)
_thisTerrainProp = BlockCodex[_thisTerrain]
// Cache opacity
_mapThisTileOpacity.setVec(x, y, _thisTerrainProp.opacity)
// Cache luminosity
_thisTileLuminosity.set(_thisTerrainProp.getLumCol(x, y))
}
Avoids repeated property lookups during sweeps.
4. Static Local Variables
// Reused across function calls to avoid allocations
private val sunLight = Cvec(0)
private val _ambientAccumulator = Cvec(0)
private val _reflectanceAccumulator = Cvec(0)
private val _thisTileOpacity = Cvec(0)
Eliminates per-frame allocations.
5. Multi-Pass Tuning
// Configurable pass count
val passcnt = App.getConfigInt("lightpasses") // Default: 2-3
Trade-offs:
- 2 passes: ~4-6 ms, minor dark spots
- 3 passes: ~6-9 ms, good quality
- 4+ passes: ~8-12+ ms, excellent quality
Performance Metrics
Typical frame timings (Intel 6700K CPU):
- Lantern/shadow map build: ~0.003 ms
- Precalculation 1: ~1-2 ms
- Light pass 1: ~2-3 ms
- Precalculation 2: ~1-2 ms
- Light pass 2: ~2-3 ms
- Total: ~6-10 ms per frame
Optimisation history:
- Original: ~15-20 ms
- Direct memory access: ~12-15 ms
- NEWLIGHT2 algorithm: ~6-10 ms
- Current: ~4-8 ms (with tuning)
Technical Details
Lightmap Sizing
private var LIGHTMAP_WIDTH: Int =
(1f / ZOOM_MINIMUM) * screenWidth / TILE_SIZE + overscan_open * 2 + 3
private var LIGHTMAP_HEIGHT: Int =
(1f / ZOOM_MINIMUM) * screenHeight / TILE_SIZE + overscan_open * 2 + 3
Size factors:
- Screen resolution
- Minimum zoom level (for worst-case sizing)
- Overscan region
- Extra padding for edge cases
Light Map Rendering
fun getLight(x: Int, y: Int): Cvec? {
// Handle world wrapping (round world on X-axis)
val wrappedX = if (/* wrap condition */) x + world.width
else if (/* other wrap */) x - world.width
else x
if (!inBounds(wrappedX, y)) return null
return lightmap.getVec(wrappedX.convX(), y.convY())
}
Handles:
- World wrapping on X-axis (round world)
- Out-of-bounds queries
- Coordinate conversion
Lantern and Shadow Maps
private val lanternMap = HashMap<BlockAddress, Cvec>()
private val shadowMap = HashMap<BlockAddress, Cvec>()
Purpose:
- Store actor light/shadow contributions
- Keyed by block address for fast lookup
- Cleared and rebuilt each frame
Building process:
fun buildLanternAndShadowMap(actorContainer: List<ActorWithBody>) {
lanternMap.clear()
shadowMap.clear()
actorContainer.forEach { actor ->
actor.lightBoxList.forEach { (box, colour) ->
// Rasterise lightbox to affected blocks
forEachTileInBox(box) { blockAddr ->
lanternMap[blockAddr] = (lanternMap[blockAddr] ?: Cvec(0))
.maxAndAssign(colour)
}
}
// Similar for shadeBoxList → shadowMap
}
}
Creating Light Sources
Luminous Block
Define block with light emission:
"id";"lumr";"lumg";"lumb";"lumuv";"dlfn"
"200";"0.8";"0.6";"0.2";"0.0";"0"
Static orange glow.
Dynamic Light Block
"id";"lumr";"lumg";"lumb";"lumuv";"dlfn"
"201";"0.9";"0.4";"0.0";"0.0";"2"
Flickering torch (dlfn=2).
Glowing Item
class GlowingOrb(originalID: ItemID) : GameItem(originalID) {
override fun getLumCol(): Cvec {
return Cvec(0.3f, 0.8f, 0.9f, 0.2f) // Cyan with UV
}
}
Actor with Light
class Firefly(referenceID: ActorID) : ActorWithBody(referenceID) {
init {
lightBoxList.add(Lightbox(
Hitbox(8.0, 8.0, 16.0, 16.0), // Centre glow
Cvec(0.9f, 0.9f, 0.3f, 0.0f) // Yellow
))
}
}
Coloured Opacity
Create coloured glass:
"id";"shdr";"shdg";"shdb";"shduv";"solid"
"300";"0.8";"0.2";"0.2";"0.0";"0"
Red-tinted glass (high red shade, low GB).
Best Practises
- Use appropriate opacity — Match material properties
- Balance luminosity — Avoid over-bright sources
- Leverage UV channel — Create fluorescent effects
- Test dynamic lights — Ensure flickering looks good
- Tune reflectance — Metals high, rock low
- Consider performance — Limit excessive light sources
- Use per-channel opacity — Create coloured shadows
- Test in darkness — Verify light propagation
- Blend fluids properly — Scale with fluid amount
- Profile lighting passes — Monitor frame time
Common Pitfalls
- Too many light sources — Performance degrades
- Opacity = 1.0 everywhere — Light can't propagate
- No reflectance — World looks flat
- Ignoring UV channel — Missing fluorescent opportunities
- Uniform opacity — No coloured shadows
- Excessive luminosity — Blindingly bright
- Dynamic lights everywhere — Flickering overload
- Not testing zoom — Lightmap size issues
- Forgetting shadows — Actors float unnaturally
- Wrong light scaling — Dark spots or over-bright areas
See Also
- Blocks#Luminosity — Block light properties
- Actors — Actor lightboxes and shadeboxes
- World — Global light and day/night cycle
- Weather — Weather lighting effects