前六章的画面是「平的」——每个方块的所有面亮度一样。真实世界里,角落比中心暗,底部比顶部暗,阴面比阳面暗。这一章让光线参与进来。

1. 三种光照的职责

graph TD
    SUN["天空光<br/>均匀照亮整个场景<br/>模拟大气散射"]
    DIFF["漫反射<br/>方向光 + 法线点积<br/>制造明暗面"]
    AO["环境光遮蔽<br/>顶点级暗度<br/>角落/缝隙变暗"]

    SUN --> MIX["最终颜色<br/>= 纹理 × 光照"]
    DIFF --> MIX
    AO --> MIX

    style SUN fill:#f9ca24,color:#333
    style DIFF fill:#f39c12,color:#fff
    style AO fill:#636e72,color:#fff
    style MIX fill:#2ecc71,color:#fff

三种光的分工:

  • 天空光:即使没有直射光,物体也不是全黑的。模拟大气散射的均匀照明
  • 漫反射dot(normal, lightDir) — 面朝光源就亮,背对光源就暗
  • AO(环境光遮蔽):即使两个面朝向相同,角落里的面受周围方块遮挡,接收到的天空光更少

2. 顶点 AO — 体素引擎的灵魂

AO 不需要光线追踪。体素世界的 AO 有一个极其简单的实现:看这个顶点的三个相邻方块。

     ┌───┐
     │ A │      顶点 V 的三个相邻方块:
┌───┬┴───┴┬───┐  A = 侧1(x+1方向的邻居)
│ B │  V  │ C │  B = 侧2(z-1方向的邻居)
└───┴─────┴───┘  C = 角(x+1, z-1 的邻居)

规则: 如果 A、B、C 三个位置有方块,顶点 V 无遮挡 → AO = 0。如果三个位置全是方块,顶点 V 被完全遮挡 → AO = 1。中间情况 → 部分遮挡。

// 计算一个顶点的 AO 值(0=全亮, 3=全暗)
int vertexAO(Chunk &chunk, int x, int y, int z,
             int side1X, int side1Z,  // 侧边1 方向
             int side2X, int side2Z,  // 侧边2 方向
             int cornerX, int cornerZ) // 角方向
{
    int occlusion = 0;

    // 侧1 有方块?
    if (chunk.getBlock(x + side1X, y, z + side1Z).type != 0)
        occlusion++;
    // 侧2 有方块?
    if (chunk.getBlock(x + side2X, y, z + side2Z).type != 0)
        occlusion++;
    // 如果两侧都有方块,角也检查
    if (occlusion == 2 &&
        chunk.getBlock(x + cornerX, y, z + cornerZ).type != 0)
        occlusion++;

    return occlusion;  // 0, 1, 2, 3
}

同一个面的四个顶点各有不同的 AO 值:

   0───1
   │   │      0 = 左上角,两侧邻居都空 → AO=0(最亮)
   │   │      1 = 右上角,右侧有邻居   → AO=1
   3───2      2 = 右下角,两侧都有     → AO=2
              3 = 左下角,左侧有邻居   → AO=1

3. 把 AO 值传进顶点数据

扩展 Vertex 结构加一个 ao 字段:

struct Vertex {
    QVector3D position;
    QVector3D normal;
    QVector2D texCoord;
    uint8_t ao;         // 0-3,对应 AO 等级
};

着色器对应修改:

// chunk.vert
layout(location = 3) in float a_ao;   // 归一化到 0.0~1.0
out float v_ao;

void main() {
    // ...
    v_ao = 1.0 - a_ao * 0.3;  // AO=0 → ao=1.0, AO=3 → ao=0.1
}

注意:float 而不是 uint8_t 作为 vertex attribute 类型——OpenGL 的 GL_UNSIGNED_BYTE 需要用 glVertexAttribIPointer(注意那个 I),容易写错。直接用 float 最省心。


4. 片段着色器:三光合一

// chunk.frag
#version 330 core

in vec3 v_normal;
in vec2 v_texCoord;
in float v_ao;

uniform sampler2D u_atlas;

