来源:Real-Time Rendering, 4th Edition,第5章

目录

  1. 着色模型(Shading Models)
  2. 光源类型(Light Sources)
  3. 着色模型的实现(Implementing Shading Models)
  4. 走样与抗走样(Aliasing and Antialiasing)
  5. 透明度、Alpha 与合成(Transparency, Alpha, and Compositing)
  6. 显示编码(Display Encoding)

1. 着色模型

1.1 什么是着色模型?

着色模型(Shading Model)就是一套数学规则,告诉渲染引擎:

  • 给定一个表面上的点
  • 给定光源方向、观察者方向、法线方向
  • 该点应该显示什么颜色?

类比:就像画油画时,你根据光线方向决定哪里用暖色、哪里用冷色——着色模型就是这个"决策规则"的数学化描述。

1.2 Gooch 着色模型示例

书中使用 Gooch 着色模型作为示例。其核心思想:

  • 表面法线朝向光源 → 用暖色调
  • 表面法线背向光源 → 用冷色调
  • 中间角度 → 插值过渡
    这是一种非真实感渲染(Non-Photorealistic Rendering, NPR),专为技术插图设计,使细节更清晰可读。

1.3 关键向量

着色计算中有三个核心的单位向量:

          n(法线,垂直于表面)
          ^
          |
          |
----------+----------  ← 表面
         /|\
        / | \
       v  |  l
 (视线) (光线方向,指向光源)

符号 含义 方向
n \mathbf{n} n 表面法线(Normal) 垂直于表面,朝外
v \mathbf{v} v 视线方向(View vector) 从表面指向观察者
l \mathbf{l} l 光线方向(Light direction) 从表面指向光源

注意:三个向量都是单位向量(长度为 1)。

1.4 Gooch 着色模型数学公式

完整的着色方程(单光源版本):
c shaded = s ⋅ c highlight + ( 1 − s ) [ t ⋅ c warm + ( 1 − t ) ⋅ c cool ] c_{\text{shaded}} = s \cdot c_{\text{highlight}} + (1 - s)\left[t \cdot c_{\text{warm}} + (1 - t) \cdot c_{\text{cool}}\right] cshaded=schighlight+(1s)[tcwarm+(1t)ccool]
其中各中间量定义如下:
c cool = ( 0 , 0 , 0.55 ) + 0.25 ⋅ c surface c_{\text{cool}} = (0, 0, 0.55) + 0.25 \cdot c_{\text{surface}} ccool=(0,0,0.55)+0.25csurface
c warm = ( 0.3 , 0.3 , 0 ) + 0.25 ⋅ c surface c_{\text{warm}} = (0.3, 0.3, 0) + 0.25 \cdot c_{\text{surface}} cwarm=(0.3,0.3,0)+0.25csurface
c highlight = ( 1 , 1 , 1 ) c_{\text{highlight}} = (1, 1, 1) chighlight=(1,1,1)
t = ( n ⋅ l ) + 1 2 t = \frac{(\mathbf{n} \cdot \mathbf{l}) + 1}{2} t=2(nl)+1
r = 2 ( n ⋅ l ) n − l \mathbf{r} = 2(\mathbf{n} \cdot \mathbf{l})\mathbf{n} - \mathbf{l} r=2(nl)nl
s = ( 100 ( r ⋅ v ) − 97 ) + s = \left(100(\mathbf{r} \cdot \mathbf{v}) - 97\right)_{+} s=(100(rv)97)+

其中 ( x ) + (x)_+ (x)+ 表示将 x x x 截断到 [ 0 , 1 ] [0, 1] [0,1] 范围(clamp)。
逐步理解每个量:

  • t t t:衡量法线与光线的对齐程度。 n ⋅ l = cos ⁡ θ \mathbf{n} \cdot \mathbf{l} = \cos\theta nl=cosθ,当法线正对光源时 cos ⁡ θ = 1 \cos\theta = 1 cosθ=1,则 t = 1 t = 1 t=1(全暖色);背对时 cos ⁡ θ = − 1 \cos\theta = -1 cosθ=1,则 t = 0 t = 0 t=0(全冷色)。加 1 除以 2 是为了把 [ − 1 , 1 ] [-1, 1] [1,1] 映射到 [ 0 , 1 ] [0, 1] [0,1]
  • r \mathbf{r} r:光线 l \mathbf{l} l 关于法线 n \mathbf{n} n反射向量(Reflection Vector)。大多数着色语言有内置的 reflect() 函数。
  • s s s:高光混合因子。当反射方向 r \mathbf{r} r 非常靠近视线方向 v \mathbf{v} v 时, r ⋅ v ≈ 1 \mathbf{r} \cdot \mathbf{v} \approx 1 rv1 s s s 接近 1,表面显示高光。×100 - 97 是为了让高光只在非常对齐时才出现(很窄的高光)。

1.5 常见着色操作总结


操作 数学形式 用途
点积(Dot Product) a ⋅ b = cos ⁡ θ \mathbf{a} \cdot \mathbf{b} = \cos\theta ab=cosθ(单位向量时) 衡量两方向对齐程度
线性插值(Lerp) t ⋅ c a + ( 1 − t ) ⋅ c b t \cdot c_a + (1-t) \cdot c_b tca+(1t)cb 在两个颜色/值之间平滑过渡
截断(Clamp) ( x ) + (x)_+ (x)+ clamp ( x , 0 , 1 ) \text{clamp}(x, 0, 1) clamp(x,0,1) 防止值超出合法范围
反射(Reflect) r = 2 ( n ⋅ l ) n − l \mathbf{r} = 2(\mathbf{n} \cdot \mathbf{l})\mathbf{n} - \mathbf{l} r=2(nl)nl 计算镜面反射方向
归一化(Normalize) $\hat{\mathbf{v}} = \mathbf{v} / \mathbf{v}

2. 光源类型

2.1 光源的基本作用

光源通过两个参数影响着色:

  • l \mathbf{l} l:指向光源的方向向量
  • c light c_{\text{light}} clight:光源的颜色/强度
    一般着色方程(支持多光源):
    c shaded = f unlit ( n , v ) + ∑ i = 1 n c light i ⋅ f lit ( l i , n , v ) c_{\text{shaded}} = f_{\text{unlit}}(\mathbf{n}, \mathbf{v}) + \sum_{i=1}^{n} c_{\text{light}_i} \cdot f_{\text{lit}}(\mathbf{l}_i, \mathbf{n}, \mathbf{v}) cshaded=funlit(n,v)+i=1nclightiflit(li,n,v)
    加入光线角度衰减(法线与光线夹角大于90°时不受光):
    c shaded = f unlit ( n , v ) + ∑ i = 1 n ( l i ⋅ n ) + ⋅ c light i ⋅ f lit ( l i , n , v ) c_{\text{shaded}} = f_{\text{unlit}}(\mathbf{n}, \mathbf{v}) + \sum_{i=1}^{n} (\mathbf{l}_i \cdot \mathbf{n})_+ \cdot c_{\text{light}_i} \cdot f_{\text{lit}}(\mathbf{l}_i, \mathbf{n}, \mathbf{v}) cshaded=funlit(n,v)+i=1n(lin)+clightiflit(li,n,v)

( l ⋅ n ) + (\mathbf{l} \cdot \mathbf{n})_+ (ln)+ 中的 (   ) + (\ )_+ ( )+ 把负值截为 0:当光线从背面来时,点积为负,直接贡献为零。

2.2 Lambert 着色模型

f lit ( ) = c surface f_{\text{lit}}() = c_{\text{surface}} flit()=csurface(常数)时:
c shaded = f unlit ( n , v ) + ∑ i = 1 n ( l i ⋅ n ) + ⋅ c light i ⋅ c surface c_{\text{shaded}} = f_{\text{unlit}}(\mathbf{n}, \mathbf{v}) + \sum_{i=1}^{n} (\mathbf{l}_i \cdot \mathbf{n})_+ \cdot c_{\text{light}_i} \cdot c_{\text{surface}} cshaded=funlit(n,v)+i=1n(lin)+clighticsurface
这就是著名的 Lambert(朗伯)模型,1760年提出,描述完全漫反射(哑光)表面。是许多复杂着色模型的基础构件。

2.3 光源类型分类

光源类型
├── 方向光(Directional Light)
│     l 和 c_light 全局恒定
│     无位置,模拟太阳等远距离光
│
├── 点光源(Point / Omni Light)
│     有位置,向四面八方均匀发光
│     存在距离衰减
│
├── 聚光灯(Spotlight)
│     有位置,有方向,锥形区域发光
│     存在距离衰减 + 方向衰减
│
└── 面光源(Area Light)
      有形状和尺寸(本章不深入讨论)

2.4 方向光(Directional Light)

最简单的光源模型:

  • l \mathbf{l} l 在整个场景中是常数
  • c light c_{\text{light}} clight 也是常数(可因阴影衰减)
  • 没有位置
    适用场景:光源距场景非常远(如太阳照射地表场景)。

2.5 点光源(Point Light)的距离衰减

点光源有位置 p light \mathbf{p}_{\text{light}} plight,光线方向由当前着色点 p 0 \mathbf{p}_0 p0 动态计算:
l = p light − p 0 ∣ p light − p 0 ∣ \mathbf{l} = \frac{\mathbf{p}_{\text{light}} - \mathbf{p}_0}{|\mathbf{p}_{\text{light}} - \mathbf{p}_0|} l=plightp0plightp0
这就是向量归一化的典型应用:方向向量 = 差向量 / 差向量长度。
中间步骤(明确计算距离 r r r):
d = p light − p 0 , r = d ⋅ d , l = d r \mathbf{d} = \mathbf{p}_{\text{light}} - \mathbf{p}_0, \quad r = \sqrt{\mathbf{d} \cdot \mathbf{d}}, \quad \mathbf{l} = \frac{\mathbf{d}}{r} d=plightp0,r=dd ,l=rd

技巧: d ⋅ d = ∣ d ∣ 2 \mathbf{d} \cdot \mathbf{d} = |\mathbf{d}|^2 dd=d2,所以 r = d ⋅ d r = \sqrt{\mathbf{d} \cdot \mathbf{d}} r=dd ,避免了 x 2 + y 2 + z 2 \sqrt{x^2+y^2+z^2} x2+y2+z2 的繁琐写法。

平方反比衰减(Inverse-Square Attenuation)

光的物理规律:光强与距离平方成反比。
c light ( r ) = c light 0 ( r 0 r ) 2 c_{\text{light}}(r) = c_{\text{light}_0} \left(\frac{r_0}{r}\right)^2 clight(r)=clight0(rr0)2
其中 r 0 r_0 r0 是参考距离, c light 0 c_{\text{light}_0} clight0 是在 r 0 r_0 r0 处的光强。
问题1:近距离奇点 r → 0 r \to 0 r0 时趋向无穷大)
解决方案一(Unreal Engine / Frostbite):加一个小偏移 ϵ \epsilon ϵ
c light ( r ) = c light 0 ⋅ r 0 2 r 2 + ϵ c_{\text{light}}(r) = c_{\text{light}_0} \cdot \frac{r_0^2}{r^2 + \epsilon} clight(r)=clight0r2+ϵr02
解决方案二(CryEngine / Frostbite):限制最小距离 r min ⁡ r_{\min} rmin
c light ( r ) = c light 0 ( r 0 max ⁡ ( r , r min ⁡ ) ) 2 c_{\text{light}}(r) = c_{\text{light}_0} \left(\frac{r_0}{\max(r, r_{\min})}\right)^2 clight(r)=clight0(max(r,rmin)r0)2
问题2:远距离不归零(性能问题,光永远不完全消失)
解决方案:乘以一个窗口函数(Windowing Function),在 r max ⁡ r_{\max} rmax 处平滑归零:
f win ( r ) = ( 1 − ( r r max ⁡ ) 4 ) + 2 f_{\text{win}}(r) = \left(1 - \left(\frac{r}{r_{\max}}\right)^4\right)_+^2 fwin(r)=(1(rmaxr)4)+2
最终完整公式:
c light ( r ) = c light 0 ⋅ r 0 2 r 2 + ϵ ⋅ f win ( r ) c_{\text{light}}(r) = c_{\text{light}_0} \cdot \frac{r_0^2}{r^2 + \epsilon} \cdot f_{\text{win}}(r) clight(r)=clight0r2+ϵr02fwin(r)
下图展示衰减曲线的形状(概念示意):

