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