Skip to main content
MC-CPP uses a subchunk-based meshing system that subdivides each chunk into smaller units for efficient parallel mesh generation with advanced lighting effects.

Subchunk Architecture

Subchunks are defined in src/chunk/subchunk.h:12-14:
const int SUBCHUNK_WIDTH = 16;
const int SUBCHUNK_HEIGHT = 16;
const int SUBCHUNK_LENGTH = 16;
Each 16×128×16 chunk contains 8 subchunks arranged vertically (1×8×1), where each subchunk manages 4,096 blocks (16³).

Subchunk Class

The Subchunk class (src/chunk/subchunk.h:16-41) handles mesh generation for its region:
class Subchunk {
public:
    Chunk* parent;
    World* world;
    glm::ivec3 subchunk_position;    // Position in subchunk grid (0-0, 0-7, 0-0)
    glm::ivec3 local_position;       // Position in chunk-local coords (0-15)
    glm::vec3 position;              // Absolute world position

    std::vector<uint32_t> mesh;
    std::vector<uint32_t> translucent_mesh;

    void update_mesh();
private:
    // Lighting helpers
    float smooth(float a, float b, float c, float d);
    float ao_val(bool s1, bool s2, bool c);
    std::array<float, 4> get_face_ao(...);
    std::array<float, 4> get_smooth_face_light(...);
    std::array<glm::ivec3, 8> get_neighbour_voxels(glm::ivec3 npos, int face);
    
    // Per-face calculations
    std::array<float, 4> get_light(int block, int face, glm::ivec3 pos, glm::ivec3 npos);
    std::array<float, 4> get_skylight(int block, int face, glm::ivec3 pos, glm::ivec3 npos);
    std::array<float, 4> get_shading(int block, BlockType& bt, int face, glm::ivec3 npos);
    
    void add_face(int face, glm::ivec3 pos, glm::ivec3 lpos, int block, BlockType& bt, glm::ivec3 npos);
    bool can_render_face(BlockType& bt, int block_number, glm::ivec3 position);
};

Mesh Generation Process

Main Update Loop

The update_mesh() method (src/chunk/subchunk.cpp:199-225) iterates through all blocks:
void Subchunk::update_mesh() {
    mesh.clear();
    translucent_mesh.clear();
    
    for (int x=0; x<SUBCHUNK_WIDTH; x++)
        for (int y=0; y<SUBCHUNK_HEIGHT; y++)
            for (int z=0; z<SUBCHUNK_LENGTH; z++) {
                int lx = local_position.x + x;
                int ly = local_position.y + y;
                int lz = local_position.z + z;
                int bn = parent->blocks[lx][ly][lz];
                if (!bn) continue;  // Skip air
                
                BlockType& bt = *world->block_types[bn];
                glm::ivec3 pos = glm::ivec3(position) + glm::ivec3(x, y, z);
                glm::ivec3 lpos(lx, ly, lz);

                if (bt.is_cube) {
                    // Standard 6-face cube meshing
                    for(int f=0; f<6; f++) {
                        glm::ivec3 npos = pos + Util::DIRECTIONS[f];
                        if (can_render_face(bt, bn, npos)) 
                            add_face(f, pos, lpos, bn, bt, npos);
                    }
                } else {
                    // Non-cube models (plants, torches, etc.) - render all faces
                    for(int f=0; f<bt.vertex_positions.size(); f++) {
                        add_face(f, pos, lpos, bn, bt, pos);
                    }
                }
            }
}

Face Culling

The can_render_face() method (src/chunk/subchunk.cpp:186-197) implements intelligent culling:
bool Subchunk::can_render_face(BlockType& bt, int block_number, glm::ivec3 position) {
    int neighbor_id = parent->get_block_number_cached(position);

    if (neighbor_id == 0) return true;  // Air - always render

    // Glass optimization - don't render faces between same glass blocks
    if (bt.glass && neighbor_id == block_number) return false;

    // Render if neighbor is transparent
    BlockType* neighbor_type = world->block_types[neighbor_id];
    if (neighbor_type && neighbor_type->transparent) return true;

    return false;  // Opaque neighbor - cull face
}

Vertex Data Packing

Each vertex is packed into 3 × uint32_t (12 bytes total) for GPU efficiency.

Packing Functions

From src/chunk/subchunk.cpp:9-40:
// Position: 16-bit fixed-point (1/16 precision)
inline int16_t pack_pos_component(float v) {
    int val = static_cast<int>(std::round(v * 16.0f));
    return static_cast<int16_t>(std::clamp(val, -32768, 32767));
}

// UV: 8-bit normalized (0.0-1.0)
inline uint8_t pack_uv(float v) {
    int val = static_cast<int>(std::round(v * 255.0f));
    return static_cast<uint8_t>(std::clamp(val, 0, 255));
}

// Pack into 32-bit words
inline uint32_t pack_pos_xy(int16_t x, int16_t y) {
    return static_cast<uint16_t>(x) | 
           (static_cast<uint32_t>(static_cast<uint16_t>(y)) << 16);
}

inline uint32_t pack_pos_z_uv(int16_t z, uint8_t u, uint8_t v) {
    return static_cast<uint16_t>(z) | 
           (static_cast<uint32_t>(u) << 16) | 
           (static_cast<uint32_t>(v) << 24);
}

inline 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);
}

Vertex Layout

WordBits 0-15Bits 16-23Bits 24-31
0X position (int16)Y position (int16)-
1Z position (int16)U coord (uint8)V coord (uint8)
2Texture layer (uint8)Shading (uint8)Block light (4-bit) + Sky light (4-bit)

Lighting System

Smooth Lighting

