第二十章:法线贴图与高级渲染
嘿,年轻的渲染工程师!欢迎来到本章。如果说前面的章节是在学习如何用"积木"搭房子,那这一章我们要学习的是:如何让你的房子看起来像是用真正的石头、木头雕刻出来的,以及如何让画面拥有电影般的质感。
准备好了吗?让我们开始吧!
目录
- 什么是法线贴图?(不是凹凸贴图!)
- 切线空间:为什么我们需要它
- TBN 矩阵:Tangent、Bitangent、Normal
- 生成切线与副切线向量
- Shader 中的法线映射
- 帧缓冲对象(FBO):渲染到纹理
- 多通道渲染
- 高斯模糊后处理
- 泛光(Bloom)效果:亮部发光
- 色调映射(Tone Mapping)
- 烟雾模拟:乒乓 FBO
- 常见问题 Q&A
1. 什么是法线贴图?(不是凹凸贴图!)
生活中的类比:浮雕 vs. 石膏拓印
想象你面前有两块"石头":
- 第一块是真正的石头,表面坑坑洼洼,有数百万个微小的凹凸起伏。要做出这样的效果,你需要真的雕刻出每一个小坑——这在 3D 建模中意味着几百万个三角形。你的显卡会哭出来。
- 第二块是一块完全平滑的石头,上面贴了一张特殊的"贴纸"。这张贴纸不记录颜色,而是记录每个点的"朝向"——哪里该凸起、哪里该凹陷。你的显卡只需要渲染一个平滑的平面,但光照计算时会假装这个表面是凹凸不平的。
法线贴图(Normal Map)就是那张特殊的"贴纸"。
本质是什么?
法线贴图本质上是一张RGB 纹理,但它的三个通道(R、G、B)不表示颜色,而是表示法线向量的三个分量(X、Y、Z):
R 通道 = 法线的 X 分量
G 通道 = 法线的 Y 分量
B 通道 = 法线的 Z 分量
由于纹理像素值的范围是 [0, 1],而法线分量的范围是 [-1, 1],所以需要做映射:
法线分量 = 纹理值 * 2.0 - 1.0
反过来说,把法线存进纹理:
纹理值 = (法线分量 + 1.0) * 0.5
法线贴图 vs. 凹凸贴图(Bump Map)
这是初学者最容易混淆的一对概念。让我用一张表格说清楚:
| 特性 | 凹凸贴图(Bump Map) | 法线贴图(Normal Map) |
|---|---|---|
| 存储内容 | 高度值(灰度图) | 法线方向(RGB 图) |
| 本质 | "这里比旁边高多少" | "这里的表面朝哪个方向" |
| 计算方式 | 运行时通过高度差近似计算法线 | 直接读取法线,无需近似 |
| 性能 | 需要额外的求导计算 | 直接查表,更快 |
| 精度 | 低(高度差近似) | 高(精确方向) |
| 灵活性 | 只能表达凹凸 | 可以表达任意表面朝向变化 |
一句话总结: 凹凸贴图存储的是"高度",法线贴图存储的是"方向"。法线贴图更直接、更精确、更快。
为什么法线贴图是蓝色的?
如果你在网上搜索法线贴图,会发现它们大多是蓝紫色的。为什么呢?
因为在切线空间(我们马上会讲)中,默认的表面法线指向 "外",也就是 (0, 0, 1)。映射到纹理值就是 (0.5, 0.5, 1.0)——半红、半绿、全蓝,看起来就是蓝紫色!
当表面有凹凸时,法线会偏离 (0, 0, 1),RGB 值就会变化,颜色就不再是纯蓝紫了。
代码示例:从纹理中读取法线
// 在 Fragment Shader 中
uniform sampler2D tNormal; // 法线贴图
in vec2 vUv; // UV 坐标
vec3 getNormalFromMap() {
// 从纹理中采样,范围 [0, 1]
vec3 normalRGB = texture(tNormal, vUv).rgb;
// 映射到 [-1, 1]
vec3 normal = normalRGB * 2.0 - 1.0;
// 归一化(保险起见)
return normalize(normal);
}
常见误区
误区 1:"法线贴图就是一张蓝色的图片,我可以用 Photoshop 随便画一张。"
错!虽然理论上可以手绘,但法线贴图的 RGB 三个通道有严格的数学含义。随便画的"蓝色图片"不会产生正确的光照效果。通常法线贴图是通过高模烘焙到低模,或者从高度图转换而来。
误区 2:"法线贴图改变了模型的几何形状。"
错!法线贴图只改变光照计算,不改变实际的几何。从边缘看,模型仍然是平的。这就是为什么法线贴图适合表达"微小表面细节"(如皮肤毛孔、砖缝),而不适合表达"大的形状变化"。
误区 3:"法线贴图和凹凸贴图是一回事。"
错!虽然它们的目的都是让平面看起来有凹凸感,但实现方式完全不同。凹凸贴图是"老技术",法线贴图是"现代标准"。
动手试一试
练习 1.1:打开
akira-graphics/normal-maps/cube.html,尝试把法线贴图换成一张纯蓝色的图片(RGB = 128, 128, 255)。观察光照效果——它应该看起来和没有法线贴图时几乎一样,因为(128, 128, 255)映射后就是(0, 0, 1),即默认法线。练习 1.2:把法线贴图的 R 通道调大(偏红),观察光照如何变化。想想为什么?
2. 切线空间:为什么我们需要它
生活中的类比:地图上的"北"
想象你站在地球的不同位置:
- 站在赤道上,"北"指向北极星的方向。
- 站在南极点,所有的方向都是"北"。
- 站在一座山的斜坡上,"北"仍然是地理北极,但对你脚下的局部表面来说,这个"北"可能根本不在你的"平面"上。
现在问题来了:法线贴图是在一张平面的 2D 纹理上定义的。纹理的 "上" 对应表面的哪个方向?纹理的 "右" 对应表面的哪个方向?
如果模型是静止的、平面的,这很简单。但如果模型在旋转、扭曲呢?
切线空间(Tangent Space)就是每个顶点自己的"局部坐标系",就像每个顶点都有自己的"小地图"。
本质是什么?
切线空间是一个定义在每个顶点上的局部坐标系,由三个互相垂直的向量组成:
- Tangent(切线,T):指向纹理 U 坐标增加的方向(纹理的"右")
- Bitangent(副切线,B):指向纹理 V 坐标增加的方向(纹理的"上")
- Normal(法线,N):垂直于表面的方向(表面的"外")
B (Bitangent, V方向)
^
|
| N (Normal, 表面外)
| /
| /
|/
+---------> T (Tangent, U方向)
为什么需要这个局部坐标系?
因为法线贴图中的法线值是相对于切线空间定义的。法线贴图里存储的 (0, 0, 1) 不是世界空间的 "朝上",而是 "沿着该顶点的法线方向"。
如果没有切线空间,当你把法线贴图贴到一个旋转的立方体上时,"上" 对立方体的每个面来说含义都不同。切线空间让每个面都有自己的 "上",法线贴图就能正确工作了。
为什么不用世界空间或模型空间?
| 空间 | 优点 | 缺点 |
|---|---|---|
| 世界空间 | 直观,法线直接可用 | 模型不能旋转、不能复用 |
| 模型空间 | 可以旋转 | 模型不能变形(如骨骼动画) |
| 切线空间 | 支持变形、旋转、复用 | 需要 TBN 矩阵转换 |
切线空间是唯一的"万能解"。同一张法线贴图可以贴到任何模型上,模型可以旋转、变形,法线始终正确。
常见误区
误区:"切线空间是全局的,所有顶点共享同一个。"
错!每个顶点都有自己的切线空间(虽然相邻顶点的切线空间通常很接近)。就像地球表面的每一点都有自己的"局部北"。
动手试一试
练习 2.1:想象一个立方体有 6 个面。每个面的纹理坐标(UV)都是从
(0,0)到(1,1)。请问:立方体正面的 Tangent 指向世界空间的哪个方向?右面呢?上面呢?答案:正面 Tangent 指向 +X,右面 Tangent 指向 +Z(或 -X,取决于 UV 布局),上面 Tangent 指向 +X。这说明每个面的切线空间都不同!
3. TBN 矩阵:Tangent、Bitangent、Normal
生活中的类比:翻译官
想象你有一封用法语写的信(法线贴图中的法线),需要读给一个只懂中文的人(世界空间的光照计算)听。你需要一个翻译官把法语翻译成中文。
TBN 矩阵就是那个翻译官。
它把"切线空间中的向量"翻译成"世界空间(或视图空间)中的向量"。
本质是什么?
TBN 矩阵是一个 3x3 的旋转矩阵,它的三列(或三行,取决于约定)就是 T、B、N 三个向量:
| Tx Bx Nx |
TBN = | Ty By Ny |
| Tz Bz Nz |
当我们把切线空间中的法线 n_tangent 乘以 TBN 矩阵:
n_world = TBN * n_tangent
就得到了世界空间中的法线。
数学推导:为什么 TBN 能"翻译"向量?
这是一个坐标系变换问题。让我一步一步推导:
步骤 1:理解坐标系
在切线空间中,基向量是:
e1 = (1, 0, 0)—— 沿 T 方向e2 = (0, 1, 0)—— 沿 B 方向e3 = (0, 0, 1)—— 沿 N 方向
在世界空间中,同样的三个方向变成了:
T = (Tx, Ty, Tz)B = (Bx, By, Bz)N = (Nx, Ny, Nz)
步骤 2:一个向量在不同坐标系中的表示
假设切线空间中有一个向量 v_tangent = (a, b, c)。它的意思是:
v = a * e1 + b * e2 + c * e3
在世界空间中,同样的向量应该是:
v = a * T + b * B + c * N
展开写:
vx = a * Tx + b * Bx + c * Nx
vy = a * Ty + b * By + c * Ny
vz = a * Tz + b * Bz + c * Nz
步骤 3:写成矩阵形式
| vx | | Tx Bx Nx | | a |
| vy | = | Ty By Ny | * | b |
| vz | | Tz Bz Nz | | c |
这就是 TBN 矩阵!
Shader 中的 TBN 矩阵
在顶点着色器中,我们构建 TBN 矩阵:
// 顶点着色器
in vec3 tang; // 切线(模型空间)
in vec3 bitang; // 副切线(模型空间)
in vec3 normal; // 法线(模型空间)
uniform mat3 normalMatrix; // 法线矩阵(模型空间 -> 视图空间)
out mat3 vTBN; // 输出到片段着色器
void main() {
// 把 T、B、N 从模型空间转换到视图空间
vec3 T = normalize(normalMatrix * tang);
vec3 B = normalize(normalMatrix * bitang);
vec3 N = normalize(normalMatrix * normal);
// 构建 TBN 矩阵
vTBN = mat3(T, B, N);
// ... 其他计算
}
在片段着色器中,我们用 TBN 转换法线:
// 片段着色器
in mat3 vTBN; // 从顶点着色器传入
uniform sampler2D tNormal;
in vec2 vUv;
vec3 getNormal() {
// 从法线贴图采样,范围 [0,1],映射到 [-1,1]
vec3 n = texture(tNormal, vUv).rgb * 2.0 - 1.0;
// 用 TBN 矩阵把法线从切线空间转换到视图空间
return normalize(vTBN * n);
}
常见误区
误区 1:"TBN 矩阵是正交矩阵,所以它的逆等于它的转置。"
理论上,如果 T、B、N 严格正交且都是单位向量,TBN 是正交矩阵。但在实际中,由于顶点插值、归一化等原因,TBN 可能不是严格正交的。不过对于法线映射来说,直接用
vTBN * n已经足够好了。
误区 2:"Bitangent 可以用 cross(Normal, Tangent) 计算,不需要存储。"
理论上可以,但要注意手性(handedness)问题。如果纹理坐标被翻转(比如镜像),cross 得到的 Bitangent 方向可能是反的。所以在生产环境中,通常存储 Tangent 的 "w" 分量来表示手性,或者干脆存储完整的 Bitangent。
动手试一试
练习 3.1:在
cube.html中,把vTBN * n改成n,也就是不用 TBN 转换。观察效果——法线贴图会完全错乱,因为法线值被错误地解释成了视图空间的向量。练习 3.2:尝试交换 TBN 矩阵中的 T 和 B(
mat3(B, T, N))。观察效果,理解为什么顺序很重要。
4. 生成切线与副切线向量
生活中的类比:沿着山路找方向
想象你站在一座山的某个位置。你知道"上山的方向"(法线,垂直于地面),但你还想知道:
- "沿着山路往东走"是什么方向?(Tangent)
- "沿着山路往北走"是什么方向?(Bitangent)
如果你有一张地图(UV 坐标),你就可以通过地图上的方向推断出实际的方向。
本质是什么?
给定一个三角形的三个顶点位置(P1, P2, P3)和对应的纹理坐标(UV1, UV2, UV3),我们需要求出 Tangent 和 Bitangent。
数学推导
步骤 1:定义边向量和 UV 差
E1 = P2 - P1 // 三角形的一条边
E2 = P3 - P1 // 三角形的另一条边
(ΔU1, ΔV1) = UV2 - UV1
(ΔU2, ΔV2) = UV3 - UV1
步骤 2:建立方程
Tangent 指向 U 增加的方向,Bitangent 指向 V 增加的方向。所以:
E1 = ΔU1 * T + ΔV1 * B
E2 = ΔU2 * T + ΔV2 * B
这是两个向量方程,每个方程有 3 个分量(x, y, z)。
步骤 3:求解 T 和 B
把方程展开(只看一个分量,比如 x):
E1x = ΔU1 * Tx + ΔV1 * Bx
E2x = ΔU2 * Tx + ΔV2 * Bx
这是一个二元一次方程组,用克莱默法则求解:
行列式 D = ΔU1 * ΔV2 - ΔU2 * ΔV1
Tx = (ΔV2 * E1x - ΔV1 * E2x) / D
Bx = (-ΔU2 * E1x + ΔU1 * E2x) / D
对 y 和 z 分量同理。
步骤 4:写成向量形式
f = 1.0 / (ΔU1 * ΔV2 - ΔU2 * ΔV1)
T = f * (ΔV2 * E1 - ΔV1 * E2)
B = f * (-ΔU2 * E1 + ΔU1 * E2)
最后别忘了归一化:
T = normalize(T)
B = normalize(B)
完整代码实现
以下是 cube.html 和 rock.html 中使用的 createTB 函数:
function createTB(geometry) {
const {position, index, uv} = geometry.attributes;
if(!uv) throw new Error('NO uv.');
// 计算单个三角形的 T 和 B
function getTBNTriangle(p1, p2, p3, uv1, uv2, uv3) {
// 边向量
const edge1 = new Vec3().sub(p2, p1);
const edge2 = new Vec3().sub(p3, p1);
// UV 差
const deltaUV1 = new Vec2().sub(uv2, uv1);
const deltaUV2 = new Vec2().sub(uv3, uv1);
const tang = new Vec3();
const bitang = new Vec3();
// f = 1 / (ΔU1 * ΔV2 - ΔU2 * ΔV1)
const f = 1.0 / (deltaUV1.x * deltaUV2.y - deltaUV2.x * deltaUV1.y);
// T = f * (ΔV2 * E1 - ΔV1 * E2)
tang.x = f * (deltaUV2.y * edge1.x - deltaUV1.y * edge2.x);
tang.y = f * (deltaUV2.y * edge1.y - deltaUV1.y * edge2.y);
tang.z = f * (deltaUV2.y * edge1.z - deltaUV1.y * edge2.z);
tang.normalize();
// B = f * (-ΔU2 * E1 + ΔU1 * E2)
bitang.x = f * (-deltaUV2.x * edge1.x + deltaUV1.x * edge2.x);
bitang.y = f * (-deltaUV2.x * edge1.y + deltaUV1.x * edge2.y);
bitang.z = f * (-deltaUV2.x * edge1.z + deltaUV1.x * edge2.z);
bitang.normalize();
return {tang, bitang};
}
// 为每个顶点分配 T 和 B
const len = position.data.length / position.size;
const tang = new Float32Array(len * 3);
const bitang = new Float32Array(len * 3);
// 遍历每个三角形
for(let i = 0; i < index.data.length; i += 3) {
const i1 = index.data[i];
const i2 = index.data[i + 1];
const i3 = index.data[i + 2];
// 获取三个顶点的位置和 UV
const p1 = [position.data[i1 * 3], position.data[i1 * 3 + 1], position.data[i1 * 3 + 2]];
const p2 = [position.data[i2 * 3], position.data[i2 * 3 + 1], position.data[i2 * 3 + 2]];
const p3 = [position.data[i3 * 3], position.data[i3 * 3 + 1], position.data[i3 * 3 + 2]];
const u1 = [uv.data[i1 * 2], uv.data[i1 * 2 + 1]];
const u2 = [uv.data[i2 * 2], uv.data[i2 * 2 + 1]];
const u3 = [uv.data[i3 * 2], uv.data[i3 * 2 + 1]];
// 计算这个三角形的 T 和 B
const {tang: t, bitang: b} = getTBNTriangle(p1, p2, p3, u1, u2, u3);
// 把同一个三角形的 T 和 B 赋给它的三个顶点
// 注意:一个顶点可能属于多个三角形,这里简单覆盖
// 更好的做法是做平均
tang.set(t, i1 * 3);
tang.set(t, i2 * 3);
tang.set(t, i3 * 3);
bitang.set(b, i1 * 3);
bitang.set(b, i2 * 3);
bitang.set(b, i3 * 3);
}
// 添加到几何体
geometry.addAttribute('tang', {data: tang, size: 3});
geometry.addAttribute('bitang', {data: bitang, size: 3});
return geometry;
}
关于顶点共享的说明
上面的代码有一个简化:如果一个顶点属于多个三角形,后面的三角形会覆盖前面的 T/B 值。更好的做法是对共享顶点的 T/B 做平均,这样可以得到更平滑的结果。
// 改进版:累加后再平均
const tangAccum = new Float32Array(len * 3);
const bitangAccum = new Float32Array(len * 3);
const count = new Float32Array(len); // 记录每个顶点被多少个三角形共享
for(let i = 0; i < index.data.length; i += 3) {
// ... 计算 t 和 b ...
// 累加
for(let j = 0; j < 3; j++) {
const idx = index.data[i + j];
tangAccum[idx * 3] += t[0];
tangAccum[idx * 3 + 1] += t[1];
tangAccum[idx * 3 + 2] += t[2];
bitangAccum[idx * 3] += b[0];
bitangAccum[idx * 3 + 1] += b[1];
bitangAccum[idx * 3 + 2] += b[2];
count[idx]++;
}
}
// 平均
for(let i = 0; i < len; i++) {
tangAccum[i * 3] /= count[i];
tangAccum[i * 3 + 1] /= count[i];
tangAccum[i * 3 + 2] /= count[i];
bitangAccum[i * 3] /= count[i];
bitangAccum[i * 3 + 1] /= count[i];
bitangAccum[i * 3 + 2] /= count[i];
}
常见误区
误区:"Tangent 和 Bitangent 必须严格垂直。"
实际上,由于纹理的拉伸(非均匀缩放),Tangent 和 Bitangent 可能不是严格垂直的。这就是为什么我们存储两者,而不是用 cross 计算其中一个。当然,在大多数标准情况下,它们近似垂直。
动手试一试
练习 4.1:在
cube.html中,打印出一个三角形的edge1、edge2、deltaUV1、deltaUV2,手动计算一次 T 和 B,验证代码结果。练习 4.2:如果把一个正方形的 UV 从
(0,0)-(1,1)改成(0,0)-(2,2)(纹理重复两次),Tangent 和 Bitangent 会变化吗?为什么?
5. Shader 中的法线映射
生活中的类比:给石膏像上色
想象你有一个光滑的石膏像(低模几何),现在你想让它看起来像是粗糙的石头。你可以:
- 真的去雕刻每一个细节(增加几何)—— 太费时间
- 给它涂上一层特殊的颜料,颜料里混入了微小的反光颗粒,让光线散射得像粗糙表面一样 —— 这就是法线映射
本质是什么?
法线映射的核心流程:
- 采样法线贴图:从纹理中获取切线空间的法线
- TBN 转换:把法线从切线空间转换到视图空间
- 光照计算:用转换后的法线进行标准的光照计算(Phong、Blinn-Phong 等)
完整 Shader 代码
以下是 rock.html 中的核心片段着色器:
#version 300 es
precision highp float;
in vec3 vNormal; // 视图空间的法线(不使用法线贴图时用)
in vec3 vPos; // 视图空间的位置
in vec2 vUv; // UV 坐标
in vec3 vCameraPos; // 视图空间的相机位置
in mat3 vTBN; // TBN 矩阵(切线空间 -> 视图空间)
uniform sampler2D tMap; // 颜色贴图
uniform sampler2D tNormal; // 法线贴图
uniform float uTime;
out vec4 FragColor;
// 从法线贴图获取视图空间的法线
vec3 getNormal() {
// 采样法线贴图,范围 [0,1] -> [-1,1]
vec3 n = texture(tNormal, vUv).rgb * 2.0 - 1.0;
// 有些法线贴图的强度不够,可以放大 xy 分量
n.xy *= 2.0;
// 用 TBN 矩阵转换到视图空间
return normalize(vTBN * n);
}
void main() {
// 视线方向
vec3 eyeDirection = normalize(vCameraPos - vPos);
// 获取法线(用法线贴图)
vec3 normal = getNormal();
// 也可以试试不用法线贴图的效果:
// vec3 normal = vNormal;
// 标准 Phong 光照计算
vec4 phong = phongReflection(vPos, normal, eyeDirection);
// 采样颜色贴图
vec3 tex = texture(tMap, vUv).rgb;
// 添加一个移动的光源效果
vec3 light = normalize(vec3(sin(uTime), 1.0, cos(uTime)));
float shading = dot(normal, light) * 0.5;
FragColor.rgb = tex + shading;
FragColor.a = 1.0;
}
另一种方法:在片段着色器中计算 TBN(无需顶点属性)
cube.html 中展示了另一种更"高级"的方法:不预计算 Tangent/Bitangent,而是在片段着色器中通过导数(derivatives)实时计算 TBN。
vec3 getNormal() {
// 计算位置和 UV 在屏幕空间的导数
vec3 pos_dx = dFdx(vPos.xyz); // 相邻像素在 x 方向的位置差
vec3 pos_dy = dFdy(vPos.xyz); // 相邻像素在 y 方向的位置差
vec2 tex_dx = dFdx(vUv); // 相邻像素在 x 方向的 UV 差
vec2 tex_dy = dFdy(vUv); // 相邻像素在 y 方向的 UV 差
// 通过导数反推 Tangent 和 Bitangent
vec3 t = normalize(pos_dx * tex_dy.t - pos_dy * tex_dx.t);
vec3 b = normalize(-pos_dx * tex_dy.s + pos_dy * tex_dx.s);
mat3 tbn = mat3(t, b, normalize(vNormal));
// 采样并转换法线
vec3 n = texture(tNormal, vUv).rgb * 2.0 - 1.0;
return normalize(tbn * n);
}
这种方法的优点:
- 不需要预计算 Tangent/Bitangent
- 不需要额外的顶点属性
- 适用于任意几何
缺点:
- 依赖
dFdx/dFdy,在某些平台可能不支持或精度有限 - 在纹理接缝处可能有问题
- 性能略差(每像素都要计算)
常见误区
误区:"法线贴图只能用于凹凸效果。"
错!法线贴图可以表达任何表面朝向的变化。例如,你可以用法线贴图让球体表面看起来像水晶(每个面的法线不同),或者模拟织物的纤维方向。
误区:"法线贴图会改变物体的轮廓。"
错!法线贴图不改变几何,所以从边缘看物体仍然是平滑的。这是法线贴图和位移贴图(Displacement Map)的关键区别。
动手试一试
练习 5.1:在
rock.html中,把n.xy *= 2.0改成n.xy *= 0.5,观察凹凸效果的变化。理解为什么放大 xy 会增强凹凸感。练习 5.2:注释掉
vec3 normal = getNormal(),改用vec3 normal = vNormal,对比有无法线贴图的效果差异。
6. 帧缓冲对象(FBO):渲染到纹理
生活中的类比:复印机
想象你有一台复印机。 normally,你直接在纸上写字(直接渲染到屏幕)。但复印机可以把纸上的内容复印到另一张纸上(渲染到纹理)。然后你可以拿这张复印件做各种事情:放大、缩小、加滤镜、剪贴...
FBO 就是那台复印机。
本质是什么?
通常,GPU 渲染的结果直接显示在屏幕上(默认帧缓冲)。但有时候我们希望:
- 先把场景渲染到一张纹理上
- 然后对这张纹理做后处理(模糊、调色等)
- 最后把处理后的结果显示到屏幕上
帧缓冲对象(Frame Buffer Object, FBO) 允许我们把渲染目标从"屏幕"改为"纹理"。
FBO 的组成
一个 FBO 可以附加多种"附件"(attachment):
- 颜色附件:存储像素的颜色值(通常是一张纹理)
- 深度附件:存储深度值(用于深度测试)
- 模板附件:存储模板值(用于模板测试)
最简单的 FBO 只需要一个颜色附件:
+----------------------------+
| FBO |
| +--------------------+ |
| | 颜色纹理 (RGBA) | |
| | 512 x 512 | |
| +--------------------+ |
+----------------------------+
代码示例:创建和使用 FBO
以下是 blur.html 中 FBO 的使用方式(使用 gl-renderer 库):
// 创建 FBO
const fbo1 = renderer.createFBO();
const fbo2 = renderer.createFBO();
// 第一次渲染:把原始图形渲染到 fbo1
renderer.bindFBO(fbo1);
renderer.render(); // 渲染结果现在存在 fbo1.texture 中
// 第二次渲染:把 fbo1 的内容作为纹理输入,渲染到 fbo2
renderer.useProgram(blurProgram);
renderer.bindFBO(fbo2);
renderer.uniforms.tMap = fbo1.texture;
renderer.render();
// 最后渲染到屏幕(bindFBO(null) 表示默认帧缓冲)
renderer.bindFBO(null);
renderer.uniforms.tMap = fbo2.texture;
renderer.render();
WebGL 原生 API
如果不使用库,原生 WebGL2 创建 FBO 的代码如下:
// 创建纹理作为颜色附件
const texture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.texImage2D(
gl.TEXTURE_2D, 0, gl.RGBA,
width, height, 0,
gl.RGBA, gl.UNSIGNED_BYTE, null
);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
// 创建 FBO
const fbo = gl.createFramebuffer();
gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);
// 把纹理附加为颜色附件
gl.framebufferTexture2D(
gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0,
gl.TEXTURE_2D, texture, 0
);
// 检查 FBO 是否完整
if (gl.checkFramebufferStatus(gl.FRAMEBUFFER) !== gl.FRAMEBUFFER_COMPLETE) {
console.error('FBO incomplete!');
}
// 解绑
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
// 使用 FBO 渲染
gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);
// ... 渲染 ...
// 恢复默认帧缓冲(屏幕)
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
常见误区
误区:"FBO 和纹理是一回事。"
错!FBO 是一个"容器",纹理是"内容"。FBO 可以附加纹理,也可以附加渲染缓冲(Renderbuffer)。纹理可以被采样(在 shader 中读取),渲染缓冲不能。
误区:"FBO 的大小必须和屏幕一样大。"
错!FBO 可以是任意大小。很多时候后处理用的 FBO 比屏幕小(比如 1/4 大小),这样可以减少计算量。
动手试一试
练习 6.1:在
blur.html中,把 FBO 的大小改成 256x256(如果库支持),观察模糊效果的变化。理解 FBO 分辨率对性能和画质的影响。练习 6.2:尝试创建两个 FBO,先把场景渲染到 fbo1,再把 fbo1 渲染到 fbo2,最后把 fbo2 渲染到屏幕。这其实就是"双缓冲"的概念。
7. 多通道渲染
生活中的类比:摄影棚布光
想象你在拍摄一部电影。你不会只拍一次就完事:
- 第一遍:拍基础场景
- 第二遍:单独拍特效(绿幕)
- 第三遍:拍光影效果
- 后期:把所有层合成在一起
多通道渲染(Multi-pass Rendering) 就是 3D 世界的"分层拍摄"。
本质是什么?
多通道渲染是指:
- 用不同的 shader、不同的设置,把场景渲染多次
- 每次渲染的结果存到 FBO(纹理)中
- 最后把所有纹理合成为最终画面
常见的渲染通道
| 通道 | 内容 | 用途 |
|---|---|---|
| 基础颜色 | 物体的基本颜色 | 作为底图 |
| 法线 | 每个像素的法线 | 延迟光照、SSAO |
| 深度 | 每个像素的深度 | 雾效、景深 |
| 高光 | 高光强度 | 控制反光区域 |
| 泛光 | 亮部区域 | 发光效果 |
代码示例:两通道渲染
// 通道 1:渲染原始场景到 fboScene
renderer.bindFBO(fboScene);
renderer.useProgram(sceneProgram);
renderer.render();
// 通道 2:对场景做模糊处理到 fboBlur
renderer.bindFBO(fboBlur);
renderer.useProgram(blurProgram);
renderer.uniforms.tMap = fboScene.texture;
renderer.render();
// 通道 3:合成到屏幕
renderer.bindFBO(null);
renderer.useProgram(compositeProgram);
renderer.uniforms.tScene = fboScene.texture;
renderer.uniforms.tBlur = fboBlur.texture;
renderer.render();
多通道 vs. 单通道
| 单通道(Forward Rendering) | 多通道(Deferred / Post-processing) | |
|---|---|---|
| 优点 | 简单,支持透明物体 | 光照计算少,后处理灵活 |
| 缺点 | 光源多时光照计算爆炸 | 需要更多显存,透明物体处理麻烦 |
| 适用 | 简单场景、移动设备 | 复杂场景、需要后处理 |
常见误区
误区:"多通道渲染一定比单通道慢。"
不一定!虽然多通道有额外的开销(切换 FBO、多遍渲染),但对于复杂场景(很多光源),延迟渲染(一种多通道技术)反而更快,因为光照只在可见像素上计算。
动手试一试
练习 7.1:在
bloom.html中,数一下总共有多少个渲染通道。理解每个通道的作用。练习 7.2:尝试添加一个新通道:在模糊之前,把场景变成黑白(灰度)。这需要修改哪些代码?
8. 高斯模糊后处理
生活中的类比:近视眼 vs. 毛玻璃
想象你隔着一块毛玻璃看东西:
- 每个点都变成了周围多个点的"平均"
- 边缘变得柔和
- 细节被"抹平"了
高斯模糊(Gaussian Blur) 就是数字世界的"毛玻璃效果"。
本质是什么?
高斯模糊是一种图像卷积操作。对于每个像素,它的新颜色是周围像素颜色的加权平均,权重服从高斯分布(钟形曲线)。
一维高斯函数
高斯函数的公式:
G(x) = (1 / sqrt(2 * PI * sigma^2)) * exp(-x^2 / (2 * sigma^2))
其中 sigma 控制模糊的程度(sigma 越大越模糊)。
为什么用两个一维模糊代替一个二维模糊?
直接做二维高斯模糊,对于一个 5x5 的核,每个像素需要采样 25 次。
但高斯函数有一个重要性质:可分离性。
G(x, y) = G(x) * G(y)
这意味着:
- 先对图像做水平方向的一维高斯模糊
- 再对结果做垂直方向的一维高斯模糊
效果等同于做一次二维模糊,但计算量从 N^2 降到 2N!
代码示例:分离式高斯模糊
以下是 blur.html 中的高斯模糊 shader:
uniform sampler2D tMap;
uniform int axis; // 0 = 水平, 1 = 垂直
void main() {
vec4 color = texture2D(tMap, vUv);
// 高斯核权重(sigma ≈ 1.5 的 5 点近似)
float weight[5];
weight[0] = 0.227027; // 中心点
weight[1] = 0.1945946; // 相邻点
weight[2] = 0.1216216;
weight[3] = 0.054054;
weight[4] = 0.016216;
// 像素间隔
float tex_offset = 1.0 / 512.0;
// 中心像素
vec3 result = color.rgb * weight[0];
// 累加周围像素
for(int i = 1; i < 5; ++i) {
float f = float(i);
if(axis == 0) {
// 水平方向
result += texture2D(tMap, vUv + vec2(tex_offset * f, 0.0)).rgb * weight[i];
result += texture2D(tMap, vUv - vec2(tex_offset * f, 0.0)).rgb * weight[i];
} else {
// 垂直方向
result += texture2D(tMap, vUv + vec2(0.0, tex_offset * f)).rgb * weight[i];
result += texture2D(tMap, vUv - vec2(0.0, tex_offset * f)).rgb * weight[i];
}
}
gl_FragColor.rgb = result;
gl_FragColor.a = color.a;
}
多遍模糊增强效果
只做一次水平和一次垂直模糊,效果可能不够明显。通常我们会多次迭代:
// 第一次:水平模糊 fbo1 -> fbo2
renderer.bindFBO(fbo2);
renderer.uniforms.tMap = fbo1.texture;
renderer.uniforms.axis = 0;
renderer.render();
// 第二次:垂直模糊 fbo2 -> fbo1
renderer.bindFBO(fbo1);
renderer.uniforms.tMap = fbo2.texture;
renderer.uniforms.axis = 1;
renderer.render();
// 第三次:水平模糊 fbo1 -> fbo2
renderer.bindFBO(fbo2);
renderer.uniforms.tMap = fbo1.texture;
renderer.uniforms.axis = 0;
renderer.render();
// 第四次:垂直模糊 fbo2 -> 屏幕
renderer.bindFBO(null);
renderer.uniforms.tMap = fbo2.texture;
renderer.uniforms.axis = 1;
renderer.render();
权重值的推导
那些权重值是怎么来的?让我们推导一下。
对于 sigma = 1.5,计算 x = 0, 1, 2, 3, 4 时的高斯值:
G(0) = exp(0) = 1.0
G(1) = exp(-1 / (2 * 1.5^2)) = exp(-0.222) ≈ 0.8007
G(2) = exp(-4 / 4.5) = exp(-0.889) ≈ 0.411
G(3) = exp(-9 / 4.5) = exp(-2) ≈ 0.135
G(4) = exp(-16 / 4.5) = exp(-3.556) ≈ 0.028
归一化(让总和为 1):
总和 = G(0) + 2*G(1) + 2*G(2) + 2*G(3) + 2*G(4)
= 1.0 + 2*0.8007 + 2*0.411 + 2*0.135 + 2*0.028
≈ 3.898
weight[0] = G(0) / 总和 ≈ 0.256
weight[1] = G(1) / 总和 ≈ 0.205
...
实际代码中的值是经过优化的近似值。
常见误区
误区:"高斯模糊就是取周围像素的平均值。"
错!简单平均是"盒式模糊"(Box Blur),所有权重相同。高斯模糊的权重是中心大、边缘小,更符合人眼对模糊的自然感知。
误区:"高斯模糊只能用于后处理。"
错!高斯模糊还可以用于:阴影软化(PCSS)、环境光遮蔽(SSAO)、景深(DOF)等。
动手试一试
练习 8.1:在
blur.html中,把weight数组改成所有值相等(比如都是 0.2),观察盒式模糊和高斯模糊的区别。练习 8.2:增加高斯核的大小(从 5 点增加到 9 点),观察模糊效果的变化。注意性能变化。
9. 泛光(Bloom)效果:亮部发光
生活中的类比:相机过曝
想象你对着太阳拍照:
- 太阳周围会出现一圈光晕
- 亮部会"溢出"到暗部
- 整个画面有一种梦幻的感觉
这就是**泛光(Bloom)**效果——模拟强光在相机镜头中散射的现象。
本质是什么?
Bloom 的实现分为三步:
- 提取亮部:从场景中提取高亮区域
- 模糊亮部:对提取的亮部做高斯模糊,让光"扩散"
- 合成:把模糊的亮部叠加回原始场景
原始场景 提取亮部 模糊亮部 合成结果
+--------+ +--------+ +--------+ +--------+
| **** | | | | | | .... |
| **** | -> | **** | -> | .... | -> | .... |
| | | | | .... | | .... |
+--------+ +--------+ +--------+ +--------+
代码示例:Bloom 的完整实现
以下是 bloom.html 中的核心逻辑:
步骤 1:提取亮部 + 水平模糊(Shader)
uniform sampler2D tMap;
uniform int axis;
uniform float filter; // 亮度阈值
void main() {
vec4 color = texture2D(tMap, vUv);
// 计算亮度(人眼对绿色最敏感)
float brightness = dot(color.rgb, vec3(0.2126, 0.7152, 0.0722));
// 阈值:只有亮度超过 filter 的像素才会保留
brightness = step(filter, brightness);
// 高斯模糊...
float weight[5];
weight[0] = 0.227027;
weight[1] = 0.1945946;
weight[2] = 0.1216216;
weight[3] = 0.054054;
weight[4] = 0.016216;
float tex_offset = 1.0 / 512.0;
vec3 result = color.rgb * weight[0];
for(int i = 1; i < 5; ++i) {
float f = float(i);
if(axis == 0) {
result += texture2D(tMap, vUv + vec2(tex_offset * f, 0.0)).rgb * weight[i];
result += texture2D(tMap, vUv - vec2(tex_offset * f, 0.0)).rgb * weight[i];
} else {
result += texture2D(tMap, vUv + vec2(0.0, tex_offset * f)).rgb * weight[i];
result += texture2D(tMap, vUv - vec2(0.0, tex_offset * f)).rgb * weight[i];
}
}
// 只保留亮部
gl_FragColor.rgb = brightness * result;
gl_FragColor.a = color.a;
}
步骤 2:合成(Shader)
uniform sampler2D tMap; // 模糊后的亮部
uniform sampler2D tSource; // 原始场景
void main() {
vec3 color = texture2D(tSource, vUv).rgb;
vec3 bloomColor = texture2D(tMap, vUv).rgb;
// 叠加:原始颜色 + 泛光颜色
color += bloomColor;
// 色调映射(稍后讲解)
float exposure = 2.0;
vec3 result = vec3(1.0) - exp(-color * exposure);
// Gamma 校正
float gamma = 1.3;
if(length(bloomColor) > 0.0) {
result = pow(result, vec3(1.0 / gamma));
}
gl_FragColor.rgb = result;
gl_FragColor.a = 1.0;
}
步骤 3:JavaScript 中的多通道渲染
// 创建三个 FBO
const fbo0 = renderer.createFBO(); // 原始场景
const fbo1 = renderer.createFBO(); // 模糊中间结果
const fbo2 = renderer.createFBO(); // 模糊中间结果
// 通道 1:渲染原始场景到 fbo0
renderer.bindFBO(fbo0);
renderer.useProgram(program);
renderer.render();
// 通道 2:提取亮部 + 水平模糊 -> fbo2
renderer.useProgram(blurProgram);
renderer.bindFBO(fbo2);
renderer.uniforms.tMap = fbo0.texture;
renderer.uniforms.axis = 0;
renderer.uniforms.filter = 0.7; // 亮度阈值
renderer.render();
// 通道 3:垂直模糊 -> fbo1
renderer.useProgram(blurProgram);
renderer.bindFBO(fbo1);
renderer.uniforms.tMap = fbo2.texture;
renderer.uniforms.axis = 1;
renderer.uniforms.filter = 0;
renderer.render();
// 通道 4:再水平模糊 -> fbo2
renderer.useProgram(blurProgram);
renderer.bindFBO(fbo2);
renderer.uniforms.tMap = fbo1.texture;
renderer.uniforms.axis = 0;
renderer.uniforms.filter = 0;
renderer.render();
// 通道 5:再垂直模糊 -> fbo1
renderer.useProgram(blurProgram);
renderer.bindFBO(fbo1);
renderer.uniforms.tMap = fbo2.texture;
renderer.uniforms.axis = 1;
renderer.uniforms.filter = 0;
renderer.render();
// 通道 6:合成到屏幕
renderer.useProgram(bloomProgram);
renderer.bindFBO(null);
renderer.uniforms.tSource = fbo0.texture; // 原始场景
renderer.uniforms.tMap = fbo1.texture; // 模糊亮部
renderer.render();
亮度计算:为什么用这些系数?
float brightness = dot(color.rgb, vec3(0.2126, 0.7152, 0.0722));
这些系数是人眼对不同颜色的敏感度:
- 绿色(0.7152):最敏感
- 红色(0.2126):中等
- 蓝色(0.0722):最不敏感
这是标准的**亮度(Luminance)**计算公式,基于人眼的感知特性。
常见误区
误区:"Bloom 就是简单的亮部模糊。"
不完全对!好的 Bloom 效果还需要:多次迭代模糊(让光晕更大)、合适的阈值(避免所有东西都发光)、色调映射(防止颜色过曝)。
误区:"Bloom 会让画面变亮,所以我要降低整体亮度。"
错!Bloom 确实增加了亮度,但色调映射(Tone Mapping)会自动处理这个问题。不要手动降低亮度,让色调映射来做它的工作。
动手试一试
练习 9.1:在
bloom.html中,把filter = 0.7改成0.3,观察效果。理解阈值的作用。练习 9.2:去掉色调映射(
vec3(1.0) - exp(-color * exposure)),直接输出color + bloomColor,观察过曝现象。
10. 色调映射(Tone Mapping)
生活中的类比:相机的曝光控制
想象你在一个房间里,窗外是明亮的阳光。你的眼睛能同时看清房间里的暗处和窗外的亮处,但相机不行——要么室内太暗,要么窗外过曝。
色调映射(Tone Mapping) 就是数字世界的"智能曝光控制",把高动态范围(HDR)的颜色映射到屏幕能显示的范围(LDR)。
本质是什么?
现实世界的亮度范围非常大:
- 蜡烛:约 1 cd/m²
- 室内:约 100 cd/m²
- 阳光:约 100,000 cd/m²
但显示器只能显示大约 0-255 的 RGB 值。色调映射就是把无限的亮度范围"压缩"到有限的显示范围内,同时尽量保留视觉细节。
为什么需要色调映射?
没有色调映射时,亮度超过 1.0 的颜色会被直接截断(clamp)到 1.0:
亮度 0.5 -> 显示 0.5(正常)
亮度 1.0 -> 显示 1.0(刚好)
亮度 2.0 -> 显示 1.0(细节丢失!)
亮度 10.0 -> 显示 1.0(完全过曝!)
色调映射用曲线代替硬截断,保留更多细节:
亮度 0.5 -> 显示 ~0.4
亮度 1.0 -> 显示 ~0.6
亮度 2.0 -> 显示 ~0.8
亮度 10.0 -> 显示 ~0.95
Reinhard 色调映射
最简单的色调映射公式是 Reinhard:
L_mapped = L / (1 + L)
推导:
- 当
L = 0时,L_mapped = 0(黑色保持黑色) - 当
L = 1时,L_mapped = 0.5(中间调) - 当
L -> ∞时,L_mapped -> 1(永远不会真正达到白色,无限接近)
曝光控制的 Reinhard
加上曝光参数 exposure:
L_mapped = 1 - exp(-L * exposure)
这就是 bloom.html 中使用的公式!
推导:
- 这是 Reinhard 的另一种形式,基于自然对数
exposure控制"相机快门速度":值越大,画面越亮- 当
L * exposure很小时,exp(-x) ≈ 1 - x,所以L_mapped ≈ L * exposure(线性区域) - 当
L * exposure很大时,exp(-x) ≈ 0,所以L_mapped ≈ 1(饱和区域)
代码示例
vec3 toneMapping(vec3 color) {
float exposure = 2.0;
// 曝光 + Reinhard 色调映射
vec3 mapped = vec3(1.0) - exp(-color * exposure);
return mapped;
}
Gamma 校正
色调映射后,通常还要做 Gamma 校正。这是因为显示器不是线性的——输入 0.5 不会显示成 50% 亮度,而是约 22% 亮度。
Gamma 校正公式:
output = input ^ (1 / gamma)
通常 gamma = 2.2。在 bloom.html 中使用了 gamma = 1.3,这是为了艺术效果而调整的值。
vec3 gammaCorrect(vec3 color) {
float gamma = 2.2;
return pow(color, vec3(1.0 / gamma));
}
完整的后处理管线
HDR 颜色 -> 色调映射 -> Gamma 校正 -> 显示
| | |
| | +-> 补偿显示器非线性
| +-> 压缩动态范围
+-> 光照计算结果(可能 > 1.0)
常见误区
误区:"色调映射就是降低亮度。"
错!色调映射是非线性的压缩。暗部几乎不受影响,亮部被压缩。目的是保留细节,不是简单地变暗。
误区:"Gamma 校正是可选的。"
错!如果不做 Gamma 校正,画面会看起来太暗(尤其是在中间调区域)。现代渲染管线中,Gamma 校正是必须的。
动手试一试
练习 10.1:在
bloom.html中,把exposure从 2.0 改成 0.5 和 5.0,观察效果。练习 10.2:把
gamma从 1.3 改成 2.2(标准值)和 1.0(无 Gamma 校正),观察画面变化。
11. 烟雾模拟:乒乓 FBO
生活中的类比:传话游戏
想象一群小朋友玩传话游戏:
- 第一个小朋友说一句话
- 第二个小朋友听到后传给第三个
- 第三个再传给第四个...
- 每传一次,话就变化一点
乒乓 FBO 就是 GPU 版的传话游戏,只不过"传"的是纹理,"变化"的是物理模拟。
本质是什么?
烟雾、火焰、流体等效果通常需要迭代模拟:
- 当前帧的状态存在纹理 A 中
- Shader 读取纹理 A,计算下一帧的状态
- 把结果写入纹理 B
- 交换 A 和 B,重复
这就是乒乓技术(Ping-Pong)——两个 FBO 交替作为"读"和"写"的目标。
帧 1: 读取 fboA -> 计算 -> 写入 fboB
帧 2: 读取 fboB -> 计算 -> 写入 fboA
帧 3: 读取 fboA -> 计算 -> 写入 fboB
... 如此往复
烟雾扩散的数学
烟雾模拟的核心是扩散方程(热传导方程):
dC/dt = D * (d²C/dx² + d²C/dy²)
其中 C 是烟雾浓度,D 是扩散系数。
在离散形式下(用相邻像素近似二阶导数):
C_new = C + k * (left + right + up + down - 4 * C)
这就是 smoke.html 中的核心公式!
代码示例:烟雾模拟
以下是 smoke.html 的完整实现:
Shader:烟雾扩散
uniform sampler2D tMap; // 上一帧的烟雾状态
uniform float uTime;
// 梯度噪声(用于添加随机扰动)
float noise(vec2 st) {
// ... Perlin/Simplex noise 实现 ...
}
void main() {
vec3 smoke = vec3(0);
// 初始化:第一帧在中心放一个烟雾源
if(uTime <= 0.0) {
vec2 st = vUv - vec2(0.5);
float d = length(st);
smoke = vec3(step(d, 0.05)); // 中心圆形区域
}
vec2 st = vUv;
float offset = 1.0 / 256.0; // 相邻像素的 UV 间隔
// 采样当前像素和四个邻居
vec3 diffuse = texture2D(tMap, st).rgb;
vec4 left = texture2D(tMap, st + vec2(-offset, 0.0));
vec4 right = texture2D(tMap, st + vec2( offset, 0.0));
vec4 up = texture2D(tMap, st + vec2(0.0, -offset));
vec4 down = texture2D(tMap, st + vec2(0.0, offset));
// 随机扰动(让烟雾更自然)
float rand = noise(st + 5.0 * uTime);
// 扩散计算!
// diff = k * (邻居的平均 - 当前值)
float diff = 8.0 * 0.016 * (
(1.0 + rand) * left.r +
(1.0 - rand) * right.r +
down.r +
2.0 * up.r -
5.0 * diffuse.r
);
// 新状态 = 旧状态 + 扩散 + 新烟雾源
gl_FragColor.rgb = (diffuse + diff) + smoke;
gl_FragColor.a = 1.0;
}
JavaScript:乒乓 FBO
// 创建两个 FBO
const fbo = {
readFBO: renderer.createFBO(),
writeFBO: renderer.createFBO(),
// 快捷访问当前读取的纹理
get texture() {
return this.readFBO.texture;
},
// 交换读写 FBO
swap() {
const tmp = this.writeFBO;
this.writeFBO = this.readFBO;
this.readFBO = tmp;
},
};
function update(t) {
// 先渲染到屏幕(显示当前状态)
renderer.bindFBO(null);
renderer.uniforms.uTime = t / 1000;
renderer.uniforms.tMap = fbo.texture;
renderer.render();
// 再渲染到 writeFBO(计算下一帧)
renderer.bindFBO(fbo.writeFBO);
renderer.uniforms.tMap = fbo.texture;
fbo.swap(); // 交换,为下一帧做准备
renderer.render();
requestAnimationFrame(update);
}
update(0);
扩散公式详解
让我们仔细看看这个扩散公式:
float diff = 8.0 * 0.016 * (
(1.0 + rand) * left.r +
(1.0 - rand) * right.r +
down.r +
2.0 * up.r -
5.0 * diffuse.r
);
拆解:
8.0 * 0.016:扩散系数k,控制扩散速度。0.016约等于1/60,对应 60fps 的时间步长。left + right + down + 2*up - 5*diffuse:这是离散拉普拉斯算子的变体。- 标准拉普拉斯:
left + right + up + down - 4*center - 这里
up的权重是 2,其他是 1,总和减 5。这制造了向上的对流效果(烟雾上升)!
- 标准拉普拉斯:
(1.0 + rand) * left和(1.0 - rand) * right:左右不对称的随机扰动,模拟风的随机性。
常见误区
误区:"乒乓 FBO 需要两个完全独立的纹理。"
对!关键是不能同时读写同一个纹理。GPU 不允许在 shader 中读取一个纹理的同时又向它写入。所以必须有两个 FBO 交替使用。
误区:"烟雾模拟需要复杂的 Navier-Stokes 方程。"
对于简单的视觉效果,不需要完整的流体模拟!上面的代码只用了简单的扩散方程,效果已经很好了。当然,如果要物理精确,确实需要 Navier-Stokes。
动手试一试
练习 11.1:在
smoke.html中,把up.r的系数从2.0改成1.0,观察烟雾是否还上升。练习 11.2:把扩散系数
8.0 * 0.016改成16.0 * 0.016,观察烟雾扩散速度的变化。练习 11.3:尝试在烟雾初始化时放两个烟雾源(比如左下角和右下角),观察它们如何交互。
12. 常见问题 Q&A
Q1:法线贴图和高度贴图可以互相转换吗?
A: 可以,但单向更精确。
- 高度 -> 法线:可以通过对高度图求导(梯度)来近似法线。这是常见的做法。
- 法线 -> 高度:理论上可以积分,但由于法线只记录方向而不记录绝对高度,积分结果可能不唯一,且会有累积误差。
所以通常的做法是:艺术家雕刻高模 -> 烘焙高度图 -> 转换为法线贴图。
Q2:TBN 矩阵的 T 和 B 顺序重要吗?
A: 非常重要!TBN 矩阵的定义是 mat3(T, B, N),意味着:
- 第一列是 Tangent 的方向
- 第二列是 Bitangent 的方向
- 第三列是 Normal 的方向
如果你交换 T 和 B,相当于把纹理旋转了 90 度,法线方向就会错乱。同样,如果法线贴图是在 TBN = mat3(T, B, N) 的约定下制作的,你就必须用这个约定来解析它。
Q3:为什么法线贴图看起来是蓝紫色的?
A: 因为在切线空间中,"默认"的法线是 (0, 0, 1)——即完全沿着表面法线方向,没有偏移。映射到纹理值就是 (0.5, 0.5, 1.0),也就是半红、半绿、全蓝,视觉上就是蓝紫色。
当表面有凹凸时,法线会偏离 (0, 0, 1),R 和 G 通道就会变化,颜色就不再是纯蓝紫。
Q4:FBO 和默认帧缓冲有什么区别?
A:
| 特性 | 默认帧缓冲 | FBO |
|---|---|---|
| 显示到屏幕 | 是 | 否 |
| 可以作为纹理采样 | 否(通常) | 是 |
| 大小 | 等于画布大小 | 可以自定义 |
| 用途 | 最终显示 | 离屏渲染、后处理 |
Q5:高斯模糊为什么要分离成两个一维模糊?
A: 计算复杂度。
- 二维 5x5 模糊:每个像素采样 25 次
- 两个一维 5 点模糊:每个像素采样 10 次
- 对于更大的核,差距更大:9x9 二维需要 81 次,分离后只需要 18 次
这是因为高斯函数的可分离性:G(x, y) = G(x) * G(y)。
Q6:Bloom 的阈值应该怎么设置?
A: 取决于场景和美术风格。
- 阈值低(0.3-0.5):很多东西都会发光,画面梦幻但可能过曝
- 阈值中(0.6-0.8):只有真正亮的地方发光,比较自然
- 阈值高(0.9+):只有极亮的区域(如太阳、灯)发光,写实风格
建议从 0.7 开始调整。
Q7:色调映射和 Gamma 校正的顺序是什么?
A: 必须是先色调映射,后 Gamma 校正。
HDR 颜色 -> 色调映射 -> Gamma 校正 -> 显示
如果顺序反过来:
- Gamma 校正会把颜色变暗
- 然后色调映射再压缩
- 结果会错误地过暗
Q8:烟雾模拟中,为什么 up 的权重是 2.0?
A: 这是模拟浮力(烟雾上升)的技巧。
标准扩散方程中,四个邻居的权重应该相等。但把 up 的权重加大,相当于在扩散方程中加入了向上的对流项:
C_new = C + k * (left + right + down + 2*up - 5*C)
= C + k * (left + right + up + down - 4*C) + k * up - k * C
= 扩散项 + 对流项
Q9:法线贴图可以用在 2D 游戏中吗?
A: 可以!这就是"2.5D"效果的秘密。
在 2D 游戏中,你可以:
- 画一张普通的 2D 精灵图
- 配一张法线贴图
- 用 shader 根据法线计算光照
这样,2D 角色就能对光源产生真实的明暗变化,看起来有立体感。很多现代 2D 游戏(如《奥日与黑暗森林》)都用了这个技术。
Q10:为什么我的法线贴图看起来有接缝?
A: 常见原因:
- Tangent/Bitangent 不连续:在 UV 接缝处,Tangent 方向可能突变。需要确保接缝处的 Tangent 一致。
- 纹理过滤:使用线性过滤时,接缝处的像素会被混合。尝试在法线贴图上使用
GL_NEAREST,或者在采样后重新归一化。 - Mipmap:法线贴图的 mipmap 会导致法线被平均(而不是方向被平均),产生偏差。可以使用各向异性过滤,或者在 shader 中重新归一化。
总结
恭喜你读完了这一章!让我们来回顾一下我们学到了什么:
| 概念 | 一句话总结 |
|---|---|
| 法线贴图 | 用纹理存储表面朝向,假装表面有细节 |
| 切线空间 | 每个顶点自己的"局部坐标系",让法线贴图可以复用 |
| TBN 矩阵 | 把切线空间的向量"翻译"到世界/视图空间 |
| 切线/副切线 | 通过 UV 和位置的几何关系计算得出 |
| FBO | 把渲染目标从屏幕改为纹理,实现离屏渲染 |
| 多通道渲染 | 分层渲染、分层处理,最后合成 |
| 高斯模糊 | 用高斯权重做加权平均,分离式实现更高效 |
| Bloom | 提取亮部 -> 模糊 -> 叠加,模拟光晕 |
| 色调映射 | 把 HDR 压缩到 LDR,保留细节 |
| 乒乓 FBO | 两个 FBO 交替读写,实现迭代模拟 |
这些技术是现代实时渲染的基石。掌握它们,你就从"会画三角形"进阶到了"能做出电影级画面"!
继续加油,年轻的渲染工程师!
参考代码文件
本章涉及的示例代码位于:
c:/Users/10603/Desktop/otherLearn2/akira-graphics/normal-maps/cube.html—— 基础法线映射(含导数计算 TBN)c:/Users/10603/Desktop/otherLearn2/akira-graphics/normal-maps/rock.html—— 岩石法线映射(含纹理 + 动态光源)c:/Users/10603/Desktop/otherLearn2/akira-graphics/pass/blur.html—— 高斯模糊后处理c:/Users/10603/Desktop/otherLearn2/akira-graphics/pass/bloom.html—— 泛光效果(含色调映射)c:/Users/10603/Desktop/otherLearn2/akira-graphics/pass/smoke.html—— 烟雾模拟(乒乓 FBO)