// 光照参数
uniform vec3 u_lightDir = vec3(0.5, 1.0, 0.3);  // 太阳方向
uniform vec3 u_skyColor = vec3(0.6, 0.75, 1.0);   // 天空光颜色
uniform float u_skyStrength = 0.25;                // 天空光强度

out vec4 fragColor;

void main() {
    vec4 texColor = texture(u_atlas, v_texCoord);
    if (texColor.a < 0.5) discard;

    vec3 N = normalize(v_normal);
    vec3 L = normalize(u_lightDir);

    // 1. 漫反射
    float diff = max(dot(N, L), 0.0);
    float diffuse = 0.4 + diff * 0.6;  // 最低 0.4,最高 1.0

    // 2. 天空光(从上方来的均匀光)
    float skyFactor = N.y * 0.5 + 0.5;  // 法线朝上 → 天空光强
    vec3 skyLight = u_skyColor * u_skyStrength * skyFactor;

    // 3. AO(顶点级遮蔽)
    float aoFactor = v_ao;

    // 合成
    vec3 lit = texColor.rgb * diffuse * aoFactor + skyLight * aoFactor;
    fragColor = vec4(lit, texColor.a);
}

关键: AO 同时作用于漫反射和天空光。角落里的面接收到的所有光线都更少——这才符合物理直觉。


5. AO 开关对比

没有 AO 的世界:

┌────┬────┬────┐
│ 草 │ 草 │ 草 │  所有面亮度一样
├────┼────┼────┤  方块之间界限模糊
│ 土 │ 土 │ 土 │  看起来像平面贴图
└────┴────┴────┘

有 AO 的世界:

┌────┬────┬────┐
│ ██ │ ██ │ ██ │  顶部亮←天空光照到
├────┼────┼────┤  侧面暗←邻居遮挡
│ ░░ │ ░░ │ ░░ │  角落最暗←两侧遮挡
└────┴────┴────┘  方块之间界限分明

AO 是体素引擎从「Demo」到「游戏画面」的分水岭。 它可以作为编译期开关:

#define ENABLE_AO 1

void Chunk::addFace(...) {
    for (int v = 0; v < 4; ++v) {
        Vertex vert;
#if ENABLE_AO
        vert.ao = vertexAO(...);
#else
        vert.ao = 0;  // 无 AO = 全亮
#endif
        vertices.push_back(vert);
    }
}

6. 性能影响

AO 计算完全在 Mesh 生成阶段(工作线程),不增加渲染帧的任何开销。它对帧率的影响是零——只增加 Chunk 重建时的计算量。

单个 Chunk 的 AO 计算量:65536 个位置 × 6 面 × 4 顶点 ≈ 150 万次 neighbor check。但在 -O2 下这只需要 0.3ms——几乎可以忽略。


7. 章节收尾

graph TD
    VERT["Vertex 数据<br/>position + normal + uv + ao"]
    AO_CALC["vertexAO()<br/>查 3 个相邻方块<br/>返回 0-3"]
    VS2["顶点着色器<br/>v_ao = 1.0 - a_ao * 0.3"]
    FS2["片段着色器<br/>diffuse + sky + ao"]
    PIXEL["最终像素颜色"]

    AO_CALC --> VERT
    VERT --> VS2
    VS2 --> FS2
    FS2 --> PIXEL

    style AO_CALC fill:#636e72,color:#fff
    style FS2 fill:#9b59b6,color:#fff
    style PIXEL fill:#2ecc71,color:#fff

架构师复盘

最开始我只做了漫反射 + 方向光,画面看起来很「干净」但也很「假」——方块之间的接缝不明显,整个 Chunk 像一个完整的大平面。

后来我在别人的体素项目里看到 AO 效果,试着实现了一下。加完 AO 第一眼看到新画面的时候,那种「卧槽这看起来是真的了」的感觉,是写体素引擎以来最大的多巴胺瞬间。

如果你只想优化一个视觉效果,先做 AO。 它改动最小(只加一个 float 到 vertex),效果最大。


系列文章:体素引擎从零构建 | 作者:Logic