光强
|
1.0|***
   |   *
   |    *          ← 平方反比曲线
   |     **
0.5|       **
   |         ***
   |            ****   ← 加窗后曲线
   |                ****__
0.0+----------------------------> 距离 r
   0    1    2    3(rmax) 4    5

2.6 聚光灯(Spotlight)

聚光灯在距离衰减的基础上,增加方向衰减 f dir ( l ) f_{\text{dir}}(\mathbf{l}) fdir(l)
c light = c light 0 ⋅ f dist ( r ) ⋅ f dir ( l ) c_{\text{light}} = c_{\text{light}_0} \cdot f_{\text{dist}}(r) \cdot f_{\text{dir}}(\mathbf{l}) clight=clight0fdist(r)fdir(l)
聚光灯结构示意:

         s(聚光方向)
         |
         |
    θp --+-- θp   ← 半影角(penumbra angle),内锥,全亮
         |
    θu --+-- θu   ← 本影角(umbra angle),外锥,全暗
         |
         ▼
     (光源位置)

参数含义:

  • s \mathbf{s} s:聚光灯的朝向方向
  • θ s \theta_s θs s \mathbf{s} s − l -\mathbf{l} l(从光源指向表面方向)之间的夹角
  • θ p \theta_p θp(penumbra):内锥角, θ s < θ p \theta_s < \theta_p θs<θp 时全亮
  • θ u \theta_u θu(umbra):外锥角, θ s > θ u \theta_s > \theta_u θs>θu 时全暗
    过渡区公式(Frostbite 引擎):
    t = ( cos ⁡ θ s − cos ⁡ θ u cos ⁡ θ p − cos ⁡ θ u ) + t = \left(\frac{\cos\theta_s - \cos\theta_u}{\cos\theta_p - \cos\theta_u}\right)_+ t=(cosθpcosθucosθscosθu)+
    f dir F ( l ) = t 2 f_{\text{dir}}^F(\mathbf{l}) = t^2 fdirF(l)=t2
    过渡区公式(three.js 库):
    f dir T ( l ) = smoothstep ( t ) = t 2 ( 3 − 2 t ) f_{\text{dir}}^T(\mathbf{l}) = \text{smoothstep}(t) = t^2(3 - 2t) fdirT(l)=smoothstep(t)=t2(32t)

smoothstep [ 0 , 1 ] [0,1] [0,1] 之间的三次多项式平滑插值,在着色语言中是内置函数。

3. 着色模型的实现

3.1 计算频率划分

着色计算不都需要在 GPU 上逐像素运行。根据计算结果变化的频率,可以分配到不同阶段:

计算频率分析

整个应用期间不变
(硬件配置、安装选项)

每帧变化一次
(视图矩阵、投影矩阵)

每个模型变化一次
(模型位置相关参数)

每次 Draw Call 变化
(材质参数)

逐顶点变化
(顶点着色器处理)

逐像素变化
(像素着色器处理)

CPU 预计算
写入常量缓冲区

顶点着色器
Vertex Shader

像素着色器
Pixel Shader

原则:能早算的就早算,不变的量放到 CPU 端预计算。

3.2 逐顶点 vs 逐像素着色

为什么几乎所有着色都在像素着色器里算?
对比实验:同一个着色模型,一个逐顶点算,一个逐像素算:

顶点着色(Gouraud Shading):
  - 在顶点处算颜色,三角形内部线性插值
  - 高光等非线性效果会出现形状错误
  - 顶点稀少时误差明显
像素着色(Phong Shading,注意与 Phong 光照模型区分):
  - 每个像素独立计算完整着色
  - 高光形状正确
  - 结果质量高

问题:法线插值后长度不为1
顶点法线长度为 1,但线性插值后长度会小于 1(内缩效果),所以必须在像素着色器中重新归一化

顶点A法线: (1, 0, 0)     顶点B法线: (0, 1, 0)
                  \         /
           插值中点: (0.5, 0.5, 0)
           长度 = sqrt(0.5^2 + 0.5^2) = 0.707  ← 不是1!
           必须归一化为 (0.707, 0.707, 0)

另一个问题:视线向量和光线向量不应该在插值前归一化
若对指向特定位置的向量(如光线方向)插值,必须先插值、再归一化,否则方向会偏错:

错误做法:先归一化再插值
正确做法:先插值再归一化

3.3 多光源 Gooch 模型实现示例

完整着色模型(多光源版):
c shaded = 1 2 c cool + ∑ i = 1 n ( l i ⋅ n ) + ⋅ c light i ⋅ [ s i ⋅ c highlight + ( 1 − s i ) ⋅ c warm ] c_{\text{shaded}} = \frac{1}{2} c_{\text{cool}} + \sum_{i=1}^{n} (\mathbf{l}_i \cdot \mathbf{n})_+ \cdot c_{\text{light}_i} \cdot \left[s_i \cdot c_{\text{highlight}} + (1 - s_i) \cdot c_{\text{warm}}\right] cshaded=21ccool+i=1n(lin)+clighti[sichighlight+(1si)cwarm]
c cool = ( 0 , 0 , 0.55 ) + 0.25 c surface , c warm = ( 0.3 , 0.3 , 0 ) + 0.25 c surface c_{\text{cool}} = (0, 0, 0.55) + 0.25 c_{\text{surface}}, \quad c_{\text{warm}} = (0.3, 0.3, 0) + 0.25 c_{\text{surface}} ccool=(0,0,0.55)+0.25csurface,cwarm=(0.3,0.3,0)+0.25csurface
c highlight = ( 2 , 2 , 2 ) , r i = 2 ( n ⋅ l i ) n − l i , s i = ( 100 ( r i ⋅ v ) − 97 ) + c_{\text{highlight}} = (2, 2, 2), \quad \mathbf{r}_i = 2(\mathbf{n} \cdot \mathbf{l}_i)\mathbf{n} - \mathbf{l}_i, \quad s_i = (100(\mathbf{r}_i \cdot \mathbf{v}) - 97)_+ chighlight=(2,2,2),ri=2(nli)nli,si=(100(riv)97)+

3.4 GLSL 实现代码(附 C++ WebGL 集成)

以下是完整可运行的 C++ 端代码框架,以及对应的 GLSL 着色器代码(嵌入字符串中):

// ============================================================
// 文件:gooch_shading_demo.cpp
// 说明:多光源 Gooch 着色模型的 WebGL2/OpenGL 实现示例
//       展示如何设置 uniform 变量和着色器程序
// ============================================================
#include <string>
#include <vector>
#include <cmath>
#include <iostream>
#include <algorithm>
// ============================================================
// GLSL 顶点着色器源码(字符串形式)
// ============================================================
const std::string VERTEX_SHADER_SRC = R"glsl(
#version 300 es
// ---- 顶点属性输入(来自 VAO)----
layout(location = 0) in vec4 position;  // 顶点位置(模型空间)
layout(location = 1) in vec4 normal;    // 顶点法线(模型空间)
// ---- Uniform 变量(CPU 端设置,每次 Draw Call 不变)----
uniform mat4 uModel;      // 模型矩阵:模型空间 -> 世界空间
uniform mat4 uViewProj;   // 视图投影矩阵:世界空间 -> 裁剪空间
// ---- 输出给片元着色器的插值变量 ----
out vec3 vPos;    // 世界空间位置(用于计算光线方向)
out vec3 vNormal; // 世界空间法线(用于着色计算)
void main() {
    // 将顶点位置变换到世界空间
    vec4 worldPosition = uModel * position;
    vPos = worldPosition.xyz;  // 传递世界空间位置给片元着色器
    // 将法线变换到世界空间
    // 注意:这里不提前归一化,因为模型矩阵可能含均匀缩放
    vNormal = (uModel * normal).xyz;
    // 最终裁剪空间位置(gl_Position 是光栅化器必需的)
    gl_Position = uViewProj * worldPosition;
}
)glsl";
// ============================================================
// GLSL 片元(像素)着色器源码
// MAXLIGHTS 是一个占位符,CPU 端会在编译前替换为实际数值
// ============================================================
const std::string FRAGMENT_SHADER_SRC = R"glsl(
#version 300 es
precision highp float;
// ---- 来自顶点着色器的插值输入 ----
in vec3 vPos;    // 当前像素对应的世界空间位置
in vec3 vNormal; // 插值后的世界空间法线(需要重新归一化)
// ---- 光源结构体(与 CPU 端布局一致)----
struct Light {
    vec4 position; // xyz = 位置,w 未使用(用于 std140 对齐)
    vec4 color;    // xyz = RGB 颜色,w 未使用
};
// ---- Uniform 块(绑定到 UBO,批量传递光源数据)----
layout(std140) uniform LightUBlock {
    Light uLights[MAXLIGHTS]; // MAXLIGHTS 由 CPU 在编译前替换
};
uniform uint  uLightCount;    // 当前实际使用的光源数量
uniform vec3  uEyePosition;   // 观察者(相机)的世界空间位置
uniform vec3  uWarmColor;     // 暖色(CPU 预计算)
uniform vec3  uFUnlit;        // 无光照部分颜色 = 0.5 * c_cool(CPU 预计算)
// ---- 输出颜色 ----
out vec4 outColor;
// ============================================================
// lit() 函数:计算单个光源对当前像素的着色贡献(发光部分)
// 参数:
//   l - 归一化的光线方向(从表面指向光源)
//   n - 归一化的表面法线
//   v - 归一化的视线方向(从表面指向观察者)
// 返回:该光源方向对应的颜色(warm 和 highlight 的插值)
// ============================================================
vec3 lit(vec3 l, vec3 n, vec3 v) {
    // 计算光线 l 关于法线 n 的反射向量
    // reflect(-l, n) 等价于 r = 2*(n·l)*n - l
    // 注意:GLSL 的 reflect(I, N) 中 I 是入射方向,所以要取反
    vec3 r_l = reflect(-l, n);
    // 计算高光混合因子 s = clamp(100 * (r·v) - 97, 0, 1)
    // 只有当反射方向非常接近视线方向时,s 才接近 1(高光)
    float s = clamp(100.0 * dot(r_l, v) - 97.0, 0.0, 1.0);
    // 高光颜色(比1亮,用于创造发光效果)
    vec3 highlightColor = vec3(2.0, 2.0, 2.0);
    // 在暖色和高光之间插值
    // mix(a, b, t) = (1-t)*a + t*b
    // s=0 时返回暖色,s=1 时返回高光色
    return mix(uWarmColor, highlightColor, s);
}
void main() {
    // 重新归一化法线(插值后长度可能不为1)
    vec3 n = normalize(vNormal);
    // 计算视线方向:从当前点指向观察者
    vec3 v = normalize(uEyePosition - vPos);
    // 初始化输出颜色为无光照部分(0.5 * c_cool)
    // alpha 固定为 1.0(完全不透明)
    outColor = vec4(uFUnlit, 1.0);
    // 遍历所有活跃光源,累加各光源的贡献
    for (uint i = 0u; i < uLightCount; i++) {
        // 计算从当前像素指向第 i 个光源的方向(归一化)
        vec3 l = normalize(uLights[i].position.xyz - vPos);
        // Lambert 因子:(n · l),截断到 [0, 1]
        // 当光线从背面来时(夹角>90°),点积为负,clamp 后为 0
        float NdL = clamp(dot(n, l), 0.0, 1.0);
        // 累加:Lambert 因子 × 光源颜色 × 发光部分颜色
        outColor.rgb += NdL * uLights[i].color.rgb * lit(l, n, v);
    }
}
)glsl";
// ============================================================
// 数学辅助结构(用于演示 CPU 端的计算)
// ============================================================
struct Vec3 {
    float x, y, z;
    Vec3(float x, float y, float z) : x(x), y(y), z(z) {}
    // 向量加法
    Vec3 operator+(const Vec3& o) const { return {x+o.x, y+o.y, z+o.z}; }
    // 向量数乘
    Vec3 operator*(float s) const { return {x*s, y*s, z*s}; }
};
// 点积
float dot(const Vec3& a, const Vec3& b) {
    return a.x*b.x + a.y*b.y + a.z*b.z;
}
// 归一化
Vec3 normalize(const Vec3& v) {
    float len = std::sqrt(dot(v, v));  // 利用点积求长度
    return {v.x/len, v.y/len, v.z/len};
}
// ============================================================
// CPU 端预计算:warm color 和 cool color(减少 GPU 工作量)
// ============================================================
struct ShadingUniforms {
    Vec3 warmColor;
    Vec3 coolColor;
    Vec3 fUnlit;    // = 0.5 * coolColor,作为无光部分
    // 根据表面颜色预计算暖色和冷色
    void compute(const Vec3& surfaceColor) {
        // c_cool = (0, 0, 0.55) + 0.25 * c_surface
        coolColor = Vec3(0.0f, 0.0f, 0.55f) + surfaceColor * 0.25f;
        // c_warm = (0.3, 0.3, 0) + 0.25 * c_surface
        warmColor = Vec3(0.3f, 0.3f, 0.0f) + surfaceColor * 0.25f;
        // 无光照部分 = 0.5 * c_cool(Equation 5.21)
        fUnlit = coolColor * 0.5f;
    }
};
// ============================================================
// 演示:模拟单像素的着色计算
// ============================================================
Vec3 simulatePixelShading(
    const Vec3& n_raw,          // 插值后的原始法线(未归一化)
    const Vec3& pos,            // 像素世界空间位置
    const Vec3& eyePos,         // 观察者位置
    const Vec3& lightPos,       // 光源位置
    const Vec3& lightColor,     // 光源颜色
    const ShadingUniforms& uni  // 预计算的 uniform 数据
) {
    // 像素着色器内:重新归一化法线
    Vec3 n = normalize(n_raw);
    // 计算视线方向
    Vec3 v = normalize(Vec3(eyePos.x - pos.x, eyePos.y - pos.y, eyePos.z - pos.z));
    // 计算光线方向
    Vec3 lDir = Vec3(lightPos.x - pos.x, lightPos.y - pos.y, lightPos.z - pos.z);
    Vec3 l = normalize(lDir);
    // Lambert 因子(截断)
    float NdL = std::max(0.0f, dot(n, l));
    // 计算反射向量 r = 2*(n·l)*n - l(对应 GLSL reflect(-l, n))
    float ndl = dot(n, l);
    Vec3 r = Vec3(
        2.0f * ndl * n.x - l.x,
        2.0f * ndl * n.y - l.y,
        2.0f * ndl * n.z - l.z
    );
    // 高光因子
    float s = std::clamp(100.0f * dot(r, v) - 97.0f, 0.0f, 1.0f);
    // lit() 函数结果:插值暖色和高光
    Vec3 highlight(2.0f, 2.0f, 2.0f);
    Vec3 litColor = Vec3(
        uni.warmColor.x * (1.0f - s) + highlight.x * s,
        uni.warmColor.y * (1.0f - s) + highlight.y * s,
        uni.warmColor.z * (1.0f - s) + highlight.z * s
    );
    // 最终颜色 = 无光照部分 + Lambert × 光颜色 × lit颜色
    return Vec3(
        uni.fUnlit.x + NdL * lightColor.x * litColor.x,
        uni.fUnlit.y + NdL * lightColor.y * litColor.y,
        uni.fUnlit.z + NdL * lightColor.z * litColor.z
    );
}
int main() {
    // 演示:预计算 uniform 数据
    ShadingUniforms uni;
    Vec3 surfaceColor(0.8f, 0.5f, 0.2f);  // 橙色表面
    uni.compute(surfaceColor);
    std::cout << "=== Gooch 着色模型演示 ===" << std::endl;
    std::cout << "表面颜色: (" << surfaceColor.x << ", "
              << surfaceColor.y << ", " << surfaceColor.z << ")" << std::endl;
    std::cout << "冷色 c_cool: (" << uni.coolColor.x << ", "
              << uni.coolColor.y << ", " << uni.coolColor.z << ")" << std::endl;
    std::cout << "暖色 c_warm: (" << uni.warmColor.x << ", "
              << uni.warmColor.y << ", " << uni.warmColor.z << ")" << std::endl;
    std::cout << "无光部分 f_unlit: (" << uni.fUnlit.x << ", "
              << uni.fUnlit.y << ", " << uni.fUnlit.z << ")" << std::endl;
    // 模拟一个像素的着色结果
    Vec3 result = simulatePixelShading(
        Vec3(0.0f, 1.0f, 0.0f),     // 法线朝上
        Vec3(0.0f, 0.0f, 0.0f),     // 原点处的像素
        Vec3(0.0f, 0.0f, 5.0f),     // 观察者在 Z 轴正方向
        Vec3(1.0f, 2.0f, 3.0f),     // 光源位置
        Vec3(1.0f, 1.0f, 1.0f),     // 白色光
        uni
    );
    std::cout << "\n像素最终颜色: ("
              << result.x << ", " << result.y << ", " << result.z << ")"
              << std::endl;
    return 0;
}

