2
Rendering Pipeline
minjaesong edited this page 2025-11-27 11:16:42 +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.

Rendering Pipeline

The Terrarum rendering pipeline is built on LibGDX with OpenGL 3.2 Core Profile, featuring tile-based rendering, dynamic lighting, and sophisticated visual effects.

Overview

The rendering system provides:

  • Tile-based world rendering with autotiling
  • RGB+UV lightmap system with transmittance
  • Multi-layer sprite rendering (up to 64 layers)
  • Custom shaders (GLSL 1.50)
  • Post-processing effects
  • Weather and particle systems

OpenGL Configuration

Version Requirements

Terrarum targets OpenGL 3.2 Core Profile for maximum compatibility, especially with macOS:

val appConfig = Lwjgl3ApplicationConfiguration()
appConfig.setOpenGLEmulation(
    Lwjgl3ApplicationConfiguration.GLEmulation.GL30,
    3, 2  // OpenGL 3.2
)

GLSL Version

All shaders must use GLSL 1.50:

#version 150

See also: OpenGL Considerations

Shader Syntax Changes

GLSL 1.50 requires modern syntax:

Vertex Shaders:

  • attributein
  • varyingout

Fragment Shaders:

  • varyingin
  • gl_FragColor → custom out vec4 fragColor

Render Layers

Actors and world elements are rendered in ordered layers:

Layer Order (Back to Front)

  1. FAR_BEHIND — Wires and conduits
  2. BEHIND — Tapestries, background particles
  3. MIDDLE — Actors (players, NPCs, creatures)
  4. MIDTOP — Projectiles, thrown items
  5. FRONT — Front walls and barriers
  6. OVERLAY — Screen overlays (unaffected by lighting)

Each actor's renderOrder property determines its layer.

World Rendering

BlocksDrawer

The BlocksDrawer class handles tile rendering:

class BlocksDrawer(
    val world: GameWorld,
    val camera: WorldCamera
)

Tile Rendering Process

  1. Calculate visible area from camera
  2. Iterate visible tiles within camera bounds
  3. Fetch block data from world layers
  4. Determine sprite variant using autotiling
  5. Apply lighting from lightmap
  6. Batch draw tiles to screen

Tile Atlas System

Blocks use texture atlantes for autotiling variants:

Atlas Formats

  • 16×16 — Single tile (no autotiling)
  • 64×16 — Wall stickers (4 variants: free, left, right, bottom)
  • 128×16 — Platforms (8 variants)
  • 112×112 — Full autotiling (7×7 grid = 49 variants)
  • 224×224 — Seasonal autotiling (4 seasons × 49 variants)

Autotiling Algorithm

// Calculate tile connections (8 neighbours)
val connections = calculateConnections(x, y, blockType)

// Map to atlas coordinates
val atlasX = connectionPattern and 0x7  // 0-6
val atlasY = (connectionPattern shr 3) and 0x7  // 0-6

// Select sprite region
val sprite = atlas.get(atlasX * 16, atlasY * 16, 16, 16)

Connection Rules

Autotiling tiles have a "barcode" pixel in the atlas (position 6,5) encoding:

Top row (connection type):

  • 0000 — Connect mutually (all tagged tiles connect)
  • 0001 — Connect to self only

Second row (mask type):

  • 0010 — Use autotiling

Seasonal Variation

224×224 atlantes support seasonal colour blending:

┌─────────┬─────────┐
│ Summer  │ Autumn  │
├─────────┼─────────┤
│ Spring  │ Winter  │
└─────────┴─────────┘

Current and next season textures blend based on in-game time.

Lighting System

RGB+UV Lightmap

Terrarum simulates four light channels:

  • R — Red light
  • G — Green light
  • B — Blue light
  • UV — Ultraviolet light

Each channel propagates independently with transmittance.

LightmapRenderer

class LightmapRenderer(
    val world: GameWorld,
    val camera: WorldCamera
)

Light Calculation

  1. Initialize with global light (sun/moon/ambient)
  2. Add static luminous blocks (torches, lava, etc.)
  3. Add dynamic lights (actors, particles)
  4. Propagate light through tiles
  5. Apply transmittance based on block properties
  6. Render to lightmap texture

Block Lighting Properties

Blocks define lighting behaviour:

// Light absorption (0.0 = transparent, 1.0 = opaque)
val shadeR: Float
val shadeG: Float
val shadeB: Float
val shadeUV: Float

