Table of Contents
- Rendering Pipeline
- Overview
- OpenGL Configuration
- Render Layers
- World Rendering
- Lighting System
- Sprite Rendering
- Camera System
- Shader System
- Frame Buffer System
- Particle System
- Weather Effects
- UI Rendering
- Performance Optimisations
- Debug Rendering
- Common Patterns
- Best Practises
- Troubleshooting
- See Also
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:
attribute→invarying→out
Fragment Shaders:
varying→ingl_FragColor→ customout vec4 fragColor
Render Layers
Actors and world elements are rendered in ordered layers:
Layer Order (Back to Front)
- FAR_BEHIND — Wires and conduits
- BEHIND — Tapestries, background particles
- MIDDLE — Actors (players, NPCs, creatures)
- MIDTOP — Projectiles, thrown items
- FRONT — Front walls and barriers
- 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
- Calculate visible area from camera
- Iterate visible tiles within camera bounds
- Fetch block data from world layers
- Determine sprite variant using autotiling
- Apply lighting from lightmap
- 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
- Initialize with global light (sun/moon/ambient)
- Add static luminous blocks (torches, lava, etc.)
- Add dynamic lights (actors, particles)
- Propagate light through tiles
- Apply transmittance based on block properties
- 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:
- Base sprite (affected by lighting)
- Glow layer (additive blend)
- 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
- Batch draw calls — Group similar sprites together
- Use texture atlantes — Reduce texture binding overhead
- Cull off-screen objects — Don't render invisible actors
- Cache sprite references — Don't recreate textures every frame
- Reset batch colour — Always reset to white after tinting
- Profile rendering — Identify bottlenecks with timing
- Minimise shader switches — Group by shader when possible
- Use appropriate precision —
lowp/mediumpin 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
- OpenGL Considerations — GL 3.2 requirements
- Glossary — Rendering terminology
- World — World data structure
- Actors — Actor rendering