第二十章:法线贴图与高级渲染

嘿,年轻的渲染工程师!欢迎来到本章。如果说前面的章节是在学习如何用"积木"搭房子,那这一章我们要学习的是:如何让你的房子看起来像是用真正的石头、木头雕刻出来的,以及如何让画面拥有电影般的质感。

准备好了吗?让我们开始吧!


目录

  1. 什么是法线贴图?(不是凹凸贴图!)
  2. 切线空间:为什么我们需要它
  3. TBN 矩阵:Tangent、Bitangent、Normal
  4. 生成切线与副切线向量
  5. Shader 中的法线映射
  6. 帧缓冲对象(FBO):渲染到纹理
  7. 多通道渲染
  8. 高斯模糊后处理
  9. 泛光(Bloom)效果:亮部发光
  10. 色调映射(Tone Mapping)
  11. 烟雾模拟:乒乓 FBO
  12. 常见问题 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.htmlrock.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 中,打印出一个三角形的 edge1edge2deltaUV1deltaUV2,手动计算一次 T 和 B,验证代码结果。

练习 4.2:如果把一个正方形的 UV 从 (0,0)-(1,1) 改成 (0,0)-(2,2)(纹理重复两次),Tangent 和 Bitangent 会变化吗?为什么?


5. Shader 中的法线映射

生活中的类比:给石膏像上色

想象你有一个光滑的石膏像(低模几何),现在你想让它看起来像是粗糙的石头。你可以:

  1. 真的去雕刻每一个细节(增加几何)—— 太费时间
  2. 给它涂上一层特殊的颜料,颜料里混入了微小的反光颗粒,让光线散射得像粗糙表面一样 —— 这就是法线映射

本质是什么?

法线映射的核心流程:

  1. 采样法线贴图:从纹理中获取切线空间的法线
  2. TBN 转换:把法线从切线空间转换到视图空间
  3. 光照计算:用转换后的法线进行标准的光照计算(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 渲染的结果直接显示在屏幕上(默认帧缓冲)。但有时候我们希望:

  1. 先把场景渲染到一张纹理上
  2. 然后对这张纹理做后处理(模糊、调色等)
  3. 最后把处理后的结果显示到屏幕上

帧缓冲对象(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. 多通道渲染

生活中的类比:摄影棚布光

想象你在拍摄一部电影。你不会只拍一次就完事:

  1. 第一遍:拍基础场景
  2. 第二遍:单独拍特效(绿幕)
  3. 第三遍:拍光影效果
  4. 后期:把所有层合成在一起

多通道渲染(Multi-pass Rendering) 就是 3D 世界的"分层拍摄"。

本质是什么?

多通道渲染是指:

  1. 用不同的 shader、不同的设置,把场景渲染多次
  2. 每次渲染的结果存到 FBO(纹理)中
  3. 最后把所有纹理合成为最终画面

常见的渲染通道

通道 内容 用途
基础颜色 物体的基本颜色 作为底图
法线 每个像素的法线 延迟光照、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)

这意味着:

  1. 先对图像做水平方向的一维高斯模糊
  2. 再对结果做垂直方向的一维高斯模糊

效果等同于做一次二维模糊,但计算量从 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 的实现分为三步:

  1. 提取亮部:从场景中提取高亮区域
  2. 模糊亮部:对提取的亮部做高斯模糊,让光"扩散"
  3. 合成:把模糊的亮部叠加回原始场景
原始场景          提取亮部           模糊亮部           合成结果
+--------+       +--------+        +--------+        +--------+
|  ****  |       |        |        |        |        |  ....  |
|  ****  |  ->   |  ****  |   ->   |  ....  |   ->   |  ....  |
|        |       |        |        |  ....  |        |  ....  |
+--------+       +--------+        +--------+        +--------+

代码示例: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 版的传话游戏,只不过"传"的是纹理,"变化"的是物理模拟。

本质是什么?

烟雾、火焰、流体等效果通常需要迭代模拟

  1. 当前帧的状态存在纹理 A 中
  2. Shader 读取纹理 A,计算下一帧的状态
  3. 把结果写入纹理 B
  4. 交换 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
);

拆解:

  1. 8.0 * 0.016:扩散系数 k,控制扩散速度。0.016 约等于 1/60,对应 60fps 的时间步长。

  2. left + right + down + 2*up - 5*diffuse:这是离散拉普拉斯算子的变体。

    • 标准拉普拉斯:left + right + up + down - 4*center
    • 这里 up 的权重是 2,其他是 1,总和减 5。这制造了向上的对流效果(烟雾上升)!
  3. (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 游戏中,你可以:

  1. 画一张普通的 2D 精灵图
  2. 配一张法线贴图
  3. 用 shader 根据法线计算光照

这样,2D 角色就能对光源产生真实的明暗变化,看起来有立体感。很多现代 2D 游戏(如《奥日与黑暗森林》)都用了这个技术。

Q10:为什么我的法线贴图看起来有接缝?

A: 常见原因:

  1. Tangent/Bitangent 不连续:在 UV 接缝处,Tangent 方向可能突变。需要确保接缝处的 Tangent 一致。
  2. 纹理过滤:使用线性过滤时,接缝处的像素会被混合。尝试在法线贴图上使用 GL_NEAREST,或者在采样后重新归一化。
  3. 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)