“那些你希望看起来更遥远的东西,你必须让它们成比例地更蓝;如果一个东西是五倍远,就让它蓝五倍。”
—— 达芬奇

总览

本章的核心概念是参与介质(Participating Media):充满粒子的体积,光在穿过它时会发生散射和吸收。
我们平时渲染的不透明表面,其实是光线在极密集的参与介质边界上反射的结果(用 BRDF 建模)。而雾、烟、云、水、皮肤、蜡烛等,则是密度较低或内部散射明显的介质,需要特殊处理。
本章围绕以下问题展开:

光穿过介质时发生了什么?
→ 如何在实时渲染中高效近似这些现象?

14.1 光散射理论(Light Scattering Theory)

14.1.1 参与介质的四种基本事件

光在介质中传播时,会遇到四种事件:

                     光路方向 d →
   [吸收]      光子被介质吸收,转为热量
     ↓
   [外散射]    光子被粒子弹走,偏离当前路径(减少辐射亮度)
     ↓
   [内散射]    其他方向的光子被弹入当前路径(增加辐射亮度)
     ↓
   [自发光]    介质受热发光(如火焰)

增加光路上的能量: 内散射 σ s \sigma_s σs + 自发光
减少光路上的能量: 消光 σ t = σ a + σ s \sigma_t = \sigma_a + \sigma_s σt=σa+σs(吸收 + 外散射)

符号表


符号 含义 单位
σ a \sigma_a σa 吸收系数 m − 1 \text{m}^{-1} m1
σ s \sigma_s σs 散射系数 m − 1 \text{m}^{-1} m1
σ t \sigma_t σt 消光系数 = σ a + σ s \sigma_a + \sigma_s σa+σs m − 1 \text{m}^{-1} m1
ρ \rho ρ 反照率(Albedo) 无量纲
p p p 相位函数 sr − 1 \text{sr}^{-1} sr1

反照率(Albedo) 衡量介质散射相对于吸收的比重:
ρ = σ s σ s + σ a = σ s σ t \rho = \frac{\sigma_s}{\sigma_s + \sigma_a} = \frac{\sigma_s}{\sigma_t} ρ=σs+σaσs=σtσs

  • ρ ≈ 0 \rho \approx 0 ρ0:大部分光被吸收,介质昏暗(如深色废气烟)
  • ρ ≈ 1 \rho \approx 1 ρ1:大部分光被散射,介质明亮(如空气、云、大气)

单次散射积分方程

考虑视线从相机 c c c 穿过介质到达表面 p p p 的完整辐射亮度:
L i ( c , − v ) = T r ( c , p ) L o ( p , v ) + ∫ 0 ∣ p − c ∣ T r ( c , c − v t ) ⋅ L scat ( c − v t , v ) ⋅ σ s   d t L_i(c, -\mathbf{v}) = T_r(c, p) L_o(p, \mathbf{v}) + \int_0^{|p-c|} T_r(c, c - \mathbf{v}t) \cdot L_\text{scat}(c - \mathbf{v}t, \mathbf{v}) \cdot \sigma_s \, dt Li(c,v)=Tr(c,p)Lo(p,v)+0pcTr(c,cvt)Lscat(cvt,v)σsdt
各项含义:

  • T r ( c , p ) T_r(c, p) Tr(c,p):从表面 p p p 到相机 c c c 的透射率(介质对背景的遮挡)
  • L o ( p , v ) L_o(p, \mathbf{v}) Lo(p,v):不透明表面的出射辐射亮度
  • L scat ( x , v ) L_\text{scat}(x, \mathbf{v}) Lscat(x,v):视线上位置 x x x 处的内散射光

14.1.2 透射率(Transmittance)与比尔-朗伯定律

透射率 T r T_r Tr 表示光能穿过介质的比例:
T r ( x a , x b ) = e − τ , 其中 τ = ∫ x a x b σ t ( x )   d x T_r(x_a, x_b) = e^{-\tau}, \quad \text{其中} \quad \tau = \int_{x_a}^{x_b} \sigma_t(x) \, dx Tr(xa,xb)=eτ,其中τ=xaxbσt(x)dx
这就是比尔-朗伯定律(Beer-Lambert Law)
τ \tau τ 称为光学深度,值越大,穿过的光越少。
直觉:

  • τ = 1 \tau = 1 τ=1:约 60% 的光被吸收/散射,只有约 37% 通过
  • τ = 3 \tau = 3 τ=3:只有约 5% 通过
    举例:若 σ t = ( 0.5 , 1.0 , 2.0 ) \sigma_t = (0.5, 1.0, 2.0) σt=(0.5,1.0,2.0)(RGB),深度 d = 1 d=1 d=1 米:
    T r = e − d σ t ≈ ( 0.61 , 0.37 , 0.14 ) T_r = e^{-d \sigma_t} \approx (0.61, 0.37, 0.14) Tr=edσt(0.61,0.37,0.14)
    即蓝色通道衰减最快( σ t \sigma_t σt 最大),红色通道衰减最慢——这正是水下看起来偏蓝绿而深处变红的原因。

14.1.3 散射事件与可见性

在位置 x x x、从方向 v \mathbf{v} v 处的内散射积分(对所有光源求和):
L scat ( x , v ) = π ∑ i = 1 n p ( v , l c i ) ⋅ v ( x , p light i ) ⋅ c light i ( ∣ x − p light i ∣ ) L_\text{scat}(x, \mathbf{v}) = \pi \sum_{i=1}^{n} p(\mathbf{v}, \mathbf{l}_{c_i}) \cdot v(x, p_{\text{light}_i}) \cdot c_{\text{light}_i}(|x - p_{\text{light}_i}|) Lscat(x,v)=πi=1np(v,lci)v(x,plighti)clighti(xplighti)
其中:

  • p ( v , l c i ) p(\mathbf{v}, \mathbf{l}_{c_i}) p(v,lci):相位函数,描述从光源方向散射到观察方向的概率
  • v ( x , p light i ) v(x, p_{\text{light}_i}) v(x,plighti):可见性函数(阴影项)
  • c light i c_{\text{light}_i} clighti:光源辐射亮度(含距离衰减)
    可见性函数结合了两类遮挡:
    v ( x , p light ) = shadowMap ( x , p light ) ⋅ T r ( x , p light ) v(x, p_\text{light}) = \text{shadowMap}(x, p_\text{light}) \cdot T_r(x, p_\text{light}) v(x,plight)=shadowMap(x,plight)Tr(x,plight)
  • shadowMap:不透明物体遮挡(传统阴影映射)
  • T r ( x , p light ) T_r(x, p_\text{light}) Tr(x,plight):体积介质自身的透射率遮挡(体积阴影)

14.1.4 相位函数(Phase Functions)

相位函数描述光在介质粒子上散射到各方向的概率分布。
它必须在单位球面上积分为 1(能量守恒)。

各向同性相位函数

最简单,光均匀散射到所有方向:
p ( θ ) = 1 4 π p(\theta) = \frac{1}{4\pi} p(θ)=4π1

散射类型与粒子大小的关系

设粒子大小参数 s p = 2 π r λ s_p = \frac{2\pi r}{\lambda} sp=λ2πr r r r = 粒子半径, λ \lambda λ = 波长):

散射类型 条件 典型介质
瑞利散射(Rayleigh) s p ≪ 1 s_p \ll 1 sp1 空气分子
米氏散射(Mie) s p ≈ 1 s_p \approx 1 sp1 雾、烟、云
几何散射 s p ≫ 1 s_p \gg 1 sp1 雨滴

瑞利散射(Rayleigh Scattering)

用于描述空气分子对光的散射,两个对称的散射瓣(前向与后向各一个,强度相同):
p ( θ ) = 3 16 π ( 1 + cos ⁡ 2 θ ) p(\theta) = \frac{3}{16\pi}(1 + \cos^2\theta) p(θ)=16π3(1+cos2θ)
关键特性——散射系数与波长的关系:
σ s ( λ ) ∝ 1 λ 4 \sigma_s(\lambda) \propto \frac{1}{\lambda^4} σs(λ)λ41
短波长(蓝色/紫色)散射远多于长波长(红色)。
这就是为什么:

  • 正午天空是蓝色:阳光穿过大气路径短,蓝光大量散射入眼睛
  • 日出/日落天空是红色:阳光穿过大气路径极长,蓝光几乎全被散射掉,只剩红光透射
    RGB 归一化散射系数: σ s = ( 0.490 , 0.172 , 0.339 ) \sigma_s = (0.490, 0.172, 0.339) σs=(0.490,0.172,0.339)(已归一化到亮度 = 1)。
Henyey-Greenstein(HG)相位函数

用于模拟米氏散射,支持前向和后向散射的可调控参数 g g g
p hg ( θ , g ) = 1 − g 2 4 π ( 1 + g 2 − 2 g cos ⁡ θ ) 1.5 p_\text{hg}(\theta, g) = \frac{1 - g^2}{4\pi (1 + g^2 - 2g\cos\theta)^{1.5}} phg(θ,g)=4π(1+g22gcosθ)1.51g2

  • g < 0 g < 0 g<0:后向散射(光主要往来的方向反弹)
  • g = 0 g = 0 g=0:各向同性
  • g > 0 g > 0 g>0:前向散射(光主要继续沿原方向前进)
Schlick 相位函数近似

更快的近似,用简单的平方代替复杂的幂函数:
p ( θ , k ) = 1 − k 2 4 π ( 1 + k cos ⁡ θ ) 2 , k ≈ 1.55 g − 0.55 g 3 p(\theta, k) = \frac{1 - k^2}{4\pi(1 + k\cos\theta)^2}, \quad k \approx 1.55g - 0.55g^3 p(θ,k)=4π(1+kcosθ)21k2,k1.55g0.55g3
k k k g g g 预计算一次即可。能量守恒性好,计算速度快。

14.2 专用体积渲染(Specialized Volumetric Rendering)

14.2.1 大范围雾(Large-Scale Fog)

雾是最常用的深度提示,也是参与介质的最简单近似。
线性雾:
f = z end − z s z end − z start f = \frac{z_\text{end} - z_s}{z_\text{end} - z_\text{start}} f=zendzstartzendzs
物理精确的指数雾(Beer-Lambert 法):
f = e − d f z s f = e^{-d_f z_s} f=edfzs
其中 d f d_f df 是用户控制的雾密度参数, z s z_s zs 是表面到相机的线性深度。
最终颜色混合( c i c_i ci 为表面颜色, c f c_f cf 为雾颜色):
c = f ⋅ c i + ( 1 − f ) ⋅ c f c = f \cdot c_i + (1 - f) \cdot c_f c=fci+(1f)cf
高度雾(Height Fog): 在特定高度范围内存在的雾层,对每个像素按视线穿过该层的距离评估密度,边缘用平滑函数过渡。
局部雾(Local Fog): 用椭球体或盒子定义局部雾区域(如山洞、墓地),逐个渲染其包围盒,在 Pixel Shader 中计算视线穿入/穿出点的深度,据此评估透射率。
水下效果: 相机在水面以下时开启雾逻辑,上方关闭;深度按视线穿过水体的距离计算透射率。

