real-time rendering 第四版学习:第五章:着色基础(Shading Basics)
来源:Real-Time Rendering, 4th Edition,第5章
目录
- 着色模型(Shading Models)
- 光源类型(Light Sources)
- 着色模型的实现(Implementing Shading Models)
- 走样与抗走样(Aliasing and Antialiasing)
- 透明度、Alpha 与合成(Transparency, Alpha, and Compositing)
- 显示编码(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=s⋅chighlight+(1−s)[t⋅cwarm+(1−t)⋅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.25⋅csurface
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.25⋅csurface
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(n⋅l)+1
r = 2 ( n ⋅ l ) n − l \mathbf{r} = 2(\mathbf{n} \cdot \mathbf{l})\mathbf{n} - \mathbf{l} r=2(n⋅l)n−l
s = ( 100 ( r ⋅ v ) − 97 ) + s = \left(100(\mathbf{r} \cdot \mathbf{v}) - 97\right)_{+} s=(100(r⋅v)−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 n⋅l=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 r⋅v≈1, s s s 接近 1,表面显示高光。
×100 - 97是为了让高光只在非常对齐时才出现(很窄的高光)。
1.5 常见着色操作总结
| 操作 | 数学形式 | 用途 |
|---|---|---|
| 点积(Dot Product) | a ⋅ b = cos θ \mathbf{a} \cdot \mathbf{b} = \cos\theta a⋅b=cosθ(单位向量时) | 衡量两方向对齐程度 |
| 线性插值(Lerp) | t ⋅ c a + ( 1 − t ) ⋅ c b t \cdot c_a + (1-t) \cdot c_b t⋅ca+(1−t)⋅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(n⋅l)n−l | 计算镜面反射方向 |
| 归一化(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=1∑nclighti⋅flit(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=1∑n(li⋅n)+⋅clighti⋅flit(li,n,v)
( l ⋅ n ) + (\mathbf{l} \cdot \mathbf{n})_+ (l⋅n)+ 中的 ( ) + (\ )_+ ( )+ 把负值截为 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=1∑n(li⋅n)+⋅clighti⋅csurface
这就是著名的 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=∣plight−p0∣plight−p0
这就是向量归一化的典型应用:方向向量 = 差向量 / 差向量长度。
中间步骤(明确计算距离 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=plight−p0,r=d⋅d,l=rd
技巧: d ⋅ d = ∣ d ∣ 2 \mathbf{d} \cdot \mathbf{d} = |\mathbf{d}|^2 d⋅d=∣d∣2,所以 r = d ⋅ d r = \sqrt{\mathbf{d} \cdot \mathbf{d}} r=d⋅d,避免了 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 r→0 时趋向无穷大)
解决方案一(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)=clight0⋅r2+ϵ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)=clight0⋅r2+ϵr02⋅fwin(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=clight0⋅fdist(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θp−cosθucosθs−cosθ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(3−2t)
smoothstep是 [ 0 , 1 ] [0,1] [0,1] 之间的三次多项式平滑插值,在着色语言中是内置函数。
3. 着色模型的实现
3.1 计算频率划分
着色计算不都需要在 GPU 上逐像素运行。根据计算结果变化的频率,可以分配到不同阶段:
原则:能早算的就早算,不变的量放到 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=1∑n(li⋅n)+⋅clighti⋅[si⋅chighlight+(1−si)⋅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(n⋅li)n−li,si=(100(ri⋅v)−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(材质):面向美术人员的视觉外观封装,最终通过着色器实现
材质系统的层次结构:
着色器变体的来源:
一个材质系统需要管理大量着色器变体(Shader Variants):
| 变体来源 | 说明 | 示例 |
|---|---|---|
| 不同几何处理 | 骨骼动画、形变、实例化 | 静态网格 vs 蒙皮网格 |
| 不同材质特性 | 开关某个特效 | 有无法线贴图 |
| 不同光照类型 | 延迟渲染 vs 前向渲染 | Deferred vs Forward |
| 不同平台 | 手机 vs PC | Mobile GLSL vs Desktop GLSL |
实际案例:游戏 Destiny: The Taken King 单帧使用超过 9000 个编译好的着色器变体。Unity 某个材质系统理论上有接近 1000 亿个可能变体。
着色器组合策略:
- 代码复用(Code Reuse):用
#include共享函数库 - 超级着色器(Ubershader):一个大着色器 + 条件分支,运行时选择路径
- 节点图(Additive/Node Graph):可视化编辑器组合节点(Unreal Engine 的材质编辑器就是这种)
- 模板接口(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)
好的采样模式应当:
- 样本分布均匀(避免聚集)
- 对近水平和近垂直边缘效果好(人眼对这类边缘最敏感)
- 低差异序列(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=αs⋅cs+(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=αs⋅cs+cd
适合发光效果(闪电、火花),不适合模拟物理透明度。
5.3 透明度渲染的排序问题
问题:Z-buffer 只能存储每像素一个深度,多个透明物体叠加时需要从后往前(back-to-front)渲染才能正确混合。
正确渲染顺序(back-to-front):
1. 先渲染所有不透明物体
2. 按距相机距离从远到近排序透明物体
3. 依次渲染(over 混合)
问题:
- 物体相互穿插时无法正确排序
- 凹形物体自遮挡时排序失效
5.4 顺序无关透明度(OIT)
深度剥离(Depth Peeling)
核心思想:多趟渲染,每趟"剥掉"最近的一层透明面。
优点:结果正确
缺点:每一层需要完整渲染一遍,性能差
加权混合 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=(1−u)⋅cwavg+u⋅cd
其中 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=αd⋅cd+(1−αd)⋅αs⋅cs
α 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′=αs⋅cs 已预乘。
优点:
- 线性插值(如 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.2≈0.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=fsRGB−1(x)={1.055x1/2.4−0.05512.92xx>0.0031308x≤0.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.04045y≤0.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 矫正的后果
- 暗部过暗:线性值 0.1 的区域实际只有 0.1 2.2 ≈ 0.01 0.1^{2.2} \approx 0.01 0.12.2≈0.01 的亮度,比应有值暗得多
- 光照计算错误:两个光源叠加时( 0.6 + 0.4 = 1.0 0.6 + 0.4 = 1.0 0.6+0.4=1.0),若在非线性空间相加,结果会不物理正确
- 抗走样失效(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)和帧缓冲格式即可,不需要手动在着色器中计算。
总结:第五章核心知识体系
第六章:纹理(Texturing)
来源:Real-Time Rendering, 4th Edition,第6章
目录
- 纹理的核心思想
- 纹理管线(Texturing Pipeline)
- 图像纹理(Image Texturing)
- 程序纹理(Procedural Texturing)
- 纹理动画(Texture Animation)
- 材质贴图(Material Mapping)
- Alpha 贴图(Alpha Mapping)
- 凹凸映射(Bump Mapping)
- 视差映射(Parallax Mapping)
- 纹理光源(Textured Lights)
- 总结
1. 纹理的核心思想
1.1 为什么需要纹理?
想象你要渲染一面砖墙。如果真的用几何体去表示每一块砖、每条灰缝,顶点数量会爆炸。纹理的核心思路是:
用一张图片"贴"到简单几何体上,让眼睛误以为表面很复杂。
一面砖墙只需要两个三角形,贴上砖墙照片,近看之前效果就很令人信服。
1.2 纹理能改变什么?
纹理不只能改变颜色,几乎可以改变着色方程中的任何参数:
同一块砖墙,可以同时用三张纹理:
1. 颜色纹理(albedo map) → 砖块是红色,灰缝是灰色
2. 粗糙度纹理(roughness map)→ 砖块光泽,灰缝哑光
3. 凹凸纹理(bump map) → 砖块表面看起来凹凸不平
每一张纹理负责修改着色方程中的一个输入参数,合力制造出复杂的视觉效果。
1.3 纹素(Texel)vs 像素(Pixel)
- 像素(Pixel):屏幕上显示的点
- 纹素(Texel):纹理图像中的点
两者经常被混淆,特别是在纹理缩放时需要区分。
2. 纹理管线
2.1 总览
整个纹理系统可以抽象成一条"管线",从空间中的一个点出发,最终得到一个值来修改着色。
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 | 超出范围用单独指定的边框颜色 |
可视化示意(以 wrap 和 clamp 为例):
原始纹理 [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′=pu−x, v ′ = p v − y v' = p_v - y v′=pv−y,则双线性插值结果为:
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)=(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)
直觉理解:距离采样点越近的纹素,权重越大。右上角纹素的权重正好是 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(3−2x)(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(6x2−15x+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 构建过程:
每层是上一层 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=(xur−xll)(yur−yll)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[l−1]。
优点:能正确处理水平和垂直方向的细长矩形。
缺点:斜方向的细长矩形仍然效果差;需要更高精度的存储(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=1−nx2−ny2
因此只存 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/2−1/41/201/21/4−1/2−1/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=Y−Cg,G=Y+Cg,R=t+Co,B=t−Co
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=0∑n2k1⋅noise(2k⋅p)
高频成分权重小,低频成分权重大,形成分形状的自然纹理(云、大理石纹)。
4.2 元胞纹理(Cellular Textures)
测量空间中每个点到若干"特征点"的距离,将距离映射成颜色。可以生成:皮肤、砖块、龟裂地面等图案。
4.3 程序纹理的优缺点
| 特点 | 说明 |
|---|---|
| 无限分辨率 | 任意放大都不会模糊 |
| 无参数化问题 | 不依赖 UV 展开 |
| 可动态变化 | 水波纹、裂缝扩散等 |
| 抗走样可控 | 已知频率,可以精确去掉高频 |
| GPU 成本 | 计算量比图像查询大 |
| 调试困难 | 不直观 |
5. 纹理动画(Texture Animation)
纹理不需要是静态的:
- 视频纹理:每帧更新纹理图像(如视频播放、实时摄像头)
- UV 动画:每帧偏移纹理坐标,模拟流动效果(瀑布:每帧减小 v v v 坐标)
- 矩阵变换:对 UV 施加旋转、缩放、剪切,实现更复杂的动画
- 混合过渡:在两张纹理之间渐变(如雕像变成肉身)
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 纹理(透明度贴图)的常见应用:
- 贴花(Decal):把一个图案贴到物体表面,透明区域不覆盖原来颜色
- 镂空(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),插值时把这个黑色"带入"了不透明区域的边缘。
解决方案:
- 预先将透明区域的颜色"涂抹"(Inpainting):把透明纹素的颜色设置为附近不透明纹素的颜色,虽然 alpha=0,但 rgb 值不是黑色
- 使用预乘 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(x−1,y),hy(x,y)=2h(x,y+1)−h(x,y−1)
未归一化的法线:
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=(R−128)/127, n y = ( G − 128 ) / 127 n_y = (G - 128)/127 ny=(G−128)/127, n z = ( B − 128 ) / 127 n_z = (B - 128)/127 nz=(B−128)/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(x−1,y),hy(x,y)=2h(x,y+1)−h(x,y−1)
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+vzh⋅vxy
问题:当视线几乎平行于表面时( v z → 0 v_z \to 0 vz→0),除法发散,产生严重错误。
9.3 偏移限制视差映射(Offset Limiting)
Welsh 改进版:限制最大偏移量不超过高度本身:
p adj ′ = p + h ⋅ v x y p'_{\text{adj}} = p + h \cdot \mathbf{v}_{xy} padj′=p+h⋅vxy
去掉了 / v z /v_z /vz,更稳定。含义:偏移量最多等于高度,定义了一个以高度为半径的圆,偏移不超过圆的边界。
正视(vz≈1): 斜视(vz≈0):
偏移量 ≈ h * vxy 偏移量被限制,不再发散
几乎等于精确版本 牺牲一些准确性,换取稳定性
9.4 视差遮挡映射(Parallax Occlusion Mapping, POM)
核心思想:沿着视线方向步进,直到找到与高度场的交点。
步骤:
视线步进示意:
视线(从左上进入):
● ← 视线起点(表面上方)
/
/ ● ← 第一步采样,射线在高度场上方(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 整章知识脉络
11.2 核心概念速查
| 技术 | 解决的问题 | 代价 |
|---|---|---|
| 双线性插值 | 放大时的像素化 | 略模糊 |
| 三线性 Mipmap | 缩小时的走样/闪烁 | 远处过度模糊 |
| 各向异性过滤 | Mipmap 斜面过度模糊 | 带宽增加 |
| BC 纹理压缩 | 显存和带宽占用大 | 有损,有精度损失 |
| 法线贴图 | 几何面少但需要细节 | 无轮廓变化 |
| 视差映射 | 法线贴图缺乏深度感 | 轮廓仍不变 |
| POM | 视差映射遮挡/阴影问题 | 多次纹理查询,开销大 |
| Alpha 测试 | 镂空物体的排序问题 | 边缘锯齿 |
| Alpha to Coverage | 镂空边缘抗走样 | 需 MSAA 支持 |
11.3 纹理过滤质量对比
距离(远→近) 过滤质量
远 ●───────────────────────────────────────● 近
最近邻 mipmap 各向异性 原始纹素
(锯齿) (远处模糊) (最佳平衡) (清晰)
11.4 Gamma 矫正与 Mipmap 的重要提示
生成 Mipmap 必须在线性空间进行,否则平均颜色会偏暗(因为 sRGB 编码空间中直接平均是错误的):
- 读取 sRGB 颜色纹理 → 解码到线性空间
- 在线性空间做 2 × 2 2\times2 2×2 平均
- 将结果编码回 sRGB 存储
现代 GPU API 在指定纹理格式为GL_SRGB8_ALPHA8等时会自动处理这个问题。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐


所有评论(0)