When Options::SMOOTH_LIGHTING is enabled, vertex lighting is interpolated from neighboring blocks. The get_smooth_face_light() method (src/chunk/subchunk.cpp:75-77) averages 4 adjacent samples:
std::array<float, 4> Subchunk::get_smooth_face_light(
    float light, float l1, float l2, float l3, 
    float l4, float l5, float l6, float l7, float l8) {
    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() helper (src/chunk/subchunk.cpp:47-64) handles edge cases where lighting transitions to darkness:
float Subchunk::smooth(float a, float b, float c, float d) {
    if (a == 0 || b == 0 || c == 0 || d == 0) {
        float min_val = a;
        if (a > 0) {
            if (b > 0) min_val = std::min(min_val, b);
            if (c > 0) min_val = std::min(min_val, c);
            if (d > 0) min_val = std::min(min_val, d);
        } else {
            min_val = 0.0f;
        }
        a = std::max(a, min_val);
        b = std::max(b, min_val);
        c = std::max(c, min_val);
        d = std::max(d, min_val);
    }
    return (a + b + c + d) / 4.0f;
}

Ambient Occlusion

AO is calculated from the 8 blocks surrounding each vertex (src/chunk/subchunk.cpp:66-73):
float Subchunk::ao_val(bool s1, bool s2, bool c) {
    if (s1 && s2) return 0.25f;  // Both sides blocked - darkest
    return 1.0f - (s1 + s2 + c) / 4.0f;  // Partial occlusion
}

std::array<float, 4> Subchunk::get_face_ao(
    bool s1, bool s2, bool s3, bool s4, 
    bool s5, bool s6, bool s7, bool s8) {
    return {
        ao_val(s2, s4, s1),
        ao_val(s4, s7, s6),
        ao_val(s5, s7, s8),
        ao_val(s2, s5, s3)
    };
}

Neighbor Sampling Pattern

For each face, 8 neighbors are sampled in a specific pattern (src/chunk/subchunk.cpp:79-89):
std::array<glm::ivec3, 8> Subchunk::get_neighbour_voxels(glm::ivec3 npos, int face) {
    using namespace Util;
    std::array<glm::ivec3, 8> n{};
    
    if(face==0) // East (+X)
        n = {npos+UP+SOUTH, npos+UP, npos+UP+NORTH, 
             npos+SOUTH, npos+NORTH, 
             npos+DOWN+SOUTH, npos+DOWN, npos+DOWN+NORTH};
    else if(face==1) // West (-X)
        n = {npos+UP+NORTH, npos+UP, npos+UP+SOUTH, 
             npos+NORTH, npos+SOUTH, 
             npos+DOWN+NORTH, npos+DOWN, npos+DOWN+SOUTH};
    // ... similar for faces 2-5
    
    return n;
}
This pattern ensures consistent vertex ordering for correct AO/lighting interpolation.

Face Addition

The add_face() method (src/chunk/subchunk.cpp:152-184) assembles all vertex data:
void Subchunk::add_face(int face, glm::ivec3 pos, glm::ivec3 lpos, 
                       int block, BlockType& bt, glm::ivec3 npos) {
    auto& target = bt.translucent ? translucent_mesh : mesh;
    auto shading = get_shading(block, bt, face, npos);
    auto lights = get_light(block, face, pos, npos);
    auto skylights = get_skylight(block, face, pos, npos);

    bool has_uv = (face < bt.tex_coords.size());

    for(int i=0; i<4; i++) {  // 4 vertices per quad
        float vx = bt.vertex_positions[face][i*3+0] + lpos.x;
        float vy = bt.vertex_positions[face][i*3+1] + lpos.y;
        float vz = bt.vertex_positions[face][i*3+2] + lpos.z;

        uint8_t u = 0, v = 0;
        if (has_uv) {
            u = pack_uv(bt.tex_coords[face][i*2+0]);
            v = pack_uv(bt.tex_coords[face][i*2+1]);
        }

        uint8_t layer = static_cast<uint8_t>(bt.tex_indices[face]);
        uint8_t shade = pack_shading(shading[i]);
        uint8_t bl = static_cast<uint8_t>(std::clamp<int>(lights[i], 0, 15));
        uint8_t sl = static_cast<uint8_t>(std::clamp<int>(skylights[i], 0, 15));

        int16_t px = pack_pos_component(vx);
        int16_t py = pack_pos_component(vy);
        int16_t pz = pack_pos_component(vz);

        target.push_back(pack_pos_xy(px, py));
        target.push_back(pack_pos_z_uv(pz, u, v));
        target.push_back(pack_attr(layer, shade, bl, sl));
    }
}

Performance Optimizations

  • Parallel meshing - Multiple subchunks can mesh simultaneously
  • Incremental updates - Only affected subchunks remesh when blocks change
  • Memory locality - Better cache performance during iteration
  • Early culling - Empty subchunks skip meshing entirely
  • Reduced bandwidth - 12 bytes/vertex vs 48+ bytes for floats
  • Better cache utilization - More vertices fit in GPU cache
  • Smaller VBOs - Typical chunk uses ~100KB vs ~400KB unpacked
  • Fixed-point precision - Sufficient for block-based geometry
Smooth lighting queries 9 blocks per vertex (1 center + 8 neighbors):
  • Without smooth lighting: ~1 query per vertex
  • With smooth lighting: ~9 queries per vertex
  • Chunk neighbor caching makes these queries nearly free

Debug Tips

If you see missing faces at chunk boundaries, ensure update_at_position() queues neighbor subchunks when blocks change on borders.
To visualize subchunk boundaries, modify add_face() to color-code vertices based on subchunk_position.

Chunk Architecture

Learn about chunk storage and neighbor caching

Block Types

Understand vertex positions and texture coordinates

Build docs developers (and LLMs) love