// Light emission (0.0 = none, 1.0+ = bright)
val lumR: Float
val lumG: Float
val lumB: Float
val lumUV: Float

// Reflectance for non-fluids (0.0-1.0)
val reflectance: Float

Dynamic Light Functions

Luminous blocks can have time-varying brightness:

enum class DynamicLightFunction {
    STATIC,           // 0: Constant brightness
    TORCH_FLICKER,    // 1: Flickering torch
    GLOBAL_LIGHT,     // 2: Sun/moon light
    DAYLIGHT_NOON,    // 3: Fixed noon brightness
    SLOW_BREATH,      // 4: Gentle pulsing
    PULSATING         // 5: Rhythmic pulsing
}

Corner Occlusion

The lighting system includes corner darkening as a visual effect using a simple shader, enhancing depth perception.

Sprite Rendering

FlippingSpriteBatch

Terrarum uses a custom FlippingSpriteBatch for all sprite rendering:

val batch = FlippingSpriteBatch()

batch.begin()
// Draw sprites
batch.end()

This custom implementation adds engine-specific optimisations.

Actor Sprites

Actors use the SpriteAnimation system:

actor.sprite = SheetSpriteAnimation(
    spriteSheet,
    frameWidth, frameHeight,
    frameDuration
)

// Update animation
actor.sprite.update(delta)

// Render
batch.draw(actor.sprite.currentFrame, x, y)

Multi-Layer Sprites

Actors support multiple sprite layers:

@Transient var sprite: SpriteAnimation?         // Base sprite
@Transient var spriteGlow: SpriteAnimation?     // Glow layer
@Transient var spriteEmissive: SpriteAnimation? // Emissive layer

Layers are composited in order:

  1. Base sprite (affected by lighting)
  2. Glow layer (additive blend)
  3. Emissive layer (full brightness)

Blend Modes

enum class BlendMode {
    NORMAL,     // Standard alpha blending
    ADDITIVE,   // Additive blending (glow effects)
    MULTIPLY,   // Multiplicative blending (shadows)
}

actor.drawMode = BlendMode.ADDITIVE

Camera System

WorldCamera

The camera determines the visible area:

class WorldCamera(
    val width: Int,    // Screen width
    val height: Int    // Screen height
) : OrthographicCamera()

Camera Properties

val xTileStart: Int   // First visible tile X
val yTileStart: Int   // First visible tile Y
val xTileEnd: Int     // Last visible tile X
val yTileEnd: Int     // Last visible tile Y

Camera Movement

camera.position.set(targetX, targetY, 0f)
camera.update()

Screen Zoom

IngameInstance supports camera zoom:

var screenZoom: Float = 1.0f
val ZOOM_MINIMUM = 1.0f
val ZOOM_MAXIMUM = 4.0f

// Apply zoom
camera.zoom = 1f / screenZoom

Shader System

ShaderMgr

The shader manager loads and manages GLSL programs:

object ShaderMgr {
    fun get(shaderName: String): ShaderProgram
}

Custom Shaders

Shaders are stored in src/shaders/:

src/shaders/
├── default.vert        // Default vertex shader
├── default.frag        // Default fragment shader
├── lightmap.frag       // Lightmap shader
└── postprocess.frag    // Post-processing

Shader Example

Vertex Shader (default.vert):

#version 150

in vec2 a_position;
in vec2 a_texCoord0;
in vec4 a_color;

out vec2 v_texCoords;
out vec4 v_color;

uniform mat4 u_projTrans;

void main() {
    v_texCoords = a_texCoord0;
    v_color = a_color;
    gl_Position = u_projTrans * vec4(a_position, 0.0, 1.0);
}

Fragment Shader (default.frag):

#version 150

in vec2 v_texCoords;
in vec4 v_color;

out vec4 fragColor;

uniform sampler2D u_texture;

void main() {
    fragColor = texture(u_texture, v_texCoords) * v_color;
}

Applying Shaders

val shader = ShaderMgr["myshader"]
batch.shader = shader
shader.setUniformf("u_customParam", 1.0f)

Frame Buffer System

FrameBufferManager

Manages off-screen rendering targets:

object FrameBufferManager {
    fun createBuffer(width: Int, height: Int): FrameBuffer
}

Render-to-Texture

val fbo = FrameBuffer(Pixmap.Format.RGBA8888, width, height, false)

fbo.begin()
// Render to framebuffer
renderWorld()
fbo.end()

