Skip to main content
MC-CPP implements a dual-source colored lighting system with block light (artificial) and skylight (natural), combined with smooth lighting and ambient occlusion for realistic shading.

Lighting Model Overview

Two independent light sources combine additively:
  1. Block Light: Warm orange light from torches, lava, glowstone, etc.
  2. Sky Light: Cool blue-white light from the sun, varies with day/night cycle
Both use 16 discrete light levels (0-15) stored per-block and propagated through the world using flood-fill algorithms.

Block Light (Artificial)

Light Sources

Blocks that emit light are registered in World::light_blocks:
std::unordered_set<int> light_blocks = {10, 11, 50, 51, 62, 75};
// Lava, Fire, Torch, Redstone Torch, etc.
When a light-emitting block is placed:
if (is_source) {
    increase_light(pos, 15);  // Full brightness
}

Propagation Algorithm

Block light uses a flood-fill queue system (src/world.cpp:181-202):
void World::propagate_increase(bool update, int max_steps) {
    while(!light_increase_queue.empty() && steps_left-- > 0) {
        auto [pos, level] = light_increase_queue.front();
        light_increase_queue.pop_front();
        
        for(auto& d : Util::DIRECTIONS) {  // 6 directions
            glm::ivec3 n = pos + d;
            int l = get_light(n);
            
            // Propagate if neighbor is darker
            if(!is_opaque_block(n) && l + 2 <= level) {
                chunks[cp]->set_block_light(get_local_pos(n), level - 1);
                light_increase_queue.push_back({n, level - 1});
            }
        }
    }
}
Key properties:
  • Light decreases by 1 per block traveled
  • Opaque blocks stop propagation
  • Transparent blocks (glass, water) allow light through
  • Updates are throttled to Options::LIGHT_STEPS_PER_TICK (default: 2048)

Light Removal

When a light source is removed (src/world.cpp:203-222):
  1. Decrease phase: Remove light that was from this source
  2. Increase phase: Re-propagate light from remaining sources
void World::decrease_light(glm::ivec3 pos) {
    int old = get_light(pos);
    set_block_light(pos, 0);
    light_decrease_queue.push_back({pos, old});
    propagate_decrease(true);
    propagate_increase(true);  // Re-fill gaps
}

Color Tint

Block light has a warm orange tint defined in the vertex shader:
vec3 blockTint = vec3(1.10, 0.85, 0.65);  // Orange-yellow
float blockIntensity = pow(blockLevel, 1.1);
vec3 blockLight = blockTint * blockIntensity;
The power curve (pow(blockLevel, 1.1)) makes light falloff more gradual.

Sky Light (Natural)

Initialization

When a chunk is created, skylight is initialized top-down (src/world.cpp:224-277):
void World::init_skylight(Chunk* c) {
    // 1. Vertical pass: Fill sunlight down to first opaque block
    for(int x=0; x<CHUNK_WIDTH; x++) {
        for(int z=0; z<CHUNK_LENGTH; z++) {
            int height = -1;
            // Find top opaque block
            for(int y = CHUNK_HEIGHT - 1; y >= 0; y--) {
                if(is_opaque_block({x, y, z})) {
                    height = y;
                    break;
                }
            }
            // Fill sun above
            for(int y = CHUNK_HEIGHT - 1; y > height; y--) {
                c->set_sky_light({x, y, z}, 15);
            }
            // Fill darkness below
            for(int y = height; y >= 0; y--) {
                c->set_sky_light({x, y, z}, 0);
            }
        }
    }
    
    // 2. Border pass: Stitch with neighboring chunks
    // ... (see src/world.cpp:251-276)
}

Propagation Rules

Skylight propagates differently than block light (src/world.cpp:283-309):
  • Downward: No decay (special case for sunlight streaming down)
  • Horizontal: Decay by 1
  • Glass blocks: Treated specially to allow light through
int decay = 1;
if (d.y == -1) {  // Downward direction
    decay = 0;
    if (block_id != 0 && !block_types[block_id]->glass) decay = 1;
}
This creates realistic sunlight shafts through open areas.

Day/Night Cycle

Skylight color varies with time (colored_lighting/vert.glsl:49-51):
vec3 skyNight = vec3(0.25, 0.35, 0.55);   // Deep blue
vec3 skyDay = vec3(0.85, 0.95, 1.05);     // Bright blue-white
vec3 skyTint = mix(skyNight, skyDay, clamp(u_Daylight, 0.0, 1.0));
Daylight factor is computed from world time:
float World::get_daylight_factor() const {
    return std::clamp(daylight / 1800.0f, 0.0f, 1.0f);
}
The daylight value oscillates smoothly between 480 (night) and 1800 (day) using a sinusoidal curve.

Combining Lights

In the vertex shader (colored_lighting/vert.glsl:44-60):
// Decode light levels (4-bit values from packed vertex data)
float blockLevel = a_Light / 15.0;
float skyLevel = a_Skylight / 15.0;

// Apply intensity curves
float blockIntensity = pow(blockLevel, 1.1);
float skyIntensity = pow(skyLevel * u_Daylight, 1.2);