3.5 材质系统(Material Systems)

着色器(Shader)与材质(Material)的区别:

  • Shader(着色器):GPU 程序,低层次,美术人员不直接操作
  • Material(材质):面向美术人员的视觉外观封装,最终通过着色器实现
    材质系统的层次结构:

材质模板
(Material Template)
定义参数类型和着色逻辑

材质实例A
(红色金属)
填入具体参数值

材质实例B
(蓝色塑料)
填入具体参数值

材质实例C
(绿色玻璃)
填入具体参数值

Shader变体1

Shader变体2(含透明)

着色器变体的来源:
一个材质系统需要管理大量着色器变体(Shader Variants):

变体来源 说明 示例
不同几何处理 骨骼动画、形变、实例化 静态网格 vs 蒙皮网格
不同材质特性 开关某个特效 有无法线贴图
不同光照类型 延迟渲染 vs 前向渲染 Deferred vs Forward
不同平台 手机 vs PC Mobile GLSL vs Desktop GLSL

实际案例:游戏 Destiny: The Taken King 单帧使用超过 9000 个编译好的着色器变体。Unity 某个材质系统理论上有接近 1000 亿个可能变体。
着色器组合策略:

  1. 代码复用(Code Reuse):用 #include 共享函数库
  2. 超级着色器(Ubershader):一个大着色器 + 条件分支,运行时选择路径
  3. 节点图(Additive/Node Graph):可视化编辑器组合节点(Unreal Engine 的材质编辑器就是这种)
  4. 模板接口(Template-based):定义接口,不同实现可以热插拔

4. 走样与抗走样

4.1 什么是走样(Aliasing)?

走样是因为采样率不够高导致的视觉错误。
经典例子:老西部片里,车轮的轮辐比摄像机帧率转得快,看起来像在反转——这就是时间走样(Temporal Aliasing)。
计算机图形中的走样现象:

走样类型 表现 原因
几何边缘走样 锯齿(Jaggies) 三角形边缘被像素中心离散采样
纹理走样 摩尔纹、闪烁 纹理频率超过像素采样率
高光走样 萤火虫(Fireflies) 高光变化过快
时间走样 鬼影、抖动 运动物体每帧跳跃太大

4.2 采样理论基础

奈奎斯特定理(Nyquist Theorem)

核心结论:要完美重建一个信号,采样率必须大于信号最高频率的两倍。
f 采样率 > 2 × f 信号最高频率 f_{\text{采样率}} > 2 \times f_{\text{信号最高频率}} f采样率>2×f信号最高频率
这个最低有效采样率称为奈奎斯特率(Nyquist Rate)

信号频率过高(采样率不足):
  原始信号:  /\/\/\/\/\/\/\
  采样点:    *     *     *
  重建信号:  \___/\___ (完全错误)
信号频率适当(采样率足够):
  原始信号:  /\/\
  采样点:    * *  *  *
  重建信号:  /\/\ (正确)

对于实时渲染:三角形边缘产生无限高频(不连续跳变),理论上无法完美采样,因此走样是本质上难以完全消除的问题,只能尽量减轻。

常用滤波器

滤波器 特点 质量 实用性
盒式滤波器(Box Filter) 最近邻,阶梯状 简单快速
帐篷滤波器(Tent Filter) 线性插值,连续 常用
sinc 滤波器 理想低通,宽度无限 理想 不实用
Gaussian 类滤波器 近似 sinc,有限宽度 实用

sinc 函数定义(理想低通滤波器):
sinc ( x ) = sin ⁡ ( π x ) π x \text{sinc}(x) = \frac{\sin(\pi x)}{\pi x} sinc(x)=πxsin(πx)

4.3 主要抗走样技术

超级采样(SSAA / FSAA)

最暴力的方法:用更高分辨率渲染,然后降采样。

目标分辨率: 1280×1024
渲染分辨率: 2560×2048
每4个像素平均 → 得到1个输出像素(4倍开销)

优点:简单
缺点:开销极大(4倍分辨率 = 4倍像素着色器调用)

多重采样抗走样(MSAA)

核心思想:覆盖率(Coverage)用多个采样点计算,但着色只做一次

4x MSAA 示意:
像素内有4个采样位置(×):
  +-------+
  |×   ×  |
  |   ▲   |  ← 像素着色器只在这里运行一次(像素中心或质心)
  |×   ×  |
  +-------+
三角形覆盖了2个采样点 → 该像素对三角形的覆盖率 = 2/4 = 50%
最终颜色 = 50% 三角形颜色 + 50% 背景颜色

优点:覆盖率精确,着色只算一次
缺点:与延迟渲染(Deferred Shading)不兼容

时间抗走样(TAA,Temporal Antialiasing)

核心思想:利用多帧信息。每帧的采样位置在像素内偏移不同的子像素位置,加权平均历史帧。

第1帧采样位置: 左下  →  第2帧: 右上  →  第3帧: 左上  →  第4帧: 右下
                                  ↓
              混合历史帧,等效于每帧有多个采样点

优点:几乎不增加每帧开销
缺点:快速运动时产生鬼影(Ghosting),需要用运动向量(Motion Vectors) 修正

形态学抗走样(MLAA / FXAA / SMAA)

核心思想:作为后处理,检测图像中的边缘,推断边缘的真实位置,进行混合。

输入(走样图像)   检测边缘   推断真实边缘   混合输出
+--------+                                +--------+
|#####   |  →  找到走样  →  估计覆盖率  →  |####    |
|   #####|     边缘位置                    |   #### |
+--------+                                +--------+
(锯齿状)                                  (平滑)

两大主流实现:

  • FXAA(Fast Approximate AA):极快,质量一般,仅需颜色缓冲
  • SMAA(Subpixel Morphological AA):质量更好,可利用 MSAA 信息

4.4 采样模式(Sampling Patterns)

好的采样模式应当:

  1. 样本分布均匀(避免聚集)
  2. 对近水平和近垂直边缘效果好(人眼对这类边缘最敏感)
  3. 低差异序列(Low Discrepancy)
    旋转网格超级采样(RGSS):将 2×2 采样网格旋转,使4个采样点分布在4行4列的子像素格内(N-rooks 采样)。
普通 2×2 网格:       RGSS 旋转网格:
+--+--+              +--+--+
|× |× |              |  |× |
+--+--+              +--+--+
|× |× |              |×    |
+--+--+              |  ×  |
                     |    ×|
                     +-----+
                     (在16个子格中每行每列各1个)

FLIPQUAD:结合 RGSS 和像素边缘共享样本,仅需每像素 2 个样本却达到 RGSS(4样本)的质量。

5. 透明度、Alpha 与合成

5.1 Alpha 的含义

