Lighting System:
This document provides a detailed replication guide for Pyrite’s dynamic voxel lighting system, including sunlight propagation, blocklight spreading, queue-based BFS, and lightmap packing.
Overview
Pyrite uses a Breadth-First Search (BFS) algorithm to propagate light from sources and sunlight downward. The system handles two types of light:
Sunlight: From top of world, level 15 at surface, dims through water/leaves
Blocklight: From torches/glowstone, spreads spherically, diminishes by 1 per block
Both are packed into a single byte (4 bits each) for memory efficiency.
Lightmap Data Structure
Packing Format:
# Reference packing (actual engine uses compiled Numba for speed)
# sunlight: 0-15 (upper 4 bits)
# blocklight: 0-15 (lower 4 bits)
packed = (sunlight << 4) | blocklight
# Unpacking
sunlight = (packed >> 4) & 0xF
blocklight = packed & 0xF
Storage:
1D array per chunk: (110592), uint8 array
Index calculation: index = x + z * 48 + y * 48²
Typical size: ~108 KB per chunk (uncompressed), ~27 KB (compressed)
Sunlight Propagation Algorithm
Initialization (new or unloaded chunk):
Cast sunlight downward from top
Maintain level 15 through air until hitting solid blocks
Reduce by 2 through water/leaves, by 1 through other transparent blocks
def init_chunk_sunlight(chunk_voxels, chunk_lightmap):
"""Initialize sunlight for freshly loaded chunk"""
# Constants
LIGHT_LEVEL_MAX = 15
SUNLIGHT_QUEUE = [] # BFS queue
# Step 1: Cast sunlight downward
for x in range(CHUNK_SIZE):
for z in range(CHUNK_SIZE):
current_level = LIGHT_LEVEL_MAX
for y in range(CHUNK_SIZE - 1, -1, -1): # Top to bottom
voxel_id = chunk_voxels[x + z * 48 + y * 48**2]
if not is_transparent(voxel_id):
# Hit solid block, light propagates no further down this column
break
# Set sunlight for this air block
lightmap_index = x + z * 48 + y * 48**2
packed = chunk_lightmap[lightmap_index]
blocklight = packed & 0xF
# Pack into single byte: (sunlight << 4) | blocklight
chunk_lightmap[lightmap_index] = (current_level << 4) | blocklight
# Queue for horizontal spread
SUNLIGHT_QUEUE.append((x, y, z, current_level, SUNLIGHT))
# Reduce level through certain blocks
if voxel_id == WATER or voxel_id == LEAVES:
current_level = max(0, current_level - 2)
elif voxel_id in [GLASS]:
current_level = max(0, current_level - 1)
# Step 2: Propagate horizontally (BFS)
propagate_light_bfs(chunk_voxels, chunk_lightmap, SUNLIGHT_QUEUE, SUNLIGHT)
O(1) Vertical Raycast Optimization:
When a block is mined exposing a hole to sunlight, instead of expensive 3D BFS:
Vertical Raycast Optimization:
When a block is mined exposing a hole to sunlight, an O(1) downward fill is more efficient than 3D BFS:
# Pseudo-code: how vertical sunlight filling optimizes block breaking
# (Actual implementation is inside update_light_remove_block in src/lighting.py)
def quick_sunlight_fill(x, y, z, chunk_voxels, chunk_lightmap):
"""Quick downward fill for exposed blocks"""
LIGHT_LEVEL_MAX = 15
current_level = LIGHT_LEVEL_MAX
for dy in range(y, CHUNK_SIZE): # Downward
voxel_id = chunk_voxels[x + z * 48 + dy * 48**2]
if not is_transparent(voxel_id):
break # Hit solid, propagation stops
# Set light: (current_level << 4) | blocklight
lightmap_index = x + z * 48 + dy * 48**2
packed = chunk_lightmap[lightmap_index]
blocklight = packed & 0xF
chunk_lightmap[lightmap_index] = (current_level << 4) | blocklight
# Apply diminishment
if voxel_id == WATER or voxel_id == LEAVES:
current_level = max(0, current_level - 2)
# Queue neighboring blocks for BFS expansion
BFS Light Propagation
Queue-Based Approach (CPU-efficient):
Instead of Python lists (slow), use preallocated NumPy arrays as ring buffers:
Queue-Based Approach (Actual Implementation):
The engine uses global pre-allocated NumPy arrays as ring buffers for efficiency:
# Actual implementation in src/lighting.py uses:
# - GLOBAL_QUEUE_A: numpy array storing (x, y, z, level) tuples
# - GLOBAL_QUEUE_B: numpy array for secondary queue
# - Head/tail pointers for ring buffer management
#
# See propagate_light_queue(queue, tail, is_sun, ...) in src/lighting.py
Main BFS Loop:
# Pseudo-code for light propagation (actual: propagate_light_queue in src/lighting.py)
def propagate_light_queue(queue, tail, is_sun, world_voxels, world_lightmaps, chunk_positions):
"""Propagate light using BFS queue.
Args:
queue: Pre-allocated array of light nodes
tail: Current position in ring buffer
is_sun: True for sunlight, False for blocklight
world_voxels: All loaded chunk voxels
world_lightmaps: All loaded chunk lightmaps
chunk_positions: Chunk position lookup table
"""
DIRECTIONS = [
When a torch is removed or a block is broken, light must be recalculated. The algorithm uses a **Removal Pass** followed by a **Refill Pass**:
.. code-block:: python
# Pseudo-code for light source removal (actual: update_light_remove_block in src/lighting.py)
def remove_block_light_update(broken_x, broken_y, broken_z, world_voxels, world_lightmaps, chunk_positions):
"""Recalculate lighting after a block is broken.
Two-pass algorithm:
1. Removal pass: Scan neighbors and decrement light if it came from broken light source
2. Refill pass: Propagate light from unaffected sources to refill dark areas
"""
node = queue.dequeue()
if node is None:
break
x, y, z, level = node
# Skip if at lowest possible light level
if level <= 0:
continue
# Check all 6 neighbors
for dx, dy, dz in DIRECTIONS:
neighbor_x = x + dx
neighbor_y = y + dy
neighbor_z = z + dz
# Skip out-of-bounds
if neighbor_y < 0 or neighbor_y >= WORLD_HEIGHT:
continue
# Get neighbor voxel and light
neighbor_voxel = get_voxel_fast(neighbor_x, neighbor_y, neighbor_z, world_voxels)
neighbor_light = get_light_fast(neighbor_x, neighbor_y, neighbor_z, world_lightmaps, light_type)
# Skip if solid
if not is_transparent(neighbor_voxel):
continue
# Calculate diminished light
if light_type == SUNLIGHT and dy == 1 and neighbor_voxel == AIR:
# Special case: downward through air keeps full level 15
new_level = level
else:
# Standard: diminish by 1 (or 2 for water/leaves)
diminish = 1
if neighbor_voxel in [WATER, LEAVES]:
diminish = 2
new_level = max(0, level - diminish)
# Update if brighter
if new_level > neighbor_light:
set_light_fast(neighbor_x, neighbor_y, neighbor_z, world_lightmaps, light_type, new_level)
queue.enqueue(neighbor_x, neighbor_y, neighbor_z, new_level)
Fast Voxel Access Helper:
def get_voxel_fast(world_x, world_y, world_z, world_voxels):
"""Get voxel ID with chunk boundary handling"""
chunk_x = world_x // CHUNK_SIZE
chunk_y = world_y // CHUNK_SIZE
chunk_z = world_z // CHUNK_SIZE
# Check if chunk loaded
key = (chunk_x, chunk_y, chunk_z)
if key not in world_voxels:
return STONE # Unloaded = solid
# Get local index
local_x = world_x % CHUNK_SIZE
local_y = world_y % CHUNK_SIZE
local_z = world_z % CHUNK_SIZE
local_index = local_x + local_z * CHUNK_SIZE + local_y * CHUNK_SIZE ** 2
return world_voxels[key][local_index]
Blocklight (Torch) Propagation
Torch Placement:
def place_torch(world_x, world_y, world_z, world_lightmaps, queue):
"""Place torch and start light propagation"""
set_light_fast(world_x, world_y, world_z, world_lightmaps, BLOCKLIGHT, 14)
queue.enqueue(world_x, world_y, world_z, 14) # Torch emits level 14
# Start BFS propagation
propagate_light_bfs(world_voxels, world_lightmaps, chunk_positions, queue, BLOCKLIGHT)
Breaking a Torch (Removal Pass + Refill):
def remove_light_source(broken_x, broken_y, broken_z, world_voxels, world_lightmaps, queue):
"""Remove light from broken torch"""
# Step 1: REMOVAL PASS - drain dependent light
removal_queue = LightQueue()
removal_queue.enqueue(broken_x, broken_y, broken_z, 14)
while True:
node = removal_queue.dequeue()
if node is None:
break
x, y, z, level = node
# Check all 6 neighbors
for dx, dy, dz in [(-1,0,0), (1,0,0), (0,-1,0), (0,1,0), (0,0,-1), (0,0,1)]:
neighbor_x, neighbor_y, neighbor_z = x + dx, y + dy, z + dz
neighbor_voxel = get_voxel_fast(neighbor_x, neighbor_y, neighbor_z, world_voxels)
if not is_transparent(neighbor_voxel):
continue
neighbor_light = get_light_fast(neighbor_x, neighbor_y, neighbor_z, world_lightmaps, BLOCKLIGHT)
# If neighbor was lit by broken torch, reduce its light
if neighbor_light > 0 and neighbor_light < level:
# This light was dependent on broken torch
set_light_fast(neighbor_x, neighbor_y, neighbor_z, world_lightmaps, BLOCKLIGHT, 0)
removal_queue.enqueue(neighbor_x, neighbor_y, neighbor_z, neighbor_light)
elif neighbor_light >= level:
# This light is from another source (brighter neighbor)
# Capture for refill pass
refill_queue.enqueue(neighbor_x, neighbor_y, neighbor_z, neighbor_light)
# Step 2: REFILL PASS - propagate light from surviving sources
propagate_light_bfs(world_voxels, world_lightmaps, chunk_positions, refill_queue, BLOCKLIGHT)
Chunk Boundary Stitching
When chunks load/unload, light must be synchronized across boundaries:
def stitch_chunk_lighting(chunk_x, chunk_y, chunk_z, world_voxels, world_lightmaps):
"""Ensure light propagates correctly across chunk edges"""
# Check 6 neighbors of chunk
neighbors = [
(chunk_x - 1, chunk_y, chunk_z),
(chunk_x + 1, chunk_y, chunk_z),
(chunk_x, chunk_y - 1, chunk_z),
(chunk_x, chunk_y + 1, chunk_z),
(chunk_x, chunk_y, chunk_z - 1),
(chunk_x, chunk_y, chunk_z + 1),
]
light_queue = LightQueue()
for neighbor_chunk in neighbors:
if neighbor_chunk not in world_lightmaps:
continue
# For each edge voxel of current chunk
# Check if neighbor has brighter light
# If so, start propagation from boundary
# Example: check +X boundary
if neighbor_chunk[0] == chunk_x + 1:
for y in range(CHUNK_SIZE):
for z in range(CHUNK_SIZE):
# Current chunk edge (x = 47)
current_light = get_light(chunk_x, 47, y, z, world_lightmaps, SUNLIGHT)
# Neighbor edge (x = 0 in neighbor chunk)
neighbor_light = get_light(neighbor_chunk[0], 0, y, z, world_lightmaps, SUNLIGHT)
if neighbor_light > current_light:
# Neighbor is brighter, propagate inward
light_queue.enqueue(chunk_x * 48 + 47, chunk_y * 48 + y, chunk_z * 48 + z, neighbor_light)
# Run BFS from boundaries
if light_queue.head != light_queue.tail:
propagate_light_bfs(world_voxels, world_lightmaps, chunk_positions, light_queue, SUNLIGHT)
Integration with Rendering
Shader Light Application:
// In chunk.frag
in float sun_light; // Interpolated sunlight (0.0-1.0)
in float block_light; // Interpolated blocklight (0.0-1.0)
void main() {
// Sample base color
vec3 color = texture(u_texture_array, vec3(uv, texLayer)).rgb;
// Apply day/night cycle to sunlight
float day_night = 0.5 + 0.5 * u_sun_direction.y; // -1 (night) to 1 (day)
float adjusted_sun = sun_light * day_night;
float adjusted_block = block_light;
// Combine lights (take maximum for natural look)
float final_light = max(adjusted_sun, adjusted_block);
// Apply to final color
vec3 lit_color = color * final_light;
fragColor = vec4(lit_color, 1.0);
}
Replication Checklist
✓ Implement lightmap packing/unpacking
✓ Create sunlight initialization (downward raycast)
✓ Implement BFS light propagation with queue
✓ Add blocklight support (torches)
✓ Implement removal pass + refill logic
✓ Add chunk boundary stitching
✓ Integrate with mesh building (per-vertex light sampling)
✓ Connect to shader (lighting application)
Performance Considerations
Queue size: 200K nodes typical max (prevents explosion of propagation)
Diminishment: Constants prevent infinite propagation
Optimization: O(1) downward raycasts for common case
Threading: Entire BFS can run on background thread (no GIL issues with Numba)