// Combine additively
vec3 light = blockTint * blockIntensity + skyTint * skyIntensity;

// Clamp to valid range
light = max(light, vec3(0.02));  // Minimum ambient
light = min(light, vec3(1.0));   // Maximum brightness

// Apply face shading (AO/directional shading)
v_Light = light * shading;
Key points:
  • Lights add together (not multiply)
  • Different power curves for block vs sky (1.1 vs 1.2)
  • Minimum ambient prevents pure black
  • Skylight intensity is modulated by day/night factor

Smooth Lighting

When Options::SMOOTH_LIGHTING = true, per-vertex lighting is computed by averaging neighboring blocks.

Per-Vertex Light Calculation

For each face vertex, sample 4 neighboring blocks (src/chunk/subchunk.cpp:75-77):
std::array<float, 4> Subchunk::get_smooth_face_light(
    float light,      // Center light
    float l1, float l2, float l3, float l4,  // Edge neighbors
    float l5, float l6, float l7, float l8   // Corner neighbors
) {
    // Each vertex averages center + 2 edges + 1 corner
    return {
        smooth(light, l2, l4, l1),  // Vertex 0
        smooth(light, l4, l7, l6),  // Vertex 1
        smooth(light, l5, l7, l8),  // Vertex 2
        smooth(light, l2, l5, l3)   // Vertex 3
    };
}
The smooth() function averages 4 values with special handling for zero (unlit) values:
float Subchunk::smooth(float a, float b, float c, float d) {
    if (a == 0 || b == 0 || c == 0 || d == 0) {
        // Use minimum non-zero value to prevent harsh transitions
        float min_val = /* find min non-zero */;
        a = std::max(a, min_val);
        // ... (clamp others)
    }
    return (a + b + c + d) / 4.0f;
}
This creates smooth gradients across block faces.

Ambient Occlusion

AO darkens vertices based on surrounding solid blocks.

AO Calculation

For each vertex, sample 8 neighboring voxels (src/chunk/subchunk.cpp:66-73):
float Subchunk::ao_val(bool s1, bool s2, bool c) {
    if (s1 && s2) return 0.25f;  // Two sides blocked = darkest
    return 1.0f - (s1 + s2 + c) / 4.0f;
}

std::array<float, 4> Subchunk::get_face_ao(
    bool s1, bool s2, bool s3, bool s4,  // Sides
    bool s5, bool s6, bool s7, bool s8   // Corners
) {
    return {
        ao_val(s2, s4, s1),  // Vertex 0
        ao_val(s4, s7, s6),  // Vertex 1
        ao_val(s5, s7, s8),  // Vertex 2
        ao_val(s2, s5, s3)   // Vertex 3
    };
}
AO levels:
  • No blocking: 1.0 (full brightness)
  • One block: 0.75
  • Two blocks: 0.5
  • Two sides + corner: 0.25 (darkest)

Face Shading

AO values are packed into the shading component of vertex data and multiplied with lighting in the vertex shader:
v_Light = light * shading;
This creates contact shadows and depth perception.

Configuration Options

In src/options.h:
inline bool SMOOTH_LIGHTING = true;        // Enable per-vertex lighting
inline bool COLORED_LIGHTING = true;       // Use colored light model
inline int LIGHT_STEPS_PER_TICK = 2048;   // Light propagation throttle

Performance Tuning

LIGHT_STEPS_PER_TICK:
  • Higher values: Faster light updates, more CPU usage
  • Lower values: Slower light updates, less CPU usage
  • Default 2048 is a good balance for most scenarios
SMOOTH_LIGHTING:
  • Disabled: Faster meshing, blockier appearance
  • Enabled: Slower meshing, smoother gradients

Light Data Storage

Light values are stored per-block in chunks:
class Chunk {
    int blocks[16][128][16];      // Block IDs
    uint8_t block_light[16][128][16];  // 4-bit block light
    uint8_t sky_light[16][128][16];    // 4-bit sky light
    // ...
};
Each light value uses 4 bits (0-15), stored in uint8_t arrays.

Vertex Packing

Light values are packed into vertex data (src/chunk/subchunk.cpp:34-39):
uint32_t pack_attr(uint8_t layer, uint8_t shading, 
                   uint8_t blocklight, uint8_t skylight) {
    return static_cast<uint32_t>(layer) |
           (static_cast<uint32_t>(shading) << 8) |
           (static_cast<uint32_t>(blocklight & 0xF) << 16) |
           (static_cast<uint32_t>(skylight & 0xF) << 20);
}
This allows efficient GPU upload and shader access.

Debugging

Light Not Propagating

Check:
  1. Light propagation queue not full (LIGHT_STEPS_PER_TICK)
  2. Chunk is loaded and initialized
  3. Block is transparent (not opaque)

Dark Areas During Day

Check:
  1. Skylight initialized correctly (init_skylight called)
  2. Neighboring chunks loaded (for border stitching)
  3. Day/night cycle not at minimum (daylight value)

Sudden Light Changes

Cause: Light propagation is throttled and may take multiple ticks. Solution: Increase LIGHT_STEPS_PER_TICK for faster propagation (at cost of CPU).

Build docs developers (and LLMs) love