Alpha( α \alpha α:描述一个片元(Fragment)对像素的不透明度和覆盖率

  • α = 1.0 \alpha = 1.0 α=1.0:完全不透明,完全覆盖像素
  • α = 0.0 \alpha = 0.0 α=0.0:完全透明,像素不受影响
  • α = 0.5 \alpha = 0.5 α=0.5:半透明

例:肥皂泡边缘覆盖像素 75%,本身透明度 10%(透光率 90%):
α = 0.75 × 0.1 = 0.075 \alpha = 0.75 \times 0.1 = 0.075 α=0.75×0.1=0.075

5.2 over 运算符(混合公式)

最常用的透明度混合操作:
c o = α s ⋅ c s + ( 1 − α s ) ⋅ c d c_o = \alpha_s \cdot c_s + (1 - \alpha_s) \cdot c_d co=αscs+(1αs)cd
其中:

  • c s c_s cs:透明物体(source,源)颜色
  • α s \alpha_s αs:透明物体的 alpha
  • c d c_d cd:背景(destination,目标)颜色
  • c o c_o co:混合后的输出颜色
    计算示例:
    红色半透明物体(RGB = ( 0.9 , 0.2 , 0.1 ) (0.9, 0.2, 0.1) (0.9,0.2,0.1) α = 0.6 \alpha = 0.6 α=0.6)叠加在蓝色背景( ( 0.1 , 0.1 , 0.9 ) (0.1, 0.1, 0.9) (0.1,0.1,0.9))上:
    c o = 0.6 × ( 0.9 , 0.2 , 0.1 ) + 0.4 × ( 0.1 , 0.1 , 0.9 ) = ( 0.58 , 0.16 , 0.42 ) c_o = 0.6 \times (0.9, 0.2, 0.1) + 0.4 \times (0.1, 0.1, 0.9) = (0.58, 0.16, 0.42) co=0.6×(0.9,0.2,0.1)+0.4×(0.1,0.1,0.9)=(0.58,0.16,0.42)
    加法混合(Additive Blending)
    c o = α s ⋅ c s + c d c_o = \alpha_s \cdot c_s + c_d co=αscs+cd
    适合发光效果(闪电、火花),不适合模拟物理透明度。

5.3 透明度渲染的排序问题

问题:Z-buffer 只能存储每像素一个深度,多个透明物体叠加时需要从后往前(back-to-front)渲染才能正确混合。

正确渲染顺序(back-to-front):
  1. 先渲染所有不透明物体
  2. 按距相机距离从远到近排序透明物体
  3. 依次渲染(over 混合)
问题:
  - 物体相互穿插时无法正确排序
  - 凹形物体自遮挡时排序失效

5.4 顺序无关透明度(OIT)

深度剥离(Depth Peeling)

核心思想:多趟渲染,每趟"剥掉"最近的一层透明面。

第1趟
获取所有表面最近深度
存 Z-buffer1

第2趟
渲染深度=Z-buffer1 的层
存 Layer1 颜色

第3趟
更新 Z-buffer2 为次近层
渲染 Layer2

...继续剥离...

最终
将所有层 under 合并

优点:结果正确
缺点:每一层需要完整渲染一遍,性能差

加权混合 OIT(Weighted Blended OIT)

用距离权重近似排序效果,单 Pass 完成:
c o = ( 1 − u ) ⋅ c wavg + u ⋅ c d c_o = (1 - u) \cdot c_{\text{wavg}} + u \cdot c_d co=(1u)cwavg+ucd
其中 c wavg c_{\text{wavg}} cwavg 是按 alpha 加权平均的透明颜色, u u u 估算背景的可见度。
优点:单 Pass,性能好
缺点:不精确,适合高透明度的烟雾、粒子等

5.5 under 运算符

under 是 over 的"镜像",支持前到后(front-to-back)渲染透明物体:
c o = α d ⋅ c d + ( 1 − α d ) ⋅ α s ⋅ c s c_o = \alpha_d \cdot c_d + (1 - \alpha_d) \cdot \alpha_s \cdot c_s co=αdcd+(1αd)αscs
α o = α s ( 1 − α d ) + α d \alpha_o = \alpha_s(1 - \alpha_d) + \alpha_d αo=αs(1αd)+αd

5.6 预乘 Alpha(Premultiplied Alpha)

预乘 Alpha:将 RGB 值预先乘以 α \alpha α,即存储 ( α R , α G , α B , α ) (\alpha R, \alpha G, \alpha B, \alpha) (αR,αG,αB,α) 而非 ( R , G , B , α ) (R, G, B, \alpha) (R,G,B,α)
预乘后的 over 公式更简洁:
c o = c s ′ + ( 1 − α s ) ⋅ c d c_o = c_s' + (1 - \alpha_s) \cdot c_d co=cs+(1αs)cd
其中 c s ′ = α s ⋅ c s c_s' = \alpha_s \cdot c_s cs=αscs 已预乘。
优点:

  • 线性插值(如 mipmap)结果正确
  • 避免边缘黑边(Black Fringe)问题

格式 存储内容 适用场景
预乘 Alpha(Associated) ( α R , α G , α B , α ) (\alpha R, \alpha G, \alpha B, \alpha) (αR,αG,αB,α) 渲染、合成
非预乘 Alpha(Unassociated) ( R , G , B , α ) (R, G, B, \alpha) (R,G,B,α) 图像编辑(保留原始颜色)

支持 Alpha 的图像格式:

  • PNG:仅非预乘
  • OpenEXR:仅预乘
  • TIFF:两种都支持

6. 显示编码(Gamma 矫正)

6.1 问题根源:CRT 显示器的非线性响应

早期 CRT 显示器有一个物理特性:输入电压与输出亮度不是线性关系,而是幂函数关系:
亮度(辐射量) = 输入值 γ , γ ≈ 2.2 \text{亮度(辐射量)} = \text{输入值}^{\gamma}, \quad \gamma \approx 2.2 亮度(辐射量)=输入值γ,γ2.2
意味着:输入 0.5(50% 灰度)实际显示出来的亮度只有 0.5 2.2 ≈ 0.218 0.5^{2.2} \approx 0.218 0.52.20.218,比预期暗得多。
现代 LCD/OLED 也通过内置电路模仿这个曲线(因为传输标准已经建立在此基础上)。

6.2 Gamma 矫正的必要性

计算机图形学的着色计算是在线性空间进行的(加法、乘法符合物理规律),但显示器输出是非线性的。
解决方案:在写入帧缓冲前,对计算结果做逆变换(编码),抵消显示器的非线性:

着色计算(线性空间)
       ↓
   gamma 编码(x^(1/2.2))
       ↓
    帧缓冲存储
       ↓
  显示器(x^2.2)
       ↓
  实际发出的亮度(线性)

两个步骤相互抵消,最终屏幕上的亮度与计算值成正比。

6.3 sRGB 标准

sRGB 是计算机显示器的标准颜色空间,定义了精确的编解码公式。
线性值 → sRGB 编码(存入帧缓冲):
y = f sRGB − 1 ( x ) = { 1.055 x 1 / 2.4 − 0.055 x > 0.0031308 12.92 x x ≤ 0.0031308 y = f^{-1}_{\text{sRGB}}(x) = \begin{cases} 1.055 x^{1/2.4} - 0.055 & x > 0.0031308 \\ 12.92 x & x \leq 0.0031308 \end{cases} y=fsRGB1(x)={1.055x1/2.40.05512.92xx>0.0031308x0.0031308
sRGB 解码 → 线性值(读取纹理时):
x = f sRGB ( y ) = { ( y + 0.055 1.055 ) 2.4 y > 0.04045 y 12.92 y ≤ 0.04045 x = f_{\text{sRGB}}(y) = \begin{cases} \left(\dfrac{y + 0.055}{1.055}\right)^{2.4} & y > 0.04045 \\ \dfrac{y}{12.92} & y \leq 0.04045 \end{cases} x=fsRGB(y)= (1.055y+0.055)2.412.92yy>0.04045y0.04045
简化版(移动端常用):
y = x ( 编码 ) , x = y 2 ( 解码 ) y = \sqrt{x} \quad (\text{编码}), \qquad x = y^2 \quad (\text{解码}) y=x (编码),x=y2(解码)

6.4 数据流示意图

                     ┌────────────────────────────────────────┐
                     │            GPU 渲染管线                 │
   PNG 纹理          │                                         │
 (sRGB 编码)  ──────→│  tex2D() 读取 → sRGB 解码 → 线性值     │
                     │                    ↓                    │
                     │           着色计算(线性空间)           │
                     │                    ↓                    │
                     │         tone mapping(色调映射)         │
                     │                    ↓                    │
                     │          sRGB 编码 → 写入帧缓冲          │
                     └──────────────────┬────────────────────-─┘
                                        │
                                        ↓
                                   显示器(γ≈2.2)
                                        ↓
                               实际发出的线性亮度 ✓

6.5 忽略 Gamma 矫正的后果

  1. 暗部过暗:线性值 0.1 的区域实际只有 0.1 2.2 ≈ 0.01 0.1^{2.2} \approx 0.01 0.12.20.01 的亮度,比应有值暗得多
  2. 光照计算错误:两个光源叠加时( 0.6 + 0.4 = 1.0 0.6 + 0.4 = 1.0 0.6+0.4=1.0),若在非线性空间相加,结果会不物理正确
  3. 抗走样失效(Roping 效应):边缘的灰度值在非线性空间计算后,感知上会扭曲,边缘看起来像绳子(twisted rope)
正确的抗走样(线性空间):
  覆盖率 12.5% 25% 37.5% 50% 62.5% 75% 87.5%
  线性值: 0.125  0.25  0.375  0.5  0.625  0.75  0.875
  → 均匀过渡,边缘平滑
错误的抗走样(非线性空间):
  同样的覆盖率,但在 gamma=2.2 下感知亮度差异不均匀
  → 边缘出现扭曲(Roping)

6.6 实践原则总结


操作 正确做法
读取颜色纹理 转换为线性值(告知 GPU 纹理格式为 sRGB)
读取法线/粗糙度等非颜色纹理 直接使用,不做转换
所有着色、光照、混合计算 在线性空间进行
写入帧缓冲(最后一步) sRGB 编码(告知 GPU 帧缓冲为 sRGB 格式)
后处理(Bloom、模糊等) 在线性帧缓冲上进行(在编码前)

GPU API(OpenGL、DirectX 12、Vulkan)均支持自动 sRGB 转换,只需正确设置纹理格式(如 GL_SRGB8_ALPHA8)和帧缓冲格式即可,不需要手动在着色器中计算。

总结:第五章核心知识体系

第五章:着色基础

着色模型
定义颜色如何随方向变化

光源类型
定义 l 和 c_light 如何取值

实现策略
计算频率划分、材质系统

抗走样
减少采样不足带来的锯齿

透明度
alpha 混合、OIT

显示编码
gamma 矫正、sRGB

点积→方向对齐
插值→颜色过渡
反射→高光计算

方向光:全局常数方向
点光源:距离平方反比衰减
聚光灯:方向衰减+距离衰减

CPU 预计算不变量
逐像素着色为主
着色器变体管理

MSAA:多采样覆盖率
TAA:利用历史帧
FXAA/SMAA:后处理边缘检测

over 运算符:后到前混合
深度剥离:精确但慢
加权 OIT:快速近似

线性空间计算
sRGB 编解码
GPU 自动处理

第六章:纹理(Texturing)

来源:Real-Time Rendering, 4th Edition,第6章

目录

  1. 纹理的核心思想
  2. 纹理管线(Texturing Pipeline)
  3. 图像纹理(Image Texturing)
  4. 程序纹理(Procedural Texturing)
  5. 纹理动画(Texture Animation)
  6. 材质贴图(Material Mapping)
  7. Alpha 贴图(Alpha Mapping)
  8. 凹凸映射(Bump Mapping)
  9. 视差映射(Parallax Mapping)
  10. 纹理光源(Textured Lights)
  11. 总结

1. 纹理的核心思想

1.1 为什么需要纹理?

想象你要渲染一面砖墙。如果真的用几何体去表示每一块砖、每条灰缝,顶点数量会爆炸。纹理的核心思路是:

用一张图片"贴"到简单几何体上,让眼睛误以为表面很复杂。
一面砖墙只需要两个三角形,贴上砖墙照片,近看之前效果就很令人信服。

1.2 纹理能改变什么?

纹理不只能改变颜色,几乎可以改变着色方程中的任何参数:

同一块砖墙,可以同时用三张纹理:
  1. 颜色纹理(albedo map)    → 砖块是红色,灰缝是灰色
  2. 粗糙度纹理(roughness map)→ 砖块光泽,灰缝哑光
  3. 凹凸纹理(bump map)      → 砖块表面看起来凹凸不平

每一张纹理负责修改着色方程中的一个输入参数,合力制造出复杂的视觉效果。

1.3 纹素(Texel)vs 像素(Pixel)

  • 像素(Pixel):屏幕上显示的点
  • 纹素(Texel):纹理图像中的点
    两者经常被混淆,特别是在纹理缩放时需要区分。

2. 纹理管线

2.1 总览

整个纹理系统可以抽象成一条"管线",从空间中的一个点出发,最终得到一个值来修改着色。

物体空间坐标
(x, y, z)

投影函数
Projector Function

纹理坐标
(u, v)

对应函数
Corresponder Function

纹理空间位置
texture-space location

获取纹理值
Obtain Value

原始纹理值

值变换函数
Value Transform

变换后的值
修改表面属性

2.2 第一步:投影函数(Projector Function)

目标:把 3D 空间位置 ( x , y , z ) (x, y, z) (x,y,z) 变成 2D 纹理坐标 ( u , v ) (u, v) (u,v)
这就像拿一张地图贴到地球仪上——三维物体映射到二维平面,不可避免地有各种投影方式。
常见的投影方式:

球形投影(Spherical):
  把物体包在想象中的球里,从球心向外投影
  适合球形物体;两极处会有变形
柱形投影(Cylindrical):
  u 坐标同球形,v 坐标是沿圆柱轴的高度
  适合有旋转对称轴的物体,如花瓶、柱子
平面投影(Planar):
  从一个方向平行投影,像 X 光透视
  正面效果好,侧面严重拉伸
UV 展开(Natural UV):
  艺术家手工/工具辅助,把模型"展开"成平面
  避免变形,是最常用的方式

示意图(四种投影效果的横向对比概念):

球形投影        柱形投影        平面投影        UV展开
                                          (艺术家手工)
  [球面等经纬]   [柱面展开]      [正视平行]     [最优展开]
  两极压缩变形   竖轴方向良好    侧面严重拉伸   变形最小

实际工作流:在建模软件中,艺术家对模型做 UV 展开(UV Unwrapping),将三维网格"剪开展平"成二维。如图 6.6 所示,展开的 UV 线框叠加在纹理图上,帮助艺术家知道每个三角形对应纹理哪个区域。

2.3 第二步:对应函数(Corresponder Function)

目标:把 ( u , v ) ∈ [ 0 , 1 ] (u, v) \in [0, 1] (u,v)[0,1] 范围的纹理坐标映射到纹理的实际像素位置,同时处理超出边界时的行为。
最重要的行为是超出 [ 0 , 1 ] [0, 1] [0,1] 范围时怎么办

模式 DirectX 名称 OpenGL 名称 效果
平铺重复 wrap repeat 图像反复铺满,丢掉整数部分
镜像 mirror mirror 相邻重复之间镜像翻转
截断到边缘 clamp clamp to edge 超出范围的值用边缘颜色填充
截断到边框 border clamp to border 超出范围用单独指定的边框颜色

可视化示意(以 wrapclamp 为例):

原始纹理 [0,1]:   wrap 模式(v 超出 1):  clamp 模式(v 超出 1):
+--------+         +--------+                +--------+
|  图案  |         |  图案  |                |  图案  |
|        |         +--------+                |        |
+--------+         |  图案  |                |--------|  ← 边缘颜色重复
                   +--------+

矩阵变换也是一种对应函数:可以在顶点着色器或像素着色器中对 ( u , v ) (u, v) (u,v) 做平移、旋转、缩放,实现纹理在表面上的动画效果。

2.4 第三步:获取纹理值 & 值变换函数

从纹理中取出颜色(或其他数值)后,有时需要值变换

  • 最常见的是将颜色纹理从 sRGB 编码空间解码到线性空间(参见第五章 Gamma 矫正)
  • 法线纹理:将颜色值从 [ 0 , 1 ] [0, 1] [0,1] 重映射到有符号的 [ − 1 , 1 ] [-1, 1] [1,1] 范围(颜色 128 对应法线分量 0,255 对应 +1,0 对应 -1)

3. 图像纹理

3.1 放大(Magnification)

当纹理分辨率比屏幕像素少时(离得很近看),需要放大纹理——一个纹素要覆盖多个像素。

最近邻(Nearest Neighbor / Box Filter)

最简单:选离采样点最近的那个纹素。
结果:像素化(Pixelation)——正方形格子感,方块感。每次查询仅需取 1 个纹素,速度最快。

双线性插值(Bilinear Interpolation)

取最近的 4 个纹素,在两个方向上做线性插值。
设采样点落在纹素坐标 ( p u , p v ) (p_u, p_v) (pu,pv),四个最近纹素的位置为 ( x , y ) (x,y) (x,y), ( x + 1 , y ) (x+1,y) (x+1,y), ( x , y + 1 ) (x,y+1) (x,y+1), ( x + 1 , y + 1 ) (x+1,y+1) (x+1,y+1),设偏移量 u ′ = p u − x u' = p_u - x u=pux v ′ = p v − y v' = p_v - y v=pvy,则双线性插值结果为:
b ( p u , p v ) = ( 1 − u ′ ) ( 1 − v ′ )   t ( x , y ) + u ′ ( 1 − v ′ )   t ( x + 1 , y ) + ( 1 − u ′ ) v ′   t ( x , y + 1 ) + u ′ v ′   t ( x + 1 , y + 1 ) b(p_u, p_v) = (1-u')(1-v')\,t(x,y) + u'(1-v')\,t(x+1,y) + (1-u')v'\,t(x,y+1) + u'v'\,t(x+1,y+1) b(pu,pv)=(1u)(1v)t(x,y)+u(1v)t(x+1,y)+(1u)vt(x,y+1)+uvt(x+1,y+1)
直觉理解:距离采样点越近的纹素,权重越大。右上角纹素的权重正好是 u ′ × v ′ u' \times v' u×v,它对应的是以左下角为原点、采样点为右上角的小矩形面积。四个权重加起来等于 1。

纹素坐标系(以左下角为原点):
  (x, y+1)-----(x+1, y+1)
      |              |
      |    * 采样点   |   ← u' = 0.42, v' = 0.74
      |              |
  (x, y  )-----(x+1, y  )
权重:
  左下 (x,y)     = (1-0.42)(1-0.74) = 0.58 × 0.26 = 0.151
  右下 (x+1,y)   = 0.42 × (1-0.74) = 0.42 × 0.26 = 0.109
  左上 (x,y+1)   = (1-0.42) × 0.74 = 0.58 × 0.74 = 0.429
  右上 (x+1,y+1) = 0.42 × 0.74    = 0.42 × 0.74 = 0.311
  总和 = 1.000 ✓

结果:模糊(Blurry)——锯齿消失,但细节变软。

双三次插值(Bicubic / Cubic Convolution)

使用最近的 4 × 4 4 \times 4 4×4 5 × 5 5 \times 5 5×5 个纹素,加权求和。质量最高,但开销也最大。
GPU 硬件原生支持双线性插值,双三次插值需要在着色器中手动实现(多次双线性查询叠加)。

平滑插值曲线(Smoothstep / Quintic)

Quilez 提出的方法:在双线性插值的 ( u ′ , v ′ ) (u', v') (u,v) 上先应用平滑曲线,再查询:
s ( x ) = x 2 ( 3 − 2 x ) ( smoothstep ) s(x) = x^2(3 - 2x) \quad (\text{smoothstep}) s(x)=x2(32x)(smoothstep)
q ( x ) = x 3 ( 6 x 2 − 15 x + 10 ) ( quintic ) q(x) = x^3(6x^2 - 15x + 10) \quad (\text{quintic}) q(x)=x3(6x215x+10)(quintic)
这两个函数的关键性质:在 x = 0 x=0 x=0 x = 1 x=1 x=1 处斜率为 0(smoothstep);quintic 还保证二阶导数也为 0,过渡更平滑。

smoothstep s(x):            quintic q(x):
1 |         _____            1 |           ___
  |        /                   |          /
  |       /                    |         /
0 |______/                   0 |________/
  0    0.5    1               0    0.5    1
  (S形曲线,两端平坦)         (更平滑的S形)

3.2 缩小(Minification)与 Mipmap

当纹理比屏幕像素大时(离得很远看),多个纹素需要"浓缩"到一个像素里。这就是缩小问题,也是走样的主要来源。

为什么会走样?

奈奎斯特定理告诉我们:采样率必须大于信号频率的两倍。当纹理很小(远处),纹素频率可能远超像素采样率,导致走样(闪烁、摩尔纹)。

Mipmap(多级渐进纹理)

核心思想:提前把纹理做成一系列越来越小的版本,渲染时根据"纹理在屏幕上显示多大"选合适的级别。
“mip” 来自拉丁语 multum in parvo:小空间里存了很多东西。
Mipmap 构建过程:

Level 0
原始纹理
256×256

Level 1
128×128
(2×2均值)

Level 2
64×64
(2×2均值)

Level 3
32×32

...

Level 8
1×1
(全图平均色)

每层是上一层 2×2 像素取平均(box filter),分辨率减半,如此递归,直到 1×1。
额外存储开销:约为原纹理的 1 / 3 1/3 1/3(等比数列求和: 1 / 4 + 1 / 16 + … = 1 / 3 1/4 + 1/16 + \ldots = 1/3 1/4+1/16+=1/3)。

Mipmap 的访问过程

渲染时,GPU 需要决定用哪个级别(level d d d,也叫 λ \lambda λ):

  • 计算纹理坐标关于屏幕坐标的偏导数: ∂ u / ∂ x \partial u/\partial x u/x ∂ v / ∂ x \partial v/\partial x v/x ∂ u / ∂ y \partial u/\partial y u/y ∂ v / ∂ y \partial v/\partial y v/y
  • 通过这些偏导数估算当前像素覆盖了多少纹素面积
  • d d d 越大 → 选更小、更模糊的级别(远处); d = 0 d=0 d=0 → 原始清晰纹理(近处)
    三线性插值(Trilinear Interpolation)
    不仅在一个 mipmap 级别内双线性插值,还在相邻两个级别之间线性插值,消除级别切换时的突变:
level d=1.3 时:
  从 level 1 双线性采样得到颜色 c1
  从 level 2 双线性采样得到颜色 c2
  最终结果 = lerp(c1, c2, 0.3)   ← 三线性插值

LOD Bias(细节层次偏差):用户可手动调整 d d d 值的偏移量,负值让纹理更清晰,正值更模糊。

Mipmap 的问题:过度模糊

Mipmap 每一级都是正方形区域的平均。但当观察者沿一个方向斜视表面时,像素在纹理空间的投影是一个细长矩形,而 Mipmap 只会取包含这个矩形的最大正方形——结果过于模糊。

像素投影到纹理空间:
  正常视角:  [正方形区域]  ← mipmap 处理好
  斜视:     [   细长矩形   ]  ← mipmap 取了太大的正方形,过度模糊
求和面积表(Summed-Area Table, SAT)

另一种方案:预计算一张辅助纹理,每个位置存储从原点 ( 0 , 0 ) (0,0) (0,0) 到该位置的所有纹素之和。
查询任意矩形区域的均值,可用四角坐标在 O ( 1 ) O(1) O(1) 时间完成:
c = s [ x u r , y u r ] − s [ x u r , y l l ] − s [ x l l , y u r ] + s [ x l l , y l l ] ( x u r − x l l ) ( y u r − y l l ) c = \frac{s[x_{ur}, y_{ur}] - s[x_{ur}, y_{ll}] - s[x_{ll}, y_{ur}] + s[x_{ll}, y_{ll}]}{(x_{ur} - x_{ll})(y_{ur} - y_{ll})} c=(xurxll)(yuryll)s[xur,yur]s[xur,yll]s[xll,yur]+s[xll,yll]
其中 s [ x , y ] s[x,y] s[x,y] 是累积和表的值,四个角分别对应矩形的四个顶点。
思路类比(一维版本):前缀和数组,求区间 [ l , r ] [l, r] [l,r] 的和 = prefix [ r ] − prefix [ l − 1 ] \text{prefix}[r] - \text{prefix}[l-1] prefix[r]prefix[l1]
优点:能正确处理水平和垂直方向的细长矩形。
缺点:斜方向的细长矩形仍然效果差;需要更高精度的存储(16位以上)。

各向异性过滤(Anisotropic Filtering)

最常用的高质量方案:复用 mipmap 硬件,但沿细长四边形的长轴方向采多个样本:

像素在纹理空间的投影四边形(细长):
  +------------------+
  |  ●  ●  ●  ●  ●  |   ← 沿长轴方向采 5 个 mipmap 样本(各向异性比 5:1)
  +------------------+
每个样本用短轴长度决定 mipmap 级别(更清晰),
然后沿长轴方向取多个并平均。

优点:无需额外纹理内存(复用 mipmap 数据),效果远好于简单 mipmap。
性能:各向异性比(Anisotropy Ratio)越高(如 16:1),质量越好,开销也越大。
现代 GPU 默认支持各向异性过滤,游戏中"纹理质量"选项通常就是调这个参数。

3.3 纹理压缩

为什么压缩?
  • 纹理占用大量 GPU 显存
  • 更重要的是:读取纹理时的带宽开销非常大,压缩后的纹理每次访问传输更少数据,从而提速
    GPU 端解压缩极快(固定功能硬件),但压缩本身很慢——这叫做压缩不对称性(Data Compression Asymmetry):压缩可能需要几分钟,解压缩只需微秒。
BC / DXTC 压缩格式

S3TC/DXTC(DirectX)和 BC(Block Compression)是 PC/主机平台的标准。
核心思想:将纹理分成 4 × 4 4 \times 4 4×4 纹素的块(Block),每块独立压缩:

一个 4×4 纹素块(16 个纹素)的 BC1 压缩:
  存储:两个 16 位参考颜色(RGB565 格式)
        16 个纹素各有 2 位索引(从 0~3 中选一个)
  解压:2 个参考颜色 + 2 个插值中间色 = 4 个可用颜色
        每个纹素的颜色 = 4 个颜色之一
  压缩率:16 纹素 × 3 字节/纹素 = 48 字节
          压缩后 = 8 字节(4 bpt)
          压缩比 = 6:1

格式 存储 用途 说明
BC1/DXT1 4 bpt 不透明 RGB 最基础,6:1 压缩
BC2/DXT3 8 bpt RGBA(粗量化alpha) alpha 4bpt 直接存
BC3/DXT5 8 bpt RGBA(平滑alpha) alpha 插值编码,更好
BC4 4 bpt 单通道(灰度/粗糙度) 如同 BC3 的 alpha
BC5/3Dc 8 bpt 双通道(法线 XY) 两个 BC4
BC6H 8 bpt HDR 纹理(浮点RGB) 高动态范围
BC7 8 bpt 高质量 LDR RGBA 最高质量,灵活

BC1 的特点:16 个纹素的颜色只能落在 RGB 空间中一条直线上的 4 个点。这意味着红/绿/蓝同时出现在一个块里时效果差。BC6H/BC7 支持多条线,质量更高。

法线贴图的压缩

法线是三维向量 ( n x , n y , n z ) (n_x, n_y, n_z) (nx,ny,nz),但由于 ∣ n ∣ = 1 |\mathbf{n}|=1 n=1,已知 x x x y y y 就能还原 z z z
n z = 1 − n x 2 − n y 2 n_z = \sqrt{1 - n_x^2 - n_y^2} nz=1nx2ny2
因此只存 x , y x, y x,y 两个分量(使用 BC5/3Dc), z z z 在着色器里实时计算。这既节省空间,又利用了法线的特性。

ETC/ETC2 格式

OpenGL ES 移动端标准:

  • ETC1:每个 2 × 4 2 \times 4 2×4 子块存一个基础颜色 + 每纹素从小表中选亮度调整量
  • ETC2:在 ETC1 基础上增加更多压缩模式(利用 ETC1 中未使用的位组合)
  • EAC:单通道压缩,配合 ETC2 可压缩法线 XY
ASTC 格式

更灵活的现代标准:块大小从 4 × 4 4\times4 4×4 12 × 12 12\times12 12×12 可选,对应 0.89 到 8 bpt,支持 LDR/HDR/1~4 通道,已成为 OpenGL ES 3.2+ 标准。

YCoCg 颜色空间变换

纹理压缩还可以先把 RGB 转换到亮度-色度空间 YCoCg 再压缩,因为人眼对亮度变化更敏感,分离后压缩效果更好:
( Y C o C g ) = ( 1 / 4 1 / 2 1 / 4 1 / 2 0 − 1 / 2 − 1 / 4 1 / 2 − 1 / 4 ) ( R G B ) \begin{pmatrix} Y \\ C_o \\ C_g \end{pmatrix} = \begin{pmatrix} 1/4 & 1/2 & 1/4 \\ 1/2 & 0 & -1/2 \\ -1/4 & 1/2 & -1/4 \end{pmatrix} \begin{pmatrix} R \\ G \\ B \end{pmatrix} YCoCg = 1/41/21/41/201/21/41/21/4 RGB
逆变换(shader 中还原):
t = Y − C g , G = Y + C g , R = t + C o , B = t − C o t = Y - C_g, \quad G = Y + C_g, \quad R = t + C_o, \quad B = t - C_o t=YCg,G=Y+Cg,R=t+Co,B=tCo

3.4 体纹理(Volume Textures)

三维的纹理,用 ( u , v , w ) (u, v, w) (u,v,w) 三个坐标访问。
优势

  • 无需做复杂的 UV 展开,直接用 3D 坐标索引
  • 适合表示"材质从哪里切开都一样"的情况,如大理石、木材
    劣势:存储量 = 2 D 2D 2D 纹理的 O ( n ) O(n) O(n) 倍,大多数位置根本用不到(只有表面才被渲染)

3.5 立方体贴图(Cube Map)

6 张正方形纹理,对应一个立方体的 6 个面。访问方式:用一个三维方向向量 ( d x , d y , d z ) (d_x, d_y, d_z) (dx,dy,dz) 确定选哪个面,再转换为该面的 UV 坐标。
访问规则:绝对值最大的分量决定选哪个面,另外两个分量除以该绝对值映射到 [ − 1 , 1 ] [-1, 1] [1,1] 再转到 [ 0 , 1 ] [0, 1] [0,1]
最常用于:环境贴图(Environment Mapping),把周围环境"烘焙"进一个立方体贴图,实现反射效果。

3.6 纹理图集、数组与无绑定纹理

GPU 频繁切换绑定的纹理会有额外开销(状态切换)。以下技术减少切换:
纹理图集(Texture Atlas):把多个小纹理拼合到一张大纹理里,渲染时用不同的 UV 范围访问不同子纹理。缺点:wrap/repeat 模式无法对单个子纹理生效;mipmap 生成复杂。
纹理数组(Texture Array):API 层级的支持,将多个相同尺寸/格式的纹理作为数组存储,shader 里用整数索引访问。比纹理图集更干净,mipmap 也自动正确处理。比逐个绑定快约 5 倍。
无绑定纹理(Bindless Textures):每个纹理用一个 64 位句柄(指针)表示,shader 可直接通过句柄访问,绕过绑定限制。无上限限制,零绑定开销。

4. 程序纹理(Procedural Texturing)

不从图像文件读取,而是在着色器里实时计算纹理值的函数。

4.1 噪声函数(Noise Functions)

最常用的程序纹理基础:Perlin Noise。
核心思想:在空间中的晶格点预计算随机梯度,任意位置的值通过插值得到。连续、平滑、视觉上"自然"。
湍流函数(Turbulence):将不同频率的噪声加权叠加:
turbulence ( p ) = ∑ k = 0 n 1 2 k ⋅ noise ( 2 k ⋅ p ) \text{turbulence}(\mathbf{p}) = \sum_{k=0}^{n} \frac{1}{2^k} \cdot \text{noise}(2^k \cdot \mathbf{p}) turbulence(p)=k=0n2k1noise(2kp)
高频成分权重小,低频成分权重大,形成分形状的自然纹理(云、大理石纹)。

4.2 元胞纹理(Cellular Textures)

测量空间中每个点到若干"特征点"的距离,将距离映射成颜色。可以生成:皮肤、砖块、龟裂地面等图案。

4.3 程序纹理的优缺点


特点 说明
无限分辨率 任意放大都不会模糊
无参数化问题 不依赖 UV 展开
可动态变化 水波纹、裂缝扩散等
抗走样可控 已知频率,可以精确去掉高频
GPU 成本 计算量比图像查询大
调试困难 不直观

5. 纹理动画(Texture Animation)

纹理不需要是静态的:

  1. 视频纹理:每帧更新纹理图像(如视频播放、实时摄像头)
  2. UV 动画:每帧偏移纹理坐标,模拟流动效果(瀑布:每帧减小 v v v 坐标)
  3. 矩阵变换:对 UV 施加旋转、缩放、剪切,实现更复杂的动画
  4. 混合过渡:在两张纹理之间渐变(如雕像变成肉身)

6. 材质贴图(Material Mapping)

6.1 什么是材质贴图?

将纹理用于修改着色方程的输入参数,而不仅仅是颜色:

  • 漫反射/反照率贴图(Albedo/Diffuse Map):最基础,修改 c surface c_{\text{surface}} csurface
  • 粗糙度贴图(Roughness Map):修改表面粗糙度参数
  • 高度贴图(Heightfield):控制视差效果
  • 遮蔽贴图(AO Map):预计算环境光遮蔽,让缝隙变暗
    几乎所有物理着色(PBR)参数都可以来自纹理。

6.2 线性 vs 非线性参数

  • 颜色:与最终输出颜色线性相关 → 可以直接用标准 mipmap 过滤
  • 法线、粗糙度:与最终颜色非线性相关 → 标准过滤可能产生走样,需要特殊方法(第9章讨论)

7. Alpha 贴图(Alpha Mapping)

7.1 透明度的用途

Alpha 纹理(透明度贴图)的常见应用:

  1. 贴花(Decal):把一个图案贴到物体表面,透明区域不覆盖原来颜色
  2. 镂空(Cutout):用一张矩形面模拟有复杂轮廓的物体(如树叶、灌木)
    十字树(Cross Tree):两张互相垂直的矩形,各贴同一张灌木图(含 alpha),从侧面看有厚度感,适合远景树木。
从上方看"十字树":
    |
    |  ← 第二个矩形(旋转90度)
    |
----+----  ← 第一个矩形
    |
正面效果不错,从正上方看会穿帮。

7.2 Alpha 测试(Alpha Testing)

最简单的方法:直接丢弃(discard)透明度低于阈值的片元:

// GLSL 片元着色器中
if (texture.a < alphaThreshold) discard;

优点:不需要排序,任意顺序渲染都正确
缺点:只有"完全透明"/"完全不透明"两个状态,边缘有锯齿

7.3 Alpha 测试 + Mipmap 的问题

当 mipmap 缩小纹理时,原来稀疏的不透明区域被平均了,alpha 值降低,导致远处物体变得比实际更透明(叶子消失)。
Castano 的解决方案:在生成 mipmap 时,对每个级别调整 alpha 值的缩放,保持各级别的"覆盖率"(即通过 alpha 测试的纹素比例)一致:
对第 k k k 级,计算覆盖率 c k c_k ck
c k = 1 n k ∑ i [ α ( k , i ) > α t ] c_k = \frac{1}{n_k} \sum_i \left[\alpha_{(k,i)} > \alpha_t\right] ck=nk1i[α(k,i)>αt]
找到新阈值 α k \alpha_k αk 使 c k = c 0 c_k = c_0 ck=c0,然后将该级别所有 alpha 值乘以 α t / α k \alpha_t / \alpha_k αt/αk

7.4 随机 Alpha 测试(Stochastic Transparency)

另一种思路:用随机(或哈希)函数替代固定阈值:

// 用随机值替代固定阈值:
if (texture.a < random()) discard;
// 等价于:alpha=0.3 的片元有 70% 概率被丢弃

哈希函数(基于位置,避免随机噪点):

float hash2D(float x, float y) {
    return fract(1.0e4 * sin(17.0*x + 0.1*y) * (0.1 + abs(sin(13.0*y + x))));
}

优点:每个片元平均而言都是正确的;
缺点:有噪点,需配合 TAA(时间抗走样)使用。

7.5 Alpha to Coverage

MSAA 硬件支持的模式:把 alpha 值(覆盖率)直接映射到 MSAA 的样本覆盖掩码。
例如 alpha = 0.75 且有 4 个 MSAA 样本 → 片元以"完全不透明"的方式覆盖其中 3 个样本。
优点:不需要排序,边缘质量好;
缺点:两个 alpha 相同的片元用相同的掩码,相互覆盖而非混合。

7.6 双线性插值与预乘 Alpha 的黑边问题

问题来源:当 GPU 在边缘处做双线性插值时,一个纯红(alpha=1)的纹素和一个纯透明(alpha=0, rgb=黑色)的纹素插值,结果颜色会偏黑。
原因:透明纹素的 rgb 通常存储为黑色 ( 0 , 0 , 0 ) (0,0,0) (0,0,0),插值时把这个黑色"带入"了不透明区域的边缘。
解决方案

  1. 预先将透明区域的颜色"涂抹"(Inpainting):把透明纹素的颜色设置为附近不透明纹素的颜色,虽然 alpha=0,但 rgb 值不是黑色
  2. 使用预乘 Alpha(Premultiplied Alpha):存储 ( α R , α G , α B , α ) (\alpha R, \alpha G, \alpha B, \alpha) (αR,αG,αB,α),插值后结果天然是预乘形式,不会产生黑边

8. 凹凸映射(Bump Mapping)

8.1 三种细节尺度

几何细节按观察者感知的尺度分三类:

宏观几何(Macro):  三角形、多边形构成的整体形状(头、手臂)
中观几何(Meso):   几个像素宽的细节(皮肤皱纹、砖块凹凸)← Bump Mapping 负责这里
微观几何(Micro):  比一个像素小的细节,由着色模型的材质参数表达(光泽/粗糙)

Bump Mapping 的核心 trick:不改变几何体的实际形状,只修改着色时用的法线方向。法线方向变了,光照计算就好像表面真的凹凸不平。
就像一张白纸,不同方向打光会有阴影变化,让人误以为有起伏。

8.2 切线空间(Tangent Space)

法线要相对于某个坐标系才有意义。Bump Mapping 使用切线空间(Tangent Space),这个坐标系随着表面弯曲而弯曲:

在表面上某点的切线坐标系:
        n (法线方向,垂直表面)
        |
        |
--------+-------- 表面
       / \
      b   t
   (副切线) (切线,沿u轴方向)
  • t \mathbf{t} t(tangent):沿 u u u 纹理轴方向
  • b \mathbf{b} b(bitangent,错误叫做 binormal):沿 v v v 纹理轴方向
  • n \mathbf{n} n(normal):垂直于表面
    TBN 矩阵(切线→世界空间的变换矩阵):
    TBN = ( t x t y t z 0 b x b y b z 0 n x n y n z 0 0 0 0 1 ) \text{TBN} = \begin{pmatrix} t_x & t_y & t_z & 0 \\ b_x & b_y & b_z & 0 \\ n_x & n_y & n_z & 0 \\ 0 & 0 & 0 & 1 \end{pmatrix} TBN= txbxnx0tybyny0tzbznz00001
    这个矩阵把世界空间的光线方向变换到切线空间,或者把切线空间的法线变换到世界空间。
    为什么用切线空间?
  • 纹理可以应用于任意朝向的表面(旋转、变形后仍然正确)
  • 切线空间法线贴图可以在不同模型间复用
  • 对称模型可以在两侧镜像复用同一张法线贴图
    存储优化:有时只存 t \mathbf{t} t b \mathbf{b} b n = t × b \mathbf{n} = \mathbf{t} \times \mathbf{b} n=t×b(叉积);或者存储为四元数(更省内存)。对称模型需要额外存一个符号位(handedness)。

8.3 Blinn 原始方法

Blinn 1978 年提出:在纹理中存储两个有符号值 ( b u , b v ) (b_u, b_v) (bu,bv),表示法线沿 u u u v v v 方向的偏移量。这种纹理叫偏移向量凹凸贴图(Offset Vector Bump Map)
也可以改用高度场(Heightfield):纹理中存储高度值(黑 = 低,白 = 高),然后通过计算相邻高度差得到法线偏移:
h x ( x , y ) = h ( x + 1 , y ) − h ( x − 1 , y ) 2 , h y ( x , y ) = h ( x , y + 1 ) − h ( x , y − 1 ) 2 h_x(x,y) = \frac{h(x+1,y) - h(x-1,y)}{2}, \quad h_y(x,y) = \frac{h(x,y+1) - h(x,y-1)}{2} hx(x,y)=2h(x+1,y)h(x1,y),hy(x,y)=2h(x,y+1)h(x,y1)
未归一化的法线:
n ( x , y ) = ( − h x ( x , y ) ,    − h y ( x , y ) ,    1 ) \mathbf{n}(x,y) = (-h_x(x,y),\; -h_y(x,y),\; 1) n(x,y)=(hx(x,y),hy(x,y),1)

8.4 法线贴图(Normal Mapping)

最常用的凹凸映射形式:直接存储扰动后的法线向量
颜色通道的编码:

  • RGB ∈ [ 0 , 255 ] \text{RGB} \in [0, 255] RGB[0,255] → 法线分量 ∈ [ − 1 , 1 ] \in [-1, 1] [1,1]
  • 规则: n x = ( R − 128 ) / 127 n_x = (R - 128)/127 nx=(R128)/127 n y = ( G − 128 ) / 127 n_y = (G - 128)/127 ny=(G128)/127 n z = ( B − 128 ) / 127 n_z = (B - 128)/127 nz=(B128)/127
  • 一张"平坦"法线贴图的颜色是 ( 128 , 128 , 255 ) (128, 128, 255) (128,128,255),对应法线 ( 0 , 0 , 1 ) (0, 0, 1) (0,0,1)(纯蓝色)
    示意(法线贴图颜色含义):
法线贴图 RGB 含义:
  红色(R高)→ 法线偏右(+x)
  绿色(G高)→ 法线偏上(+y)
  蓝色(B高)→ 法线朝外(+z,大部分区域)
  纯蓝色区域 → 法线完全朝外,即平坦区域

法线贴图的过滤问题:法线与最终颜色的关系不是线性的(有 clamp 操作),简单的 mipmap 平均会在远处引起高光闪烁(sparkle artifact)。第9章将讨论专门的法线贴图过滤方法。
从高度图生成法线贴图
h x ( x , y ) = h ( x + 1 , y ) − h ( x − 1 , y ) 2 , h y ( x , y ) = h ( x , y + 1 ) − h ( x , y − 1 ) 2 h_x(x,y) = \frac{h(x+1,y) - h(x-1,y)}{2}, \quad h_y(x,y) = \frac{h(x,y+1) - h(x,y-1)}{2} hx(x,y)=2h(x+1,y)h(x1,y),hy(x,y)=2h(x,y+1)h(x,y1)
n ( x , y ) = normalize ( − h x ,    − h y ,    1 ) \mathbf{n}(x,y) = \text{normalize}(-h_x,\; -h_y,\; 1) n(x,y)=normalize(hx,hy,1)

8.5 完整实现代码(法线贴图着色)

以下是一个完整可运行的 C++ 程序,演示 CPU 端模拟法线贴图着色的核心计算(实际渲染在 GPU 的像素着色器中进行):

// ============================================================
// 文件:normal_mapping_demo.cpp
// 说明:演示切线空间法线贴图的核心计算逻辑
//       模拟 GPU 像素着色器中的计算过程
// ============================================================
#include <cmath>
#include <algorithm>
#include <iostream>
#include <array>
// ============================================================
// 基础向量类
// ============================================================
struct Vec3 {
    float x, y, z;
    Vec3() : x(0), y(0), z(0) {}
    Vec3(float x, float y, float z) : x(x), y(y), z(z) {}
    Vec3 operator+(const Vec3& o) const { return {x+o.x, y+o.y, z+o.z}; }
    Vec3 operator-(const Vec3& o) const { return {x-o.x, y-o.y, z-o.z}; }
    Vec3 operator*(float s)        const { return {x*s, y*s, z*s}; }
    // 点积
    float dot(const Vec3& o) const { return x*o.x + y*o.y + z*o.z; }
    // 叉积
    Vec3 cross(const Vec3& o) const {
        return { y*o.z - z*o.y,
                 z*o.x - x*o.z,
                 x*o.y - y*o.x };
    }
    // 归一化
    Vec3 normalize() const {
        float len = std::sqrt(x*x + y*y + z*z);
        if (len < 1e-6f) return {0,0,1};  // 避免除以零,返回默认法线
        return {x/len, y/len, z/len};
    }
    float length() const { return std::sqrt(x*x + y*y + z*z); }
};
// ============================================================
// 从法线贴图颜色(0~255 整数)解码法线向量
// 法线贴图存储规则:颜色 128 -> 法线分量 0,255 -> +1,0 -> -1
// ============================================================
Vec3 decodeNormal(unsigned char r, unsigned char g, unsigned char b) {
    // 将 [0, 255] 映射到 [-1, 1]
    float nx = (r - 128.0f) / 127.0f;
    float ny = (g - 128.0f) / 127.0f;
    float nz = (b - 128.0f) / 127.0f;
    return Vec3(nx, ny, nz).normalize();  // 重新归一化(因为存储时有精度损失)
}
// ============================================================
// 从高度场图像(灰度)生成法线(中心差分方法)
// h 是一个简化的 2D 灰度高度场,宽 width、高 height
// ============================================================
Vec3 normalFromHeightField(
    const std::array<float, 9>& h,  // 3×3 高度值示例(中心为 (1,1))
    float scale                      // 高度缩放系数(越大凹凸越明显)
) {
    // 中心差分:计算 x 和 y 方向的斜率
    // 对应公式:h_x = (h(x+1,y) - h(x-1,y)) / 2
    float hx = (h[5] - h[3]) / 2.0f;   // h[3]=左,h[5]=右(行优先,3×3网格)
    float hy = (h[7] - h[1]) / 2.0f;   // h[1]=上,h[7]=下
    // 未归一化法线:(-h_x * scale, -h_y * scale, 1)
    // 负号是因为斜面朝右(h_x > 0)时,法线应该朝左偏
    return Vec3(-hx * scale, -hy * scale, 1.0f).normalize();
}
// ============================================================
// TBN 矩阵:将切线空间向量变换到世界空间
// 参数:切线 t、副切线 b、法线 n(都是世界空间的)
// ============================================================
Vec3 tangentToWorld(const Vec3& v, const Vec3& t, const Vec3& b, const Vec3& n) {
    // TBN 矩阵:列向量是 t, b, n
    // world = t * v.x + b * v.y + n * v.z
    return t * v.x + b * v.y + n * v.z;
}
// ============================================================
// 主着色函数:使用法线贴图进行光照计算
// 模拟 GPU 像素着色器的逻辑
// ============================================================
Vec3 shadeWithNormalMap(
    // 顶点插值输入
    const Vec3& worldPos,         // 当前像素的世界空间位置
    const Vec3& vertexNormal,     // 插值后的几何法线(需要重新归一化)
    const Vec3& vertexTangent,    // 插值后的切线
    // Uniform 输入(CPU 设置)
    const Vec3& lightPos,         // 光源位置
    const Vec3& cameraPos,        // 相机位置
    const Vec3& lightColor,       // 光源颜色
    const Vec3& surfaceColor,     // 表面基础颜色
    // 法线贴图的采样结果(已解码为 [-1,1] 的切线空间法线)
    const Vec3& tangentNormal     // 从法线贴图读取并解码的切线空间法线
) {
    // 1. 重新归一化(插值后长度可能改变)
    Vec3 n = vertexNormal.normalize();   // 几何法线
    Vec3 t = vertexTangent.normalize();  // 切线
    // 2. Gram-Schmidt 正交化:确保切线垂直于法线
    //    (因为插值可能破坏正交性)
    t = (t - n * t.dot(n)).normalize();
    // 3. 计算副切线(假设右手系,handedness=+1)
    Vec3 b = n.cross(t);
    // 4. 将切线空间法线变换到世界空间
    Vec3 worldNormal = tangentToWorld(tangentNormal, t, b, n).normalize();
    // 5. 计算光照向量(从表面指向光源)
    Vec3 l = (lightPos - worldPos).normalize();
    // 6. 计算视线向量(从表面指向相机)
    Vec3 v = (cameraPos - worldPos).normalize();
    // 7. Lambert 漫反射项:(n · l),截断到 [0, 1]
    float NdL = std::max(0.0f, worldNormal.dot(l));
    // 8. Blinn-Phong 高光:使用半程向量 h = normalize(l + v)
    Vec3 h = (l + v).normalize();
    float NdH = std::max(0.0f, worldNormal.dot(h));
    float specular = std::pow(NdH, 64.0f);  // 幂次越大,高光越锐利
    // 9. 合并:漫反射 + 高光
    Vec3 diffuse  = surfaceColor  * (lightColor.x * NdL);  // 简化:只用 R 通道演示
    Vec3 spec     = Vec3(1,1,1)   * (specular * 0.3f);
    return Vec3(
        std::min(1.0f, diffuse.x + spec.x),
        std::min(1.0f, diffuse.y + spec.y),
        std::min(1.0f, diffuse.z + spec.z)
    );
}
int main() {
    std::cout << "=== 法线贴图演示 ===" << std::endl;
    // --- 示例1:从法线贴图颜色解码法线 ---
    std::cout << "\n--- 法线颜色解码 ---" << std::endl;
    // 平坦区域(纯蓝色 128,128,255 → 法线 (0,0,1))
    Vec3 flatNormal = decodeNormal(128, 128, 255);
    std::cout << "平坦法线: (" << flatNormal.x << ", " << flatNormal.y << ", " << flatNormal.z << ")" << std::endl;
    // 偏右的法线(高红色分量)
    Vec3 rightNormal = decodeNormal(220, 128, 200);
    std::cout << "偏右法线: (" << rightNormal.x << ", " << rightNormal.y << ", " << rightNormal.z << ")" << std::endl;
    // --- 示例2:从高度场生成法线 ---
    std::cout << "\n--- 高度场转法线 ---" << std::endl;
    // 3×3 高度场(中间高,周围低)
    std::array<float, 9> heights = {
        0.0f, 0.0f, 0.0f,  // 上排
        0.0f, 1.0f, 0.0f,  // 中排(中间是凸起)
        0.0f, 0.0f, 0.0f   // 下排
    };
    Vec3 heightNormal = normalFromHeightField(heights, 2.0f);
    std::cout << "高度场法线(中心凸起): ("
              << heightNormal.x << ", " << heightNormal.y << ", " << heightNormal.z << ")" << std::endl;
    // 斜坡(左低右高)
    std::array<float, 9> slope = {
        0.0f, 0.3f, 0.6f,
        0.0f, 0.3f, 0.6f,
        0.0f, 0.3f, 0.6f
    };
    Vec3 slopeNormal = normalFromHeightField(slope, 1.0f);
    std::cout << "斜坡法线(左低右高): ("
              << slopeNormal.x << ", " << slopeNormal.y << ", " << slopeNormal.z << ")" << std::endl;
    // --- 示例3:使用法线贴图进行完整着色 ---
    std::cout << "\n--- 完整着色计算 ---" << std::endl;
    // 表面的几何信息(平坦朝上的面)
    Vec3 worldPos(0.0f, 0.0f, 0.0f);
    Vec3 geoNormal(0.0f, 1.0f, 0.0f);    // 几何法线:朝上
    Vec3 tangent(1.0f, 0.0f, 0.0f);      // 切线:朝右(X 方向)
    // 场景参数
    Vec3 lightPos(2.0f, 3.0f, 2.0f);     // 光源在右上方
    Vec3 cameraPos(0.0f, 2.0f, 5.0f);    // 相机在前上方
    Vec3 lightColor(1.0f, 1.0f, 1.0f);   // 白色光
    Vec3 surfaceColor(0.8f, 0.4f, 0.2f); // 橙色表面
    // 情形A:使用几何法线(无法线贴图)
    Vec3 noMap = shadeWithNormalMap(worldPos, geoNormal, tangent,
                                    lightPos, cameraPos, lightColor, surfaceColor,
                                    Vec3(0,0,1));  // 切线空间里的"平坦"法线
    std::cout << "无法线贴图着色结果: ("
              << noMap.x << ", " << noMap.y << ", " << noMap.z << ")" << std::endl;
    // 情形B:使用偏斜法线(有凹凸)
    Vec3 bumpNormal = Vec3(0.5f, 0.3f, 0.8f).normalize();  // 切线空间中偏向右上的法线
    Vec3 withMap = shadeWithNormalMap(worldPos, geoNormal, tangent,
                                      lightPos, cameraPos, lightColor, surfaceColor,
                                      bumpNormal);
    std::cout << "有法线贴图着色结果: ("
              << withMap.x << ", " << withMap.y << ", " << withMap.z << ")" << std::endl;
    return 0;
}

9. 视差映射(Parallax Mapping)

9.1 法线贴图的不足

法线贴图只修改法线,不修改纹理坐标。问题:

  • 视差问题:从斜侧面看砖墙,砖块应该遮挡住后面的灰缝,但法线贴图永远不会遮挡
  • 无自遮挡:凸起的部分不能遮挡旁边的凹陷
  • 无自阴影:高处不能在低处投影

9.2 基础视差映射

核心思想:利用高度场,根据视线角度移动采样坐标,让纹理"看起来"有深度。

视线方向(切线空间):
  v = (vxy_x, vxy_y, vz)
      ↑水平分量          ↑垂直分量
当前位置 p 处采样高度 h,
视差偏移:
  p_adj = p + h * vxy / vz
直觉:越倾斜(vz 小),偏移越大;高度越大,偏移越大

精确版公式:
p adj = p + h ⋅ v x y v z p_{\text{adj}} = p + \frac{h \cdot \mathbf{v}_{xy}}{v_z} padj=p+vzhvxy
问题:当视线几乎平行于表面时( v z → 0 v_z \to 0 vz0),除法发散,产生严重错误。

9.3 偏移限制视差映射(Offset Limiting)

Welsh 改进版:限制最大偏移量不超过高度本身:
p adj ′ = p + h ⋅ v x y p'_{\text{adj}} = p + h \cdot \mathbf{v}_{xy} padj=p+hvxy
去掉了 / v z /v_z /vz,更稳定。含义:偏移量最多等于高度,定义了一个以高度为半径的圆,偏移不超过圆的边界。

正视(vz≈1):       斜视(vz≈0):
  偏移量 ≈ h * vxy   偏移量被限制,不再发散
  几乎等于精确版本    牺牲一些准确性,换取稳定性

9.4 视差遮挡映射(Parallax Occlusion Mapping, POM)

核心思想:沿着视线方向步进,直到找到与高度场的交点。
步骤:

从像素对应的表面点出发

在切线空间中确定视线方向

沿视线投影到高度场,等间距采样若干点(如16步)

每个采样点查询高度场值
比较射线高度 vs 高度场高度

找到第一个射线
低于高度场的点?

该点与上一点之间做插值
(秦鼓法/二分查找)

以插值位置采样颜色、法线贴图等

正常着色,含自遮挡/自阴影支持

视线步进示意:

视线(从左上进入):
                        ●  ← 视线起点(表面上方)
                       /
                      / ● ← 第一步采样,射线在高度场上方(OK)
                     /
                    / ● ← 第二步,射线仍在上方(OK)
  高度场          _/ ●  ← 第三步,射线穿入高度场!
  _______________/        ← 找到了交叉点,在第2步和第3步之间插值

交叉点精化方法:

  • 割线法(Secant Method):利用上下两点的高度差线性插值求根,一步迭代
  • 二分查找(Binary Search):在找到的区间内反复二分,精度更高但需要更多次查询
  • 简单方案:直接用第一个穿入点,速度最快
    关键参数:步数越多,越接近正确结果。视线越斜,需要步数越多(否则可能跳过细节)。
    POM 的局限:在物体轮廓边缘处失真——渲染的像素范围由三角形决定,但 POM 假装里面有深度,轮廓仍然是三角形的直边,无法真正改变轮廓形状。
    对比效果(概念示意):
法线贴图(无视差):        POM(有视差遮挡):
  ________                  ________
  砖块看起来平坦              砖块之间有明显深度感
  灰缝不遮挡砖块              砖块能遮挡相邻的灰缝
  无自阴影                   有自阴影
  轮廓是直线                 轮廓仍是直线(局限)

10. 纹理光源(Textured Lights)

纹理不只用于表面,也可以用于定义光源的形状和分布:

  • 投影纹理(Projective Texture):聚光灯投影一张纹理图案,像幻灯机(gobo/cookie light)
  • 立方体贴图:全向光(点光源)用 cube map 控制各方向的强度分布
  • IES 配置文件:真实灯具的测量数据,作为纹理存储角度分布,还原真实灯光效果
  • 体纹理:3D 纹理控制光在体积中的分布(光束效果)

11. 总结

11.1 整章知识脉络

纹理(Texturing)

纹理管线
投影→坐标→采样→变换

图像纹理技术
放大/缩小/压缩

特殊纹理类型
体纹理/立方体贴图/数组

纹理的用途

放大:双线性/双三次

缩小:Mipmap/SAT/各向异性

压缩:BC1-BC7/ETC/ASTC

材质贴图
颜色/粗糙度/AO

Alpha 贴图
镂空/贴花/Alpha测试

凹凸映射
法线贴图/切线空间

视差映射
视差/POM/浮雕映射

纹理光源
投影/IES/体纹理

11.2 核心概念速查


技术 解决的问题 代价
双线性插值 放大时的像素化 略模糊
三线性 Mipmap 缩小时的走样/闪烁 远处过度模糊
各向异性过滤 Mipmap 斜面过度模糊 带宽增加
BC 纹理压缩 显存和带宽占用大 有损,有精度损失
法线贴图 几何面少但需要细节 无轮廓变化
视差映射 法线贴图缺乏深度感 轮廓仍不变
POM 视差映射遮挡/阴影问题 多次纹理查询,开销大
Alpha 测试 镂空物体的排序问题 边缘锯齿
Alpha to Coverage 镂空边缘抗走样 需 MSAA 支持

11.3 纹理过滤质量对比

距离(远→近)         过滤质量
远  ●───────────────────────────────────────●  近
    最近邻           mipmap         各向异性      原始纹素
    (锯齿)      (远处模糊)    (最佳平衡)    (清晰)

11.4 Gamma 矫正与 Mipmap 的重要提示

生成 Mipmap 必须在线性空间进行,否则平均颜色会偏暗(因为 sRGB 编码空间中直接平均是错误的):

  1. 读取 sRGB 颜色纹理 → 解码到线性空间
  2. 在线性空间做 2 × 2 2\times2 2×2 平均
  3. 将结果编码回 sRGB 存储
    现代 GPU API 在指定纹理格式为 GL_SRGB8_ALPHA8 等时会自动处理这个问题。
Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