14.2.2 简单体积光照

闭合形式单散射(解析积分)

假设均匀介质和各向同性相位函数,可以对单次散射进行解析积分(无需光线步进)。
GLSL 代码实现(含详细注释):

// 计算点光源在均匀介质中沿射线的内散射积分(解析解)
// 参数:
//   rayStart    : 射线起点(世界空间)
//   rayDir      : 射线方向(归一化向量)
//   lightPos    : 点光源位置(世界空间)
//   rayDistance : 沿射线的积分距离
float inScattering(
    vec3 rayStart,
    vec3 rayDir,
    vec3 lightPos,
    float rayDistance)
{
    // q = 从光源到射线起点的向量
    vec3 q = rayStart - lightPos;
    // b = q 在射线方向上的投影长度(有向)
    float b = dot(rayDir, q);
    // c = |q|^2,即光源到射线起点距离的平方
    float c = dot(q, q);
    // s = 1 / 光源到射线的最短距离
    // 利用勾股定理:最短距离^2 = c - b^2
    float s = 1.0f / sqrt(c - b * b);
    // 参数化积分端点
    float x = s * rayDistance; // 积分终点参数
    float y = s * b;           // 积分起点偏移参数
    // 解析积分结果(基于 atan 的闭合形式)
    return s * atan((x) / (1.0 + (x + y) * y));
}

使用方式: 以后处理全屏 Pass 的方式调用,假设均匀介质。

屏幕空间光轴(Light Shafts)
  1. 在清黑的缓冲区中,以深度测试只渲染太阳附近的假亮光源
  2. 以太阳为中心做径向模糊(方向性模糊),把亮度向外扩散
  3. 将模糊结果叠加到场景缓冲区
    优点:极快,视觉效果显著。
    缺点:光源不在屏幕内时无效。

14.3 通用体积渲染(General Volumetric Rendering)

14.3.1 体积数据可视化

医疗 CT/MRI 等生成三维体素数据(如 256 3 256^3 2563 个体素),需要渲染为可视化图像。
切片法: 沿垂直于视线方向的平面,从后向前逐层渲染切片,每层作为半透明四边形叠加:

视线方向 →
后方切片 (透明度低)
   ↓
中间切片
   ↓
前方切片 (透明度高)
   ↓
最终画面(逐层混合)

传输函数(Transfer Function):
将体素密度值映射到颜色和不透明度。

  • 一维传输函数: 密度 d d d → 颜色 + 透明度(简单但无法区分材质边界)
  • 二维传输函数: 密度 d d d + 梯度长度 ∥ ∇ d ∥ \|\nabla d\| ∥∇d → 颜色 + 透明度
    梯度长度大的地方 = 密度变化剧烈处 = 材质边界,可以单独着色,例如区分骨头和软组织。
    半角切片法(Half-Angle Slicing): 切片方向取视线方向和光源方向的中间方向,可以同时积累光照和遮挡信息,实现体积内的次表面散射效果。

14.3.2 参与介质渲染

相机视锥体体积(Frustum-Mapped Volume)

Wronski 提出的框架:将整个参与介质信息体素化到一个与相机视锥体对齐的三维纹理 V 0 V_0 V0 中:

  • x , y x, y x,y 轴 = 屏幕坐标(1/8 屏幕分辨率)
  • z z z 轴 = 相机深度方向(约 64 层)
  • 每个体素(形状为小截锥,称为 froxel)存储:内散射辐射亮度(RGB)+ 消光系数(A)
    从近到远逐层累积,生成最终散射体积 V f V_f Vf
    V f [ x , y , z ] = ( L scat ′ + T r ′ ⋅ L scat_in ⋅ d s ,   T r ′ ⋅ T r slice ) V_f[x,y,z] = \left(L'_\text{scat} + T'_r \cdot L_\text{scat\_in} \cdot d_s,\ T'_r \cdot T_r^\text{slice}\right) Vf[x,y,z]=(Lscat+TrLscat_inds, TrTrslice)
    其中 T r slice = e − σ t d s T_r^\text{slice} = e^{-\sigma_t d_s} Trslice=eσtds
    改进版(Hillaire)——考虑当前层内部的透射率,使用解析积分:
    V f [ x , y , z ] = L scat ′ + L scat_in − L scat_in ⋅ T r slice σ t ⋅ T r ′ V_f[x,y,z] = L'_\text{scat} + \frac{L_\text{scat\_in} - L_\text{scat\_in} \cdot T_r^\text{slice}}{\sigma_t} \cdot T'_r Vf[x,y,z]=Lscat+σtLscat_inLscat_inTrsliceTr
    最终像素颜色( L s L_s Ls 为不透明表面颜色):
    L o = T r ⋅ L s + L scat L_o = T_r \cdot L_s + L_\text{scat} Lo=TrLs+Lscat
    体积阴影(Volumetric Shadows): 把粒子和介质体素化到三个围绕相机的消光体积(级联 clipmap),作为统一的不透明度阴影图来源,实现:
  • 参与介质自阴影
  • 粒子对介质的阴影
  • 任何物体之间的相互体积阴影

14.4 天空渲染(Sky Rendering)

14.4.1 天空与空气透视

大气散射需要考虑两类:

大气散射

瑞利散射
空气分子
波长相关

米氏散射
大颗粒(尘埃/污染)
波长无关

天空蓝色
日落红色
薄雾/空气透视

太阳周围的
亮白色光晕

预计算查找表(LUT)方法(Bruneton & Neyret):
用四个参数预计算并存储散射和透射率:

  • r r r:视点高度
  • μ v \mu_v μv:视线方向与天顶的夹角余弦
  • μ s \mu_s μs:太阳方向与天顶的夹角余弦
  • ν \nu ν:视线与太阳在方位角平面上的夹角余弦
    运行时采样 LUT,避免实时光线步进,可从地面渲染到太空。
    多次散射近似(迭代 n n n 阶):
  1. 计算单次散射表 S lut S_\text{lut} Slut
  2. S lut n − 1 S_\text{lut}^{n-1} Slutn1 计算 S lut n S_\text{lut}^n Slutn
  3. 累加到总散射表
    更紧凑的三维 LUT(忽略 ν \nu ν 参数): 损失大气与地球阴影交互,但体积更小,更新更快,EA Frostbite 多款游戏使用此方案。

14.4.2 云(Clouds)

云由水滴组成,特点:

  • 高散射系数(几乎完全散射): ρ ≈ 1 \rho \approx 1 ρ1,即 σ s ≈ σ t \sigma_s \approx \sigma_t σsσt
  • 层积云 σ t ∈ [ 0.04 , 0.06 ] \sigma_t \in [0.04, 0.06] σt[0.04,0.06],积云 σ t ∈ [ 0.05 , 0.12 ] \sigma_t \in [0.05, 0.12] σt[0.05,0.12]
云的渲染方法

云渲染方法

全景纹理
静态天空用

粒子/布告板
层积云

参与介质+
光线步进
动态积云

Perlin-Worley 噪声
定义云的形状

双 HG 相位函数
前向+后向散射

多次散射近似
Wrenninge 方法

Schneider & Vos 方法(生产级动态云):

  1. 云层建模:用两层程序噪声
第一层(Perlin-Worley 混合):云的基础大形
                ↓
第二层(高频细节):侵蚀云的边缘,产生菜花状积云细节
  1. 光照:沿视线步进,对每个采样点向太阳方向发射次级步进计算透射率(体积阴影)
  2. 性能优化:每 4 × 4 4\times4 4×4 像素块只更新一个像素,重投影填补其余像素
    双 HG 相位函数(云的相位函数):
    p dual ( θ , g 0 , g 1 , w ) = p hg ( θ , g 0 ) + w ⋅ ( p hg ( θ , g 1 ) − p hg ( θ , g 0 ) ) p_\text{dual}(\theta, g_0, g_1, w) = p_\text{hg}(\theta, g_0) + w \cdot (p_\text{hg}(\theta, g_1) - p_\text{hg}(\theta, g_0)) pdual(θ,g0,g1,w)=phg(θ,g0)+w(phg(θ,g1)phg(θ,g0))
    通过混合两个 HG 函数,同时表达前向散射(阳光透过云边的银边效果)和后向散射(观察方向迎光的明亮细节)。
多次散射近似(Wrenninge 方法)

云的白色明亮外观来自光在云内部的多次弹射。单次散射会使厚云内部变得很暗,不真实。
近似方法:叠加 o o o 阶散射,每阶递减散射/消光系数并使相位函数趋向各向同性:
L multiscat ( x , v ) = ∑ n = 0 o − 1 L scat ( x , v ) L_\text{multiscat}(x, \mathbf{v}) = \sum_{n=0}^{o-1} L_\text{scat}(x, \mathbf{v}) Lmultiscat(x,v)=n=0o1Lscat(x,v)
其中每阶替换: σ s ′ = σ s a n \sigma_s' = \sigma_s a^n σs=σsan σ e ′ = σ e b n \sigma_e' = \sigma_e b^n σe=σebn p ′ ( θ ) = p ( θ c n ) p'(\theta) = p(\theta c^n) p(θ)=p(θcn)
参数 a , b , c ∈ [ 0 , 1 ] a, b, c \in [0,1] a,b,c[0,1] 由艺术家调整,控制光线穿透程度。
要求 a ≤ b a \leq b ab(确保能量守恒,避免 σ s > σ t \sigma_s > \sigma_t σs>σt)。

14.5 半透明表面(Translucent Surfaces)

14.5.1 覆盖率与透射率

覆盖率(Coverage):表面遮挡了背景多少(如布料、纸张):
c o = α ⋅ c s + ( 1 − α ) ⋅ c b c_o = \alpha \cdot c_s + (1 - \alpha) \cdot c_b co=αcs+(1α)cb
透射率(Transmittance):固体体积让特定波长的光通过多少(如玻璃、水):
c o = c s + T r ⋅ c b c_o = c_s + T_r \cdot c_b co=cs+Trcb
其中 T r T_r Tr 是 RGB 颜色向量,实现彩色透明效果。
通用混合(同时有覆盖率和透射率时):
c o = α ( c s + T r c b ) + ( 1 − α ) c b c_o = \alpha(c_s + T_r c_b) + (1 - \alpha) c_b co=α(cs+Trcb)+(1α)cb
厚度与透射率:
T r = e − σ t d T_r = e^{-\sigma_t d} Tr=eσtd
从目标颜色反算消光系数(艺术家工作流):
设艺术家指定在距离 d d d 处的目标透射颜色 t c t_c tc,则:
σ t = − log ⁡ ( t c ) d \sigma_t = \frac{-\log(t_c)}{d} σt=dlog(tc)
例如: t c = ( 0.3 , 0.7 , 0.1 ) t_c = (0.3, 0.7, 0.1) tc=(0.3,0.7,0.1) d = 4 d = 4 d=4 米:
σ t = 1 4 ( − log ⁡ 0.3 , − log ⁡ 0.7 , − log ⁡ 0.1 ) ≈ ( 0.301 , 0.089 , 0.576 ) \sigma_t = \frac{1}{4}(-\log 0.3, -\log 0.7, -\log 0.1) \approx (0.301, 0.089, 0.576) σt=41(log0.3,log0.7,log0.1)(0.301,0.089,0.576)
薄层表面的透射率(视角相关):
薄壳网格,从不同角度看穿透深度不同:
T r = e − σ t d , d = t max ⁡ ( 0.001 , n ⋅ v ) T_r = e^{-\sigma_t d}, \quad d = \frac{t}{\max(0.001, \mathbf{n} \cdot \mathbf{v})} Tr=eσtd,d=max(0.001,nv)t
其中 t t t 是材质厚度, n ⋅ v \mathbf{n} \cdot \mathbf{v} nv 是法线与视线的点积,视线越接近切线方向,穿过的距离越长,越不透明(类比菲涅耳效应)。

14.5.2 折射(Refraction)

折射方向计算(Snell 定律):
t = ( w − k ) N − n l \mathbf{t} = (w - k)\mathbf{N} - n\mathbf{l} t=(wk)Nnl
其中:
w = n ( l ⋅ N ) , k = 1 + ( w − n ) ( w + n ) , n = n 1 n 2 w = n(\mathbf{l} \cdot \mathbf{N}), \quad k = \sqrt{1 + (w - n)(w + n)}, \quad n = \frac{n_1}{n_2} w=n(lN),k=1+(wn)(w+n) ,n=n2n1
常见折射率:水 ≈ 1.33 \approx 1.33 1.33,玻璃 ≈ 1.5 \approx 1.5 1.5,空气 ≈ 1.0 \approx 1.0 1.0
色散(Dispersion): 折射率随波长变化,导致不同颜色的光弯曲角度不同(棱镜彩虹效应)。实时渲染通常忽略,VR 中有时做逆色散补偿。
屏幕空间折射方法:

  1. 正常渲染场景到纹理 s s s(折射物体除外)
  2. 渲染折射物体时,用法线的切线 xy 分量作为偏移,从 s s s 中采样扰动后的背景颜色
  3. 检查采样深度:若采样点比折射表面更近,则忽略(避免把表面前方的内容折射进来)
    粗糙折射: 对不同粗糙度的表面,用场景的多级 mipmap 模拟折射射线方向的扩散——roughness 越高,采样越高层 mipmap,背景越模糊。

14.5.3 焦散与阴影

焦散(Caustics): 折射/反射导致光路发散或汇聚,产生暗区(光稀疏)和亮斑(光密集)。
常见方案:

  • 离线预生成焦散纹理,作为 lightmap 叠加
  • 用水面法线贴图动画,投影到水底
  • 实时:把折射光子位置渲染到纹理,再 splat 回场景

14.6 次表面散射(Subsurface Scattering, SSS)

概念

次表面散射发生在高散射系数的固体材质中(皮肤、蜡、牛奶、大理石)。光进入物体后,在内部多次弹射,从略微偏移的位置重新射出。
因为光的出射位置和入射位置不同,无法用 BRDF 描述,需要 BSSRDF(双向表面散射分布函数)。

普通 BRDF:  入射点 = 出射点
BSSRDF:    入射点 ≠ 出射点(光在内部扩散)
         入射光
            ↓
    ────────┬──────────────
    表面    │
            │ 多次散射路径
    ────────┼──┬──────────
            └──→ 偏移出射点

14.6.1 包裹光照(Wrap Lighting)

最简单的 SSS 近似:让光照"绕过"物体曲面边缘,使阴影过渡更柔和。
加上颜色偏移(如皮肤用红色偏移),模拟光在皮肤内部被红色成分(血液)散射的效果。

14.6.2 法线模糊(Normal Blurring)

多次散射在空间上有扩散效果,等价于对漫反射法线做模糊:

  • 镜面反射:用精确法线(表面细节)
  • 漫反射:用模糊后的法线(甚至直接用顶点法线)
    改进:对 R、G、B 三个通道使用不同程度的模糊(皮肤中红色通道扩散最远),产生颜色渗透效果。

14.6.3 预积分皮肤着色(Pre-Integrated Skin Shading)

结合包裹光照和法线模糊,把散射和透射率预积分到一张二维 LUT:

  • 第一轴: n ⋅ l \mathbf{n} \cdot \mathbf{l} nl(光照角度)
  • 第二轴: 1 / r = ∥ ∂ n / ∂ p ∥ 1/r = \|\partial\mathbf{n}/\partial\mathbf{p}\| 1/r=n/p(表面曲率,预烘焙)
    曲率越高,散射对颜色的影响越大。运行时直接采样 LUT,无需复杂计算。

14.6.4 纹理空间扩散(Texture-Space Diffusion)

  1. 把表面的漫反射光照渲染到纹理(以纹理坐标作为光栅化位置)
  2. 在纹理空间对该光照图做高斯模糊(R 通道用更宽的滤波核)
  3. 渲染时用模糊后的光照图做漫反射着色
    效果:阴影边缘柔和、有颜色渗透(红色皮肤在阴影边缘更红)。
    代价较高(多次模糊 Pass),但可以缩减质量换取性能。

14.6.5 屏幕空间扩散(Screen-Space Diffusion)

在屏幕空间做 SSS,避免为每个网格渲染单独的光照图:

  1. 正常渲染场景,在模板缓冲区标记需要 SSS 的像素
  2. 对标记区域在屏幕空间做两次(水平+垂直)带颜色的双边模糊
  3. 用线性深度拉伸模糊宽度(近处更宽,远处更窄)
  4. 双边滤波防止跨深度漏光
    优势:一个 Pass 处理场景中所有角色,开销随角色数量几乎不变。

14.6.6 深度图技术(Depth-Map Techniques)

用于大尺度次表面散射(如手掌透光):
Green 的近似方法:

  • 从光源方向渲染深度图
  • 对当前着色点,查找其在光源深度图中的对应深度
  • 深度差 = 光线在材质内部穿行的厚度
  • 用 Beer-Lambert 定律计算透射率
    局部厚度纹理方法(Barré-Brisebois):
  • 预烘焙局部厚度纹理 t s s t_{ss} tss:约等于从反向法线看的环境光遮蔽的反值
  • 背光散射贡献: t s s ⋅ c s s ⋅ ( v ⋅ − l ) + p t_{ss} \cdot c_{ss} \cdot (\mathbf{v} \cdot -\mathbf{l})_+^p tsscss(vl)+p
  • 快速、单 Pass,效果合理

14.7 头发与皮毛(Hair and Fur)

头发的物理结构

头发纤维由三层组成(从外到内):

外层:角质层(Cuticle)
  ↓  表面由倾斜约 3° 的鳞片覆盖,影响高光方向
中层:皮质(Cortex)
  ↓  含黑色素:真黑素(棕色)+ 褐黑素(红色)
内层:髓质(Medulla)
     人发中很小,动物毛中较大

头发中的光路成分

R   : 角质层表面直接反射
      → 无色高光,朝发根方向偏移
TT  : 光透射进入,直接透射出去
      → 逆光时的亮透明高光
TRT : 光透射进入 → 内壁反射 → 再透射出去
      → 有颜色的二次高光(光被色素吸收过两次)
      → 椭圆截面的发丝产生闪烁(glints)
截面图(发丝横切面):
      → 入射光
  ┌───────────────┐
  │     R         │ ← 外表面反射(白色)
  │  ┌─────────┐  │
  │  │  TRT    │  │ ← 内壁反射后出射(彩色)
  │  │    ↓    │  │
  │  │   TT   ──→ │ ← 直接透射出去(明亮)
  │  └─────────┘  │
  └───────────────┘

Henyey-Greenstein 在头发中的应用

头发中的多次散射近似:
L multiscat ( x , v ) = ∑ n = 0 o − 1 L scat ( x , v ) L_\text{multiscat}(x, \mathbf{v}) = \sum_{n=0}^{o-1} L_\text{scat}(x, \mathbf{v}) Lmultiscat(x,v)=n=0o1Lscat(x,v)

双散射技术(Dual Scattering,Zinke et al.)

两个因子:

  • 全局透射率 Ψ G \Psi_G ΨG:计算从光源到着色点之间所有头发层的透射率(考虑每根发丝的 BSDF)
  • 局部散射分量 Ψ L \Psi_L ΨL:当前位置周围发丝向内部散射的额外贡献
    最终贡献: Ψ G + Ψ G Ψ L \Psi_G + \Psi_G \Psi_L ΨG+ΨGΨL,代入当前发丝的 BSDF 计算光源贡献。

皮毛(Fur)渲染

Shell-and-Fin 方法:

同一网格渲染 N 次(Shells):
  第 1 次:沿法线偏移 0,渲染底层皮毛截面纹理
  第 2 次:沿法线偏移 Δh,渲染第二层
  ...
  第 N 次:沿法线偏移 (N-1)Δh,渲染最外层
轮廓边缘(Fins):
  沿轮廓法线生成额外几何面
  贴上侧面毛发纹理,防止轮廓处"点状"穿帮

14.8 统一方法(Unified Approaches)

未来展望: 能否将不透明表面和体积介质统一表示?
关键思路:SGGX(对称 GGX)——将 GGX 法线分布函数推广到体积中的微薄片(flake)粒子:

  • 微薄片代替微面,体积代替表面
  • LOD 变为简单的体积滤波材质属性
  • 远处的树林可以渲染为带有正确遮挡、光照、透射率的滤波体素
    当前 GPU 能力限制使体积和表面必须分开处理;随着硬件进步,统一表示有望成为可能。

各方法适用场景速查

大气/远景雾

天空颜色/日落

局部烟/雾/粒子

玻璃/水

皮肤/蜡/玉石

头发

皮毛

需要渲染什么?

物体类型

指数深度雾
Beer-Lambert

预计算 LUT
瑞利+米氏散射

光线步进
Perlin-Worley 噪声
双 HG 相位函数

视锥体体积
froxel 方案

透射率 Tr
Beer-Lambert
折射方向

次表面散射
纹理空间或屏幕空间扩散

R+TT+TRT BSDF
Shell+Fin 几何

Shell+Fin
体积纹理

关键公式速查

消光系数:
σ t = σ a + σ s \sigma_t = \sigma_a + \sigma_s σt=σa+σs
反照率:
ρ = σ s σ t \rho = \frac{\sigma_s}{\sigma_t} ρ=σtσs
比尔-朗伯透射率:
T r ( x a , x b ) = e − ∫ x a x b σ t ( x )   d x T_r(x_a, x_b) = e^{-\int_{x_a}^{x_b} \sigma_t(x) \, dx} Tr(xa,xb)=exaxbσt(x)dx
匀质介质简化:
T r = e − σ t d T_r = e^{-\sigma_t d} Tr=eσtd
从目标颜色反算消光系数:
σ t = − log ⁡ ( t c ) d \sigma_t = \frac{-\log(t_c)}{d} σt=dlog(tc)
瑞利散射系数波长关系:
σ s ( λ ) ∝ 1 λ 4 \sigma_s(\lambda) \propto \frac{1}{\lambda^4} σs(λ)λ41
HG 相位函数:
p hg ( θ , g ) = 1 − g 2 4 π ( 1 + g 2 − 2 g cos ⁡ θ ) 1.5 p_\text{hg}(\theta, g) = \frac{1 - g^2}{4\pi (1 + g^2 - 2g\cos\theta)^{1.5}} phg(θ,g)=4π(1+g22gcosθ)1.51g2
Schlick 相位函数近似:
p ( θ , k ) = 1 − k 2 4 π ( 1 + k cos ⁡ θ ) 2 , k ≈ 1.55 g − 0.55 g 3 p(\theta, k) = \frac{1 - k^2}{4\pi(1 + k\cos\theta)^2}, \quad k \approx 1.55g - 0.55g^3 p(θ,k)=4π(1+kcosθ)21k2,k1.55g0.55g3
薄层角度相关透射率:
T r = e − σ t ⋅ t / max ⁡ ( 0.001 , n ⋅ v ) T_r = e^{-\sigma_t \cdot t / \max(0.001, \mathbf{n} \cdot \mathbf{v})} Tr=eσtt/max(0.001,nv)

第 15 章:非真实感渲染(Non-Photorealistic Rendering, NPR)

“把’非线性科学’这个词用作术语,就好比把动物学的大部分内容称为’非大象动物研究’。”
—— Stanislaw Ulam

总览

真实感渲染(Photorealistic Rendering) 的目标是让图像与照片无法区分。
非真实感渲染(NPR) 则有截然不同的目标——不是"像照片",而是"有用、有风格、有表达力"。
NPR 的两大方向:

NPR
 ├── 技术插图风格(Technical Illustration)
 │     只显示对目的有用的细节
 │     例:维修手册的零件轮廓图,比照片更清晰易懂
 └── 绘画/自然媒介风格(Painterly / Natural Media)
       钢笔素描、炭笔、水彩……
       模拟各种艺术媒介的视觉感受

本章重点:

  1. 卡通渲染(Toon / Cel Shading)
  2. 轮廓线检测与渲染(Outline Rendering)
  3. 笔触表面风格化
  4. 直线渲染
  5. 文字渲染

15.1 卡通着色(Toon Shading)

核心思想:“简化放大信息”

卡通风格的魅力在于:去掉细节噪音,让关键信息更突出。
人物用简单线条画出来,反而比写实照片更有亲和力。

表面着色方法

方法一:硬阴影(Hard Shading / Two-Tone)

最简单:计算法线与光源方向的点积,超过阈值用亮色,否则用暗色。

if (dot(normal, lightDir) > threshold)
    color = brightTone;   // 受光面
else
    color = darkTone;     // 阴影面
方法二:色调量化(Posterization)

把连续的渲染结果映射到若干离散色阶,像老式印刷品。
注意:直接量化 RGB 三个通道会导致色相偏移(因为三个通道各自变化)。
正确做法:在保持色相的颜色空间(HSV、HSL、Y’CbCr)中量化亮度通道。
或者用一维查找纹理(1D LUT):把亮度值映射到特定色调或颜色。

// 将亮度值映射到卡通色阶
float intensity = dot(normal, lightDir);
float quantized = floor(intensity * numTones) / numTones;
vec3 color = texture(toneLUT, quantized);
方法三:二维色调贴图(2D Shade Map)

Barla 等人提出的改进:用二维贴图代替一维,第二个维度表示表面深度或朝向。
用于 Team Fortress 2,实现"半卡通半写实"的混合风格。

15.2 轮廓线渲染(Outline Rendering)

边的分类

在讲检测方法之前,先弄清楚"边"有哪些类型:

边类型 定义 特点
边界边(Boundary) 只属于一个三角形(纸张边缘) 与视角无关
折痕边(Crease) 两三角形共享,二面角 > 阈值(如 60°) 与视角无关
材质边(Material) 两侧材质不同,或艺术家标记 与视角无关
轮廓边(Contour) 两侧三角形一个朝向观察者,一个背向 与视角有关
剪影边(Silhouette) 轮廓边的子集,将物体与背景分离 轮廓边中最明显的

暗示性轮廓(Suggestive Contour): 从当前视角几乎是轮廓边的位置,提供额外形状线索。

剪影边 ⊆ 轮廓边 ⊆ 所有可见边
区别示例(侧面看人头):
  - 耳朵内侧的曲线:轮廓边(一侧朝向观察者,另一侧背向)
  - 耳朵外轮廓:剪影边(同时将耳朵与背景分离)

15.2.1 法线点积着色(Shading Normal Contour)

用法线与视线方向的点积来着色:
edge_factor = n ⋅ v \text{edge\_factor} = \mathbf{n} \cdot \mathbf{v} edge_factor=nv
n ⋅ v ≈ 0 \mathbf{n} \cdot \mathbf{v} \approx 0 nv0 时,表面几乎侧对观察者,即接近轮廓边,将其涂黑。

// Pixel Shader 中实现轮廓着色
float edgeFactor = dot(normal, viewDir);
float edgeWidth = 0.3; // 线宽控制
if (edgeFactor < edgeWidth)
    color = black * (1.0 - edgeFactor / edgeWidth);

优点: 极简,不需要边信息
缺点: 线宽随曲率变化(曲率大的地方线细,曲率小的地方线宽);对立方体等折痕模型无效(折痕处附近法线不会垂直于视线)

15.2.2 程序化几何轮廓(Procedural Geometry Silhouetting)

核心思路: 先正常渲染正面,再用特殊方式渲染背面,使背面的边缘变得可见。
步骤:

  1. 正常渲染所有正面(Front Face)
  2. 开启前面剔除,关闭背面剔除
  3. 以特殊方式渲染背面
方法 A:Z-偏移法(Z-Bias)

把背面略微向前偏移,使其刚好覆盖正面的边缘:

优点:简单
缺点:线宽不均匀(依赖正面的角度)
方法 B:三角形膨胀法(Triangle Fattening)

把每个背面三角形沿其平面向外扩展,使暴露出来的边缘宽度均匀:

原始背面三角形(黑色):
        A
       / \
      /   \
     B-----C
膨胀后(每条边向外移动相同像素宽度):
          A'
         / \
        /   \
       B'----C'
   (超出正面三角形的边缘,形成均匀轮廓)

更安全的做法: 不是扩展顶点,而是扩展每条边再连接,避免细长三角形顶点跑偏。

方法 C:Shell 法(法线方向膨胀)

沿顶点法线方向把背面整体往外推,形成包裹原物体的"壳":

原始球体(半径 r)
     ↓
沿法线往外推 Δ(如相当于 5 像素的世界空间距离)
     ↓
渲染膨胀后的球体背面(黑色)
效果:黑色的壳从正面球体边缘"漏出来",形成均匀 5 像素宽的轮廓

Vertex Shader 实现:

// Shell 法轮廓扩展(Vertex Shader)
vec4 pos = modelViewProj * vec4(position, 1.0);
// 把法线变换到裁剪空间
vec2 normal2D = normalize(
    (modelViewProj * vec4(normal, 0.0)).xy
);
// 在裁剪空间沿法线方向扩展
float outlineWidth = 0.005; // 轮廓宽度(裁剪空间单位)
pos.xy += normal2D * outlineWidth * pos.w;
gl_Position = pos;

注意事项:
立方体等尖角处,每个面有不同的顶点法线,膨胀方向不一致,会出现缝隙。
解决:强制同位置顶点共享一个平均法线;或在折痕处生成退化几何体再膨胀。

折痕边的处理(Crease Edge / Fin 方法)

在每条三角形边上附加一个小"鳍"(fin)多边形,按折痕角度弯折:

侧视图:
      正面 A         正面 B
      /              \
  ---/--[fin]--[fin]--\---  ← 两个鳍贴在边上
     \  (向内折叠时隐藏)   /
      \____________________/
当 A 和 B 的二面角 > 折痕阈值时,鳍就从 A 和 B 的夹角中露出来
涂成黑色 → 折痕边可见

15.2.3 图像处理边缘检测(Edge Detection by Image Processing)

核心思路: 先渲染一个 G-buffer(存储深度、法线、物体 ID 等),再用图像处理算子在缓冲区上找不连续处。

渲染场景到 G-buffer

深度缓冲区

法线缓冲区

物体 ID 缓冲区

Sobel/Roberts 边缘检测

相邻像素 ID 比较

合并边缘图像

膨胀算子
加粗线条

叠加到最终画面

检测哪种边

缓冲区 检测的边类型
深度缓冲 轮廓边、剪影边(深度突变)
法线缓冲 轮廓边、折痕边(法线突变)
物体 ID 边界边、剪影边(ID 不同)
材质 ID 材质边

膨胀算子(Dilation Operator)

用于加粗检测到的细线:

原始细线(1像素):
0 0 0 0 0
0 0 1 0 0    (1 = 黑色边缘像素)
0 0 0 0 0
膨胀半径=1 后(取周围最暗值):
0 1 1 1 0
0 1 1 1 0    (边线变宽了 2 倍)
0 1 1 1 0

膨胀多次 → 线更粗;腐蚀算子(Erosion)则用于细化线条。

高斯差(Difference of Gaussians, DoG)

用两个不同方差的高斯模糊的差来检测边缘,产生特别自然的铅笔素描效果:
DoG ( x ) = G σ 1 ( x ) − G σ 2 ( x ) , σ 1 < σ 2 \text{DoG}(x) = G_{\sigma_1}(x) - G_{\sigma_2}(x), \quad \sigma_1 < \sigma_2 DoG(x)=Gσ1(x)Gσ2(x),σ1<σ2

优缺点

优点:

  • 处理所有类型表面(平面、曲面、非连续网格均可)
  • 不需要任何网格拓扑信息
    缺点:
  • 几乎平行于视线的薄物体(如桌上的纸)深度差极小,边缘容易漏检
  • 远处的薄物体(圆柱法线变化快)可能出现误检
  • 边缘有锯齿,需配合 MLAA 等形态学抗锯齿

15.2.4 几何轮廓边检测(Geometric Contour Edge Detection)

核心判断: 轮廓边 = 两侧三角形一个朝向观察者,一个背向。
判断公式:
( n 0 ⋅ v ) ( n 1 ⋅ v ) < 0 (\mathbf{n}_0 \cdot \mathbf{v})(\mathbf{n}_1 \cdot \mathbf{v}) < 0 (n0v)(n1v)<0
其中 n 0 \mathbf{n}_0 n0 n 1 \mathbf{n}_1 n1 是两个相邻三角形的法线, v \mathbf{v} v 是从眼睛到该边的视线方向。
两侧法线点积异号 → 一侧正对眼睛,一侧背对眼睛 → 该边是轮廓边。
暴力方法(CPU): 遍历所有边,逐一判断。
优化: 平面内共线的边不可能是轮廓边(可预先过滤);固体模型的凹入边也永远不是轮廓边。
GPU 顶点着色器方法: 每条边以退化四边形形式传入,附带两侧三角形法线。若判断为轮廓边,展开四边形;否则保持退化(不可见)。几何着色器可在运行时生成这些四边形。

轮廓循环(Contour Loops)

实体模型的轮廓边总是构成封闭曲线,称为轮廓循环。
每个顶点上的轮廓边数量必须是偶数。
利用帧间连贯性(上一帧的轮廓大概率还是这一帧的轮廓)可以加速检测,但需要处理循环合并/分裂时的突变问题。

15.2.5 隐线消除(Hidden Line Removal)

找到轮廓边之后,还需要判断哪些边是可见的(被前景面遮挡的线不应画出来)。
简单方法: 先把所有面片渲染进 z-buffer(不输出颜色),再正常绘制边(z-buffer 测试会自动挡住被遮挡的部分)。
可见笔触重建:

  1. 给所有三角形和轮廓边分配不同的 ID 号
  2. 读回 ID 缓冲区,判断哪些轮廓边可见
  3. 把可见边段连接成连续的笔触路径
  4. 用风格化样式渲染这些笔触(粗细变化、抖动、超出端点等)

15.3 笔触表面风格化(Stroke Surface Stylization)

色调美术图(Tonal Art Maps, TAMs)

问题: 用笔触纹理直接贴到表面时,缩小/放大会导致笔触密度或粗细失真。
解决: 预生成专为 mipmap 设计的笔触纹理集(TAM),每级 mipmap 都预先画好了对应密度的笔触,且包含了上一级所有笔触(确保插值平滑):

mipmap 金字塔(每行纹理对应一个光照强度,每列对应一个 mipmap 层级):
       mip0(大)   mip1(中)   mip2(小)
亮度高  [稀疏线]   [稀疏线]   [稀疏线]
亮度中  [中密线]   [中密线]   [中密线]
亮度低  [密集线]   [密集线]   [密集线]
规则:较暗/较小的纹理包含较亮/较大纹理的所有笔触
     → 插值过渡时不会"突然消失"某条线

渲染时,根据每个顶点的光照强度插值选择对应色调层,根据物体距离选择对应 mipmap 层,实现屏幕上笔触密度始终均匀。

淋浴门效应(Shower Door Effect)

用屏幕空间坐标贴纹理时,物体运动但纹理不动,看起来像隔着花纹玻璃看物体——“游动感”。
解决: 用物体表面坐标贴纹理,或跟踪表面点在屏幕上的移动来调整纹理坐标。

形态装饰(Graftals)

按需在表面上添加几何体或贴花纹理,产生特定效果(如模拟笔触、生长的小枝)。可受 LOD、表面朝向等因素控制。

15.4 直线渲染(Lines)

15.4.1 三角形边线渲染

问题: 线和三角形在同一深度,怎么保证线总是显示在表面上面?

方法 A:固定深度偏移(Fixed Bias)

每条线都往近处偏移一点。偏移太大 → 本该隐藏的线露出来;偏移太小 → 表面遮住线。
API:OpenGL 的 glPolygonOffset

方法 B:重心坐标着色法(Barycentric Coordinates)

Pixel Shader 利用三角形的重心坐标,计算当前像素到最近边的距离:

// 在顶点属性中传入重心坐标
// 顶点 A: bary = (1, 0, 0)
// 顶点 B: bary = (0, 1, 0)
// 顶点 C: bary = (0, 0, 1)
// Pixel Shader 中
float minBary = min(bary.x, min(bary.y, bary.z));
float lineWidth = 0.02;
if (minBary < lineWidth)
    fragColor = edgeColor;   // 边线颜色
else
    fragColor = faceColor;   // 面颜色

特点: 轮廓边宽度是内部边的一半(每个三角形各渲染了一半宽度),通常视觉上不明显。

方法 C:纹理坐标法

沿每条边方向设置 0→1 的纹理坐标,利用 mip chain 实现恒定宽度边线,实现简单,且可控制最大密度(避免密集网格全部填黑)。

15.4.2 遮挡线渲染

正常渲染所有边(包括被遮挡的):

直接渲染全部边 → 线框图(Wireframe)

隐藏被遮挡的线:

第 1 步:所有三角面片只写入 z-buffer(不输出颜色)
第 2 步:正常渲染边(z-buffer 测试自动遮挡)

半隐藏效果(被遮挡线用浅灰色显示):

第 1 步:同上,面片写入 z-buffer
第 2 步:反转 z-buffer 测试方向(只通过比深度更远的),
         关闭 z-buffer 写入,用浅灰色画线
         → 只有被遮挡的部分通过测试,画成浅灰

模式 效果
线框(Wireframe) 所有边可见
隐线(Hidden Line) 只显示可见边
遮挡线(Obscured Line) 被遮挡的边用浅色显示
光晕(Haloed Line) 交叉处远处线被"擦除"

15.4.3 光晕(Haloing)

两条线交叉时,距离远的那条线在交叉处被"断开",凸显远近关系:

无光晕:          有光晕:
 ----+----         ----  ----
     |                  |
     |                  |
(两线都完整显示)  (水平线较远,交叉处被断开)

实现方法:

  1. 用粗四边形(代表光晕区域)把所有线段写入 z-buffer
  2. 再用细线正常渲染所有线(此时远处线的交叉区域被 z-buffer 遮挡)

15.5 文字渲染(Text Rendering)

亚像素渲染(Subpixel Rendering)

LCD 屏幕每个像素由红、绿、蓝三个竖条形子像素组成。利用这个结构,水平方向的分辨率实际上是像素数的三倍。
ClearType(微软)Quartz 2D(苹果)FreeType:均利用亚像素渲染提升文字清晰度,尤其在低分辨率屏幕上效果显著。

普通抗锯齿:   亚像素抗锯齿:
 [灰色像素]    [R][G][B] 独立控制
  模糊边缘      更细腻的水平精度

字形提示(Hinting)

调整字形轮廓,使笔划对齐到像素网格:

未提示的 "I":       提示后的 "I":
  |  |               |  |
半覆盖两列像素        精确覆盖一列像素
(模糊)              (清晰)

有向距离场(Signed Distance Field, SDF)文字

传统方法的问题: 每个字号需要一套独立的纹理,缩放/旋转会产生模糊或锯齿。
SDF 方法(Valve / Green):
每个纹素存储到最近字形边缘的有向距离(正 = 字形内部,负 = 外部):

字形 "g" 的 SDF 表示(示意):
         值越大(白色)= 越深入字形内部
         值越小(黑色)= 越远离字形
         值 = 0 的位置 = 字形边缘
  ┌────────────────────────┐
  │ - - - - - - - - - - -  │
  │ -  +-----------+  - -  │
  │ - |+++++++++++|  - -   │
  │ - |+  边缘=0 +|  - -   │
  │ - |+++++++++++|  - -   │
  │ -  +-----------+  - -  │
  │ - - - - - - - - - - -  │
  └────────────────────────┘
渲染时:距离 > 0.5 → 字形内部,输出不透明
        距离 < 0.5 → 字形外部,输出透明
        距离 ≈ 0.5 → 边缘,用 smoothstep 抗锯齿

SDF 一张低分辨率纹理,可以在任意大小、任意角度渲染出清晰的文字。

// SDF 字体 Pixel Shader
uniform sampler2D sdfTexture;
void main() {
    // 采样 SDF 值(0~1 范围)
    float dist = texture(sdfTexture, uv).r;
    // 平滑的边缘(边缘宽度控制抗锯齿)
    float edgeWidth = 0.07;
    float alpha = smoothstep(0.5 - edgeWidth, 0.5 + edgeWidth, dist);
    fragColor = vec4(textColor, alpha);
}

扩展功能:

  • 描边效果:把 smoothstep 的范围向外偏移
  • 发光效果:对更大范围的距离值叠加发光颜色
  • 阴影效果:偏移 UV 再采样一次

各技术方法汇总

卡通风格

轮廓线

水彩/素描

直线/标注

文字

简单快速

通用稳定

任意表面

可风格化

NPR 需求

目标

Toon Shading
硬阴影 / 色调量化

方法选择

TAMs
笔触纹理

重心坐标法
SDF 轮廓

SDF 字体
亚像素渲染

法线点积着色

Shell 法
三角形膨胀

图像处理
Sobel/深度差

几何轮廓检测
VAO 边展开

膨胀算子
加粗线条

隐线消除
笔触重建

关键公式速查

轮廓边判断条件(两侧三角形法线与视线点积异号):
( n 0 ⋅ v ) ( n 1 ⋅ v ) < 0 (\mathbf{n}_0 \cdot \mathbf{v})(\mathbf{n}_1 \cdot \mathbf{v}) < 0 (n0v)(n1v)<0
SDF 边缘着色(smoothstep 抗锯齿):
α = smoothstep ( 0.5 − w ,   0.5 + w ,  dist ) \alpha = \text{smoothstep}(0.5 - w,\ 0.5 + w,\ \text{dist}) α=smoothstep(0.5w, 0.5+w, dist)
其中 w w w 是边缘宽度参数(控制锐利/柔和程度)。

第16章:多边形技术 — 从零理解

“三角形这么简单的图形,却永无止境。” ——Leopold Crelle

目录

  1. 本章总览
  2. 三维数据的来源
  3. 曲面细分与三角剖分
  4. 整合(Consolidation)
  5. 三角扇、三角条带与三角网格
  6. 网格简化
  7. 压缩与精度

总览

在实际工作中,我们拿到的三维模型数据往往"不完美"——格式不对、细节太多或太少、顶点乱序……本章就是要解决这些问题。

五大核心主题

输入的多边形数据
       |
       v
  [1] 曲面细分(Tessellation)
       把复杂多边形拆成三角形
       |
       v
  [2] 整合(Consolidation)
       合并顶点、统一法线方向
       |
       v
  [3] 优化(Optimization)
       排序顶点,让GPU缓存命中率更高
       |
       v
  [4] 简化(Simplification)
       去掉视觉上不重要的三角形
       |
       v
  [5] 压缩(Compression)
       减少存储空间
       |
       v
  高效渲染的网格数据

两大目标

  • 视觉精度(Accuracy):工程师需要每个倒角都清晰可见;游戏则允许某帧有小误差
  • 渲染速度(Speed):实时渲染要在毫秒内完成,所有技术都围绕这两点展开

三维数据的来源

三维模型可以通过多种方式产生,不同来源有不同的"坑":

来源方式 说明 常见问题
手动输入 直接键入顶点坐标 费时,易出错
程序化建模 代码生成(如分形、城市) 可能三角形过多/过少
建模软件 Blender、Maya 等 导出格式可能不兼容
摄影测量 多张照片重建表面 噪点多、法线不准
三维扫描仪 物理设备采集点云 点云密度过高
等值面(Isosurface) CT/MRI 医学扫描 产生大量冗余三角形
CAD 系统 精确工程设计 faceter 参数影响质量

两类建模器

建模器
 |
 |--- 实体建模器(Solid-based)
 |     用于 CAD,内部精确表示拓扑
 |     需要 faceter(分面器)转换为三角形
 |     → FEM 用的 faceter 会生成等面积三角形(不适合图形渲染!)
 |
 |--- 表面建模器(Surface-based)
       Blender/Maya,直接操作表面
       允许手动增删顶点

关键洞察:数据不是为图形渲染设计的,要理解数据的来源和目的,才能选对处理方式。

曲面细分与三角剖分

为什么要三角剖分?

GPU 和图形 API 都针对三角形优化。三角形就像"原子"——任何表面都能由三角形拼成。

四种细分方式(见下图)

原始多边形        凸分割          三角剖分         均匀网格
  (不细分)      (分成凸区域)     (全拆成三角形)   (均匀细分)
  ┌────┐         ┌──┬─┐           ┌─/─┐           ┌─┬─┬─┐
  │    │    →    │  │ │    →      │/ \│    →      ├─┼─┼─┤
  │    │         └──┴─┘           └───┘           └─┴─┴─┘

特殊三角剖分准则

  • Delaunay 三角剖分:要求每个三角形外接圆内不含其他顶点,最大化最小角
    • 公式理解:让三角形"尽量胖",避免细长三角形
    • 为什么要避免细长三角形?两个原因:插值误差大、光栅化效率低

变形四边形问题

正常四边形(俯视):        变形四边形(侧视):
  v0----v1                    v0----v1
  |      |                       X
  v3----v2                    v3----v2
                            (像沙漏/蝴蝶结)

修复方法:把顶点投影到垂直于多边形近似法线的平面上,使用 Newell 公式 计算法线:

Newell 公式:将多边形投影到 xy、xz、yz 三个正交平面上,各投影面积即为法线对应分量。

耳朵裁剪算法(Ear Clipping)

直觉理解:找多边形的"耳朵"(一个可以安全切掉的三角形),不断裁剪,直到全部三角化。

什么是"耳朵"?
  顶点 v_i 是耳朵,当且仅当:
  连线 v_{i-1} 到 v_{i+1} 不与任何多边形边相交
  且三角形 (v_{i-1}, v_i, v_{i+1}) 完全在多边形内部
裁剪过程:
  初始多边形 v0,v1,v2,v3,v4
       |
       | 发现 v2 是耳朵
       v
  切掉 v2,剩下 v0,v1,v3,v4
       |
       | 重新检查 v1, v3 是否成为耳朵
       v
  继续...直到只剩3个顶点(最后一个三角形)

时间复杂度 O ( n 2 ) O(n^2) O(n2),更高级的方法可达 O ( n log ⁡ n ) O(n \log n) O(nlogn) 乃至近似 O ( n ) O(n) O(n)

用光栅化做三角剖分(奇偶填充法)

一个聪明的方法:直接用 GPU 的模板缓冲区(Stencil Buffer)来做三角剖分:

  1. 以多边形某顶点为中心,画出所有三角形扇到模板缓冲区(使用"翻转"模式)
  2. 被奇数次绘制的区域 = 应填充区域;偶数次 = 孔洞
  3. 第二遍渲染时,用模板缓冲区只画出应填充的区域
多边形:凹形(内部有孔)
步骤1:三角形 [012]          步骤2:三角形 [023]
  ██████                       ████░░
  ██████         →             ████░░   (A、B 区域被"翻"回空)
  ██████                       ██████
步骤3:三角形 [034]           最终结果:只有正确区域被填充

缺点:每个多边形要两个渲染pass,深度缓冲区无法直接使用。

着色问题:四边形分割方向的选择

把四边形切成两个三角形时,有两种对角线可以选,选错了会有视觉瑕疵:

  • 无附加数据:选最短对角线
  • 有顶点颜色(如烘焙光照):选颜色差异最小的那条对角线
四边形顶点颜色:                  选哪条对角线?
  暗─────────亮                    连接两个"差异最小"的角
  |           |        →           暗──亮
  暗─────────中                    │ ╲ │
                                   暗──中   (更平滑)

T 形顶点问题(T-Vertices)

当两个相邻网格的边相交,但不共享所有顶点时,会出现着色断裂

有 T-顶点(错误):              修复后:
  a────b────c                    a────b────c
  │    │    │          →         │  ╲ │ ╱  │
  │    │    │                    │    d    │
  └────┘────┘                    └────┴────┘
  b 是 T-顶点:它属于左侧的三角形,       将 b 加入右侧三角形的顶点列表
  但不属于右侧的三角形 acd               消除着色不连续

整合

整合是把零散的多边形数据转变成可以高效使用的网格结构。

网格结构基础

一个多边形网格由两部分组成:

// 顶点列表:每个顶点存储位置、法线、纹理坐标等
struct Vertex {
    float x, y, z;       // 位置
    float nx, ny, nz;    // 法线
    float u, v;          // 纹理坐标
};
// 索引列表:每3个索引描述一个三角形
// 比如 indices = {0,1,2, 0,2,3} 表示两个三角形
// 三角形1用顶点0,1,2;三角形2用顶点0,2,3
std::vector<Vertex>   vertices; // 顶点数组
std::vector<uint32_t> indices;  // 索引数组

好处:一个顶点只存一次,被多个三角形共用,节省内存。

顶点合并(Merging / Welding)

问题:来自不同多边形的顶点,坐标可能完全相同,却被存了多次(“三角形汤”)。
解决:用哈希表合并:

#include <iostream>
#include <vector>
#include <unordered_map>
#include <string>
#include <sstream>
// 顶点结构
struct Vertex {
    float x, y, z;
};
// 为哈希表生成顶点的字符串键
// 实际生产中会用更高效的哈希函数,这里为了清晰用字符串演示
std::string vertexKey(const Vertex& v) {
    std::ostringstream oss;
    oss << v.x << "," << v.y << "," << v.z;
    return oss.str();
}
// 将"三角形汤"合并为带索引的网格
// 输入:每个三角形有3个独立顶点(可能重复)
// 输出:去重后的顶点列表 + 索引列表
void mergeMesh(
    const std::vector<Vertex>& soupVertices,  // 输入:原始顶点(可能重复)
    std::vector<Vertex>&       outVertices,   // 输出:去重顶点列表
    std::vector<uint32_t>&     outIndices     // 输出:索引列表
) {
    // 哈希表:顶点键 -> 在 outVertices 中的索引
    std::unordered_map<std::string, uint32_t> vertexMap;
    uint32_t counter = 0;
    for (const Vertex& v : soupVertices) {
        std::string key = vertexKey(v);
        auto it = vertexMap.find(key);
        if (it == vertexMap.end()) {
            // 新顶点:加入列表,记录索引
            vertexMap[key] = counter;
            outVertices.push_back(v);
            outIndices.push_back(counter);
            counter++;
        } else {
            // 已有顶点:直接用已有索引
            outIndices.push_back(it->second);
        }
    }
}
int main() {
    // 两个三角形,共享顶点 (0,0,0) 和 (1,0,0)
    std::vector<Vertex> soup = {
        {0,0,0}, {1,0,0}, {0,1,0},   // 三角形1
        {1,0,0}, {1,1,0}, {0,1,0}    // 三角形2(有2个重复顶点)
    };
    std::vector<Vertex>   mergedVerts;
    std::vector<uint32_t> mergedIdx;
    mergeMesh(soup, mergedVerts, mergedIdx);
    std::cout << "原始顶点数: " << soup.size() << std::endl;
    std::cout << "合并后顶点数: " << mergedVerts.size() << std::endl;
    // 原始6个顶点 → 合并后4个顶点
    return 0;
}

朝向一致性(Orientation)

所有多边形应该以统一的方向面向外,否则背面裁剪等算法会出错。
右手法则:从正面看,顶点按逆时针排列,拇指方向即法线方向。

逆时针(正确,法线朝向观察者):
      v1
     / \
    /   \
  v0────v2
  (法线从纸面向外)
顺时针(错误,法线朝内):
      v1
     / \
    /   \
  v2────v0

统一朝向的算法

步骤1:为所有多边形建立半边(Half-edge)结构
        每条边记录:起点、终点、所属面
步骤2:用哈希/排序找到共享边(两个面共用的边)
步骤3:建立邻接图(哪些面相邻)
步骤4:从任意一个面开始,BFS/DFS 遍历
        如果邻面与当前面共享边的方向相同
        → 翻转邻面(朝向不一致)
        否则 → 不翻转
步骤5:计算整组的有符号体积
        如果为负 → 整组翻转(面向外而非内)

有符号体积:对每个三角形计算标量三重积,求和即得整个封闭网格的有符号体积。符号告诉我们法线是朝内还是朝外。

密封性(Solidity / Watertight)

一个"密封"(或"防水")的网格满足:每条边恰好被两个三角形共享
密封网格的重要性:

  • 背面裁剪可以安全使用(提升性能)
  • 阴影体算法需要密封网格
  • 3D 打印需要密封网格
    流形(Manifold):拓扑上无异常的表面(无三个以上面共享同一条边,无两个角相互接触)。

法线平滑与折痕边

多边形网格在没有法线时,渲染出来棱角分明;加上平滑法线后,看起来像曲面。

无顶点法线(棱角分明):      有顶点法线(平滑):
    ___                           ___
   /   \                        /    \
  /     \          →           /      \
 /       \                    /        \
(看起来像多面体)               (看起来像球)
平滑组(Smoothing Groups)

模型格式可以指定哪些多边形属于同一曲面组,组间边界视为"硬边"(折痕)。

折痕角度(Crease Angle)

比较相邻两面法线的二面角(Dihedral Angle):

  • 二面角 < 折痕角度 → 同一平滑组(软边)
  • 二面角 >= 折痕角度 → 不同平滑组(硬边,有折痕)
    典型折痕角度:20~50 度。
顶点法线的计算方法

错误方法(简单平均)

顶点 P 被 3 个三角形共享:
法线 = (n1 + n2 + n3) / 3
问题:如果某个三角形被细分成两个小三角形,
     它的权重就变成原来的2倍,结果偏移!

正确方法(角度加权)
n v = normalize ( ∑ i = 1 m α i ⋅ n i ) \mathbf{n}_v = \text{normalize}\left(\sum_{i=1}^{m} \alpha_i \cdot \mathbf{n}_i\right) nv=normalize(i=1mαini)
其中 α i \alpha_i αi 是面 i i i 在顶点处的内角(弧度), n i \mathbf{n}_i ni 是面 i i i 的法线。
直觉:面积大但角度小的面,对顶点法线的贡献应该小;角度大的面,影响应该大。无论怎么三角化,结果保持一致。

高度场法线快速近似

对于地形(高度场),有公式可以快速计算法线近似值:
n ≈ ( p x − 1 x − p x + 1 x p y − 1 y − p y + 1 y 2 ) \mathbf{n} \approx \begin{pmatrix} p^x_{x-1} - p^x_{x+1} \\ p^y_{y-1} - p^y_{y+1} \\ 2 \end{pmatrix} n px1xpx+1xpy1ypy+1y2
(未归一化, p x ± 1 x p^x_{x\pm1} px±1x 表示 x 方向相邻点的高度差)

三角扇、三角条带与三角网格

为什么要这些数据结构?

最朴素的方法:三角形列表(Triangle List)——每个三角形独立存3个顶点。问题:相邻三角形的共享顶点要重复存储,GPU 要重复处理。
核心思路:让相邻三角形共享顶点,减少 GPU 的重复工作。

三角扇(Triangle Fan)

一个中心顶点,向外辐射出多个三角形:

      v2
     /  \
   v3    v1
   |  v0  |       v0 是中心顶点
   v4    v5       每次只需发送一个新顶点
     \  /
      v6

数学定义:有序顶点列表 { v 0 , v 1 , … , v n − 1 } \{v_0, v_1, \ldots, v_{n-1}\} {v0,v1,,vn1},第 i i i 个三角形为:
△ v 0   v i + 1   v i + 2 , 0 ≤ i < n − 2 \triangle v_0 \, v_{i+1} \, v_{i+2}, \quad 0 \leq i < n-2 v0vi+1vi+2,0i<n2
平均顶点数分析:若条带长度(三角形数)为 m m m,则平均每个三角形需发送的顶点数:
v a = 3 + ( m − 1 ) m = 1 + 2 m v_a = \frac{3 + (m-1)}{m} = 1 + \frac{2}{m} va=m3+(m1)=1+m2
m → ∞ m \to \infty m v a → 1 v_a \to 1 va1。比如 m = 5 m=5 m=5 时, v a = 1.4 v_a = 1.4 va=1.4,远优于朴素方法的 3。
优点:凸多边形天然可以表示为三角扇。

三角条带(Triangle Strip)

连续的三角形共享前两个顶点:

发送顺序:v0, v1, v2, v3, v4, v5 ...
v0────v2────v4
  \ T0 / \ T2 /
   \  / T1 \  /
   v1────v3────v5
T0 = [v0, v1, v2]
T1 = [v1, v2, v3]  (复用 v1, v2)
T2 = [v2, v3, v4]  (复用 v2, v3)

数学定义:第 i i i 个三角形为:
△ v i   v i + 1   v i + 2 , 0 ≤ i < n − 2 \triangle v_i \, v_{i+1} \, v_{i+2}, \quad 0 \leq i < n-2 vivi+1vi+2,0i<n2
注意:相邻三角形方向(顺/逆时针)交替,GPU 内部自动处理。
平均顶点数与三角扇相同:
v a = 1 + 2 m v_a = 1 + \frac{2}{m} va=1+m2
m = 20 m = 20 m=20 时, v a = 1.1 v_a = 1.1 va=1.1,接近理论极限 1。理论上最多节省 2/3 的顶点处理时间。
适用场景:草叶、刀刃等不被其他条带共享边顶点的细长物体。

三角网格(Triangle Mesh)+ 索引缓冲区

现代 GPU 的标准方式:一个顶点缓冲区 + 一个索引缓冲区

#include <iostream>
#include <vector>
#include <cstdint>
// 顶点结构
struct Vertex {
    float x, y, z;   // 位置
    float nx, ny, nz; // 法线
    float u, v;       // 纹理坐标
};
int main() {
    // 顶点缓冲区:每个顶点只存一次
    std::vector<Vertex> vertexBuffer = {
        // x,  y,  z,  法线(简化),  uv
        { 0.0f, 0.0f, 0.0f,  0,1,0,  0.0f, 0.0f },  // 顶点0
        { 1.0f, 0.0f, 0.0f,  0,1,0,  1.0f, 0.0f },  // 顶点1
        { 1.0f, 1.0f, 0.0f,  0,1,0,  1.0f, 1.0f },  // 顶点2
        { 0.0f, 1.0f, 0.0f,  0,1,0,  0.0f, 1.0f },  // 顶点3
    };
    // 索引缓冲区:每3个索引描述一个三角形
    // 一个正方形由两个三角形组成
    std::vector<uint16_t> indexBuffer = {
        0, 1, 2,  // 三角形1
        0, 2, 3   // 三角形2(顶点0和2被两个三角形共享)
    };
    // 索引类型选择:
    // uint16_t(unsigned short):最多 2^16 = 65536 个顶点
    // uint32_t(unsigned int)  :最多 2^32 个顶点(大型网格)
    std::cout << "顶点数: " << vertexBuffer.size() << std::endl;  // 4
    std::cout << "三角形数: " << indexBuffer.size() / 3 << std::endl;  // 2
    return 0;
}

索引类型选择

  • 顶点数 ≤ 2 16 = 65536 \leq 2^{16} = 65536 216=65536:用 uint16_t(省内存)
  • 顶点数更多:用 uint32_t

Euler-Poincaré 公式与网格统计

对于连通的平面图:
v − e + f + 2 g = 2 v - e + f + 2g = 2 ve+f+2g=2
其中:

  • v v v = 顶点数(vertices)
  • e e e = 边数(edges)
  • f f f = 面数(faces)
  • g g g = 亏格(genus,即"洞"的数量,球体 g = 0 g=0 g=0,圆环 g = 1 g=1 g=1
    对于封闭三角网格( g = 0 g=0 g=0)的推论
    f ≈ 2 v , e ≈ 3 v f \approx 2v, \quad e \approx 3v f2v,e3v
  • 三角形数约为顶点数的2倍
  • 每个顶点平均连接约6条边(称为顶点的"价",valence)

GPU 顶点缓存(Vertex Cache)

GPU 有一个变换后顶点缓存(Post-Transform Cache),存储最近处理过的顶点结果:

如果下一个三角形需要的顶点在缓存中 → 直接用,不重新计算(Cache Hit)
如果不在 → 重新计算(Cache Miss,耗时)
理想情况(大封闭网格):每个顶点平均被 2 个三角形共用
                        理论 ACMR = 0.5 顶点/三角形
最差情况(随机顺序):ACMR ≈ 3 顶点/三角形

ACMR(平均缓存未命中率):每个三角形平均需要处理的顶点数,范围 [ 0.5 , 3.0 ] [0.5, 3.0] [0.5,3.0]

缓存无关网格布局(Cache-Oblivious Mesh Layout)

问题:最优三角形顺序取决于缓存大小,但缓存大小因硬件而异。
Forsyth 算法(贪心策略):

  • 给每个顶点打分:在缓存中的位置越靠前得分越低(避免只做条带)
  • 未处理三角形越少的顶点得分越高(避免留下孤立三角形)
  • 每次选择得分最高的三角形处理
    效果:对30个未优化模型,优化前平均 ACMR = 1.522,优化后降至 0.664 或更低。

网格简化

目标:减少三角形数量,同时尽量保持视觉质量。

原始模型:200,000 三角形(高精度)
简化后:  1,000 三角形  (仍看起来不错)
应用场景:
  - LOD(Level of Detail):远处用低精度模型
  - 弱设备:性能较差的机器显示更少三角形
  - 网络传输:先传低精度,逐步细化

三种类型:

简化类型
 |
 |--- 静态简化(Static)
 |     预先生成多个 LOD 模型,运行时选择
 |
 |--- 动态简化(Dynamic / CLOD)
 |     连续的 LOD,不是几个离散档位
 |
 |--- 视角相关简化(View-dependent)
       近处细,远处粗(地形渲染)

边坍缩(Edge Collapse)

核心操作:把一条边的两个顶点合并为一个点,从而删掉2个三角形。

坍缩前:                  坍缩后(u 合并到 v):
     A                          A
    /|\                        / \
   / | \                      /   \
  B  |  C       u→v          B─────C
   \ | /          →          (三角形 ABu 和 CBu 消失)
    \|/
  u───v

对封闭网格:一次边坍缩删除 2 个三角形、3 条边、1 个顶点。
可逆性:存储坍缩顺序,可以从简化版逆向重建复杂版,支持渐进式网格(Progressive Mesh)

顶点放置策略


策略 说明 优点 缺点
子集放置 只能选 u→v 或 v→u 速度快,可隐式编码 质量略低
最优放置 两点合并到最优位置 质量更高 计算量大

二次误差度量(QEM,Quadric Error Metric)

Garland & Heckbert 提出的代价函数,衡量顶点移动后偏离原始曲面多少。
直觉:每个顶点周围有若干三角形平面,新位置到这些平面的距离平方和越小越好。
数学公式:对顶点新位置 v \mathbf{v} v,代价为:
c ( v ) = ∑ i = 1 m ( n i ⋅ v + d i ) 2 c(\mathbf{v}) = \sum_{i=1}^{m} (\mathbf{n}_i \cdot \mathbf{v} + d_i)^2 c(v)=i=1m(niv+di)2
其中 n i \mathbf{n}_i ni 是第 i i i 个平面的法线, d i d_i di 是该平面到原点的距离。
具体例子(见书中图 16.19):

一个立方体,某边上有一个额外点 e,角点为 c
e → c 的代价 = 0
    (e 移到 c,没有偏离原来所在的平面)
c → e 的代价 = 1
    (c 移到 e,偏离了右侧面 1 个单位,1² = 1)
结论:优先选择代价小的 e → c 坍缩

需要避免的坍缩

某些坍缩会让三角形法线翻转,必须检测并跳过:

坍缩前:正常网格             坍缩后:边交叉!(非法)
  ┌─────────────┐              ┌───────X─────┐
  │             │              │      / \    │
  │      u──v   │    u→v       │     /   \   │
  │             │    →         │    /     \  │
  └─────────────┘              └─────────────┘
                                (两条边相交,非法)

检测方法:坍缩后,检查相邻三角形法线是否发生翻转,如果翻转则拒绝本次坍缩。

顶点聚类(Vertex Clustering)

一种快速但质量较低的简化方法:

步骤1:用三维网格(voxel grid)覆盖整个模型
步骤2:同一体素内的顶点合并为一个"最优"顶点
步骤3:原本三角形的三个顶点若落入同一体素 → 三角形退化消失
优点:速度快,不需要网格连通性信息,可合并多个独立网格
缺点:质量不如 QEM,参数难以精确控制

压缩与精度

为什么要压缩?

  • 减少 GPU 内存占用
  • 降低内存带宽消耗(带宽是瓶颈之一)
  • 更多数据可放入缓存

索引缓冲区压缩

最简单的压缩:根据顶点数选择合适的索引类型。

顶点数 索引类型 每个索引字节数
≤ 256 \leq 256 256 uint8_t 1 字节(部分GPU不支持,对齐问题)
≤ 65536 \leq 65536 65536 uint16_t 2 字节
> 65536 > 65536 >65536 uint32_t 4 字节

注意:OpenGL ES 2.0 和未扩展的 WebGL 1.0 不支持 uint32_t 索引,只能用 uint16_t

顶点数据固定率压缩

原始单精度浮点存储(每顶点):
  position(x,y,z): 3 × 4 = 12 字节
  normal(x,y,z):   3 × 4 = 12 字节
  color(r,g,b):    3 × 4 = 12 字节
  texcoord(u,v):   2 × 4 =  8 字节
  合计:44 字节/顶点
压缩后(典型):
  position(x,y,z): 3 × 2 = 6 字节(uint16,用包围盒缩放)
  normal:          2 字节(八面体编码)
  color(r,g,b,_):  4 × 1 = 4 字节(uint8 + 1字节对齐填充)
  texcoord(u,v):   2 × 2 = 4 字节(uint16,范围[0,1])
  合计:约16字节/顶点(节省约63%)

标量量化(Scalar Quantization)

把浮点数压缩到整数,用偏移量和缩放因子记录范围:

#include <iostream>
#include <vector>
#include <cstdint>
#include <algorithm>
// 将浮点数组量化为 uint16_t
// offset: 最小值,scale: 值域范围
void quantize(
    const std::vector<float>& data,
    std::vector<uint16_t>& quantized,
    float& offset,    // 输出:用于还原的偏移量
    float& scale      // 输出:用于还原的缩放因子
) {
    // 找最大最小值
    float minVal = *std::min_element(data.begin(), data.end());
    float maxVal = *std::max_element(data.begin(), data.end());
    offset = minVal;
    scale  = maxVal - minVal;
    quantized.resize(data.size());
    for (size_t i = 0; i < data.size(); ++i) {
        // 归一化到 [0, 1],再映射到 [0, 65535]
        float normalized = (data[i] - offset) / scale;
        quantized[i] = static_cast<uint16_t>(normalized * 65535.0f + 0.5f);
    }
}
// 还原:uint16_t → float
float dequantize(uint16_t q, float offset, float scale) {
    return (q / 65535.0f) * scale + offset;
}
int main() {
    // 温度数据示例:范围 285.1 到 197.12(从书中取值)
    std::vector<float> temperatures = {285.1f, 300.0f, 197.12f, 240.5f, 350.0f};
    float offset, scale;
    std::vector<uint16_t> compressed;
    quantize(temperatures, compressed, offset, scale);
    std::cout << "压缩前(float,4字节/个)和还原结果:" << std::endl;
    for (size_t i = 0; i < temperatures.size(); ++i) {
        float restored = dequantize(compressed[i], offset, scale);
        std::cout << "  原始: " << temperatures[i]
                  << " | 量化: " << compressed[i]
                  << " | 还原: " << restored << std::endl;
    }
    // 每个数据从 4 字节压缩到 2 字节,节省 50%
    return 0;
}

还原公式
value = q 2 16 − 1 × scale + offset \text{value} = \frac{q}{2^{16}-1} \times \text{scale} + \text{offset} value=2161q×scale+offset

法线压缩

法线是归一化向量,所有可能的法线构成一个球面。研究者设计了将球面映射到平面的方案:

  • 八面体编码(Octant Projection):将法线投影到八面体表面,再展开为正方形,用 2 个 uint8_t 存储
  • 解码效率高,误差小,是目前推荐方案
球面 → 八面体展开 → 正方形(u, v)∈ [0,1]²
只需存 (u, v) 两个值(每个 1 字节),共 2 字节
比原来的 12 字节(3个float)节省 83%

切线空间压缩(法线贴图相关)

法线贴图需要每顶点存储:法线 n \mathbf{n} n、切线 t \mathbf{t} t、副切线 b \mathbf{b} b
压缩思路:三者互相垂直时,只需存两个,第三个可由叉积计算:
b = n × t \mathbf{b} = \mathbf{n} \times \mathbf{t} b=n×t
更紧凑:用一个 4 字节四元数(加 1 bit 手性标志)表示整个旋转矩阵,精度更高。

浮点精度问题

经典案例:太空飞船在地球上方渲染,飞船精度到毫米,地球在 100,000 米外。
精度差 = 100,000  m 0.001  m = 10 8 ≈ 2 27 \text{精度差} = \frac{100{,}000 \text{ m}}{0.001 \text{ m}} = 10^8 \approx 2^{27} 精度差=0.001 m100,000 m=108227
单精度浮点只有约 2 24 2^{24} 224 的尾数精度,不够用!结果:飞船顶点在屏幕上"抖动"。
解决方法1:变换流水线中,先把世界空间平移与相机平移合并(两者相减大部分抵消),减少精度损失。
解决方法2:将世界分段,每段有自己的局部原点,在段之间切换时重新定义原点。

整体流程总结

原始多边形数据(可能来自任何来源)
          |
          v
   [曲面细分/三角剖分]
     - 耳朵裁剪
     - Delaunay 三角剖分
     - 处理 T-顶点
          |
          v
      [整合]
     - 顶点合并(去重)
     - 朝向一致性
     - 法线平滑(角度加权)
          |
          v
      [优化]
     - 三角扇/条带
     - 索引缓冲区
     - 缓存友好排序(Forsyth / Tipsify)
          |
          v
      [简化]
     - 边坍缩 + QEM
     - 生成多级 LOD
     - 顶点聚类(快速简化)
          |
          v
      [压缩]
     - 量化(uint16 位置/纹理坐标)
     - 法线八面体编码
     - 索引类型优化
          |
          v
   高效的三角网格数据
   → 上传 GPU,快速渲染

关键公式汇总

三角扇/条带平均顶点数 m m m 为三角形数):
v a = 1 + 2 m v_a = 1 + \frac{2}{m} va=1+m2
Euler-Poincaré 公式(封闭网格 g = 0 g=0 g=0):
v − e + f = 2    ⟹    f ≈ 2 v ,    e ≈ 3 v v - e + f = 2 \implies f \approx 2v,\; e \approx 3v ve+f=2f2v,e3v
QEM 代价函数(顶点移动代价):
c ( v ) = ∑ i = 1 m ( n i ⋅ v + d i ) 2 c(\mathbf{v}) = \sum_{i=1}^{m} (\mathbf{n}_i \cdot \mathbf{v} + d_i)^2 c(v)=i=1m(niv+di)2
角度加权顶点法线
n v = normalize  ⁣ ( ∑ i α i ⋅ n i ) \mathbf{n}_v = \text{normalize}\!\left(\sum_{i} \alpha_i \cdot \mathbf{n}_i\right) nv=normalize(iαini)
标量量化还原 q q q 为量化值,范围 [ 0 , 2 16 − 1 ] [0, 2^{16}-1] [0,2161]):
value = q 2 16 − 1 × scale + offset \text{value} = \frac{q}{2^{16}-1} \times \text{scale} + \text{offset} value=2161q×scale+offset
高度场法线近似
n ≈ ( p x − 1 x − p x + 1 x p y − 1 y − p y + 1 y 2 ) \mathbf{n} \approx \begin{pmatrix} p^x_{x-1} - p^x_{x+1} \\ p^y_{y-1} - p^y_{y+1} \\ 2 \end{pmatrix} n px1xpx+1xpy1ypy+1y2

推荐工具与资源

  • MeshLab:开源网格可视化与处理工具,实现了大量算法(网格清理、法线生成、简化等)
  • Assimp:开源库,读写各种三维文件格式
  • Draco / Open3DGC:三角网格压缩库,可将模型文件压缩至原来的 1/4 以下
  • 参考书Level of Detail for 3D Graphics(Luebke 等著)深入讲解简化与 LOD
Logo

免费领 200 小时云算力,进群参与显卡、AI PC 幸运抽奖

更多推荐