1
Lighting Engine
minjaesong edited this page 2025-11-27 11:06:08 +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.

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 absorption
  • 1.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 reflection
  • 1.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

  1. Use appropriate opacity — Match material properties
  2. Balance luminosity — Avoid over-bright sources
  3. Leverage UV channel — Create fluorescent effects
  4. Test dynamic lights — Ensure flickering looks good
  5. Tune reflectance — Metals high, rock low
  6. Consider performance — Limit excessive light sources
  7. Use per-channel opacity — Create coloured shadows
  8. Test in darkness — Verify light propagation
  9. Blend fluids properly — Scale with fluid amount
  10. 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