// Use as texture
val texture = fbo.colorBufferTexture
batch.draw(texture, x, y)

Post-Processing

// Render scene to FBO
sceneFBO.begin()
renderScene()
sceneFBO.end()

// Apply post-processing
batch.shader = postProcessShader
batch.draw(sceneFBO.colorBufferTexture, 0f, 0f)

Particle System

Creating Particles

fun createParticle(
    x: Double,
    y: Double,
    velocityX: Double,
    velocityY: Double,
    lifetime: Float,
    sprite: TextureRegion
): ActorWithBody

Block Break Particles

createRandomBlockParticle(
    world,
    blockX,
    blockY,
    blockID,
    particleCount = 8
)

Particles are lightweight actors rendered in appropriate layers.

Weather Effects

WeatherMixer

Manages weather rendering:

val weatherBox = world.weatherbox

weatherBox.currentWeather  // Active weather type
weatherBox.windSpeed       // Wind speed
weatherBox.windDir         // Wind direction

Weather effects (rain, snow, fog) are rendered as particles with physics simulation.

UI Rendering

UI elements render on top of the game world:

// Render game world
renderWorld()

// Render UI (unaffected by lighting)
batch.shader = null  // Reset shader
uiCanvas.render(batch)

See also: UI Framework

Performance Optimisations

Batch Rendering

Minimise draw calls by batching:

batch.begin()
// Draw many sprites
for (tile in visibleTiles) {
    batch.draw(tile.texture, x, y)
}
batch.end()  // Single draw call

Culling

Only render visible elements:

if (actor.hitbox.intersects(camera.bounds)) {
    actor.render(batch)
}

Texture Atlas

Use texture atlantes to reduce texture switches:

val atlas = TextureAtlas("sprites/packed.atlas")

Level of Detail

Reduce detail for distant objects:

val distance = actor.position.dst(camera.position)
if (distance > LOD_THRESHOLD) {
    renderSimplified(actor)
} else {
    renderDetailed(actor)
}

Debug Rendering

Debug Overlays

if (App.IS_DEVELOPMENT_BUILD) {
    renderHitboxes()
    renderTileGrid()
    renderLightValues()
}

Shape Renderer

Use ShapeRenderer for debug visuals:

val shapes = ShapeRenderer()

shapes.begin(ShapeRenderer.ShapeType.Line)
shapes.rect(hitbox.x, hitbox.y, hitbox.width, hitbox.height)
shapes.end()

Common Patterns

Rendering a Tile

val blockID = world.getTileFromTerrain(x, y)
val block = BlockCodex[blockID]
val sprite = CreateTileAtlas.getSprite(blockID, x, y)
val light = lightmap.getLight(x, y)

batch.setColor(light.r, light.g, light.b, 1f)
batch.draw(sprite, x * TILE_SIZE, y * TILE_SIZE)
batch.setColor(1f, 1f, 1f, 1f)

Rendering an Actor

actor.sprite?.update(delta)
val frame = actor.sprite?.currentFrame

val light = getLightAtPosition(actor.hitbox.centerX, actor.hitbox.centerY)
batch.setColor(light.r, light.g, light.b, 1f)
batch.draw(
    frame,
    actor.hitbox.startX,
    actor.hitbox.startY
)
batch.setColor(1f, 1f, 1f, 1f)

Custom Shader Effect

val shader = ShaderMgr["wave"]
batch.shader = shader
shader.setUniformf("u_time", gameTime)
shader.setUniformf("u_amplitude", 0.1f)

batch.draw(texture, x, y)

batch.shader = null  // Reset to default

Best Practises

  1. Batch draw calls — Group similar sprites together
  2. Use texture atlantes — Reduce texture binding overhead
  3. Cull off-screen objects — Don't render invisible actors
  4. Cache sprite references — Don't recreate textures every frame
  5. Reset batch colour — Always reset to white after tinting
  6. Profile rendering — Identify bottlenecks with timing
  7. Minimise shader switches — Group by shader when possible
  8. Use appropriate precisionlowp/mediump in shaders when sufficient

Troubleshooting

Black Screen

  • Check OpenGL version support
  • Verify shader compilation (check logs)
  • Ensure camera is positioned correctly

Flickering

  • Z-fighting: Ensure proper layer ordering
  • Missing batch.begin()/end() calls
  • Texture binding issues

Performance Issues

  • Too many draw calls (use atlantes)
  • Large lightmap calculations
  • Excessive particle count
  • Missing culling

See Also