real-time rendering 第四版学习:第 14 章:体积与半透明渲染(Volumetric and Translucency Rendering)
“那些你希望看起来更遥远的东西,你必须让它们成比例地更蓝;如果一个东西是五倍远,就让它蓝五倍。”
—— 达芬奇
总览
本章的核心概念是参与介质(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} m−1 |
| σ s \sigma_s σs | 散射系数 | m − 1 \text{m}^{-1} m−1 |
| σ t \sigma_t σt | 消光系数 = σ a + σ s \sigma_a + \sigma_s σa+σs | m − 1 \text{m}^{-1} m−1 |
| ρ \rho ρ | 反照率(Albedo) | 无量纲 |
| p p p | 相位函数 | sr − 1 \text{sr}^{-1} sr−1 |
反照率(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)+∫0∣p−c∣Tr(c,c−vt)⋅Lscat(c−vt,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=e−dσ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=1∑np(v,lci)⋅v(x,plighti)⋅clighti(∣x−plighti∣)
其中:
- 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 sp≪1 | 空气分子 |
| 米氏散射(Mie) | s p ≈ 1 s_p \approx 1 sp≈1 | 雾、烟、云 |
| 几何散射 | s p ≫ 1 s_p \gg 1 sp≫1 | 雨滴 |
瑞利散射(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+g2−2gcosθ)1.51−g2
- 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θ)21−k2,k≈1.55g−0.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=zend−zstartzend−zs
物理精确的指数雾(Beer-Lambert 法):
f = e − d f z s f = e^{-d_f z_s} f=e−dfzs
其中 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=f⋅ci+(1−f)⋅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)
- 在清黑的缓冲区中,以深度测试只渲染太阳附近的假亮光源
- 以太阳为中心做径向模糊(方向性模糊),把亮度向外扩散
- 将模糊结果叠加到场景缓冲区
优点:极快,视觉效果显著。
缺点:光源不在屏幕内时无效。
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′+Tr′⋅Lscat_in⋅ds, Tr′⋅Trslice)
其中 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_in−Lscat_in⋅Trslice⋅Tr′
最终像素颜色( L s L_s Ls 为不透明表面颜色):
L o = T r ⋅ L s + L scat L_o = T_r \cdot L_s + L_\text{scat} Lo=Tr⋅Ls+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 阶):
- 计算单次散射表 S lut S_\text{lut} Slut
- 用 S lut n − 1 S_\text{lut}^{n-1} Slutn−1 计算 S lut n S_\text{lut}^n Slutn
- 累加到总散射表
更紧凑的三维 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]
云的渲染方法
Schneider & Vos 方法(生产级动态云):
- 云层建模:用两层程序噪声
第一层(Perlin-Worley 混合):云的基础大形
↓
第二层(高频细节):侵蚀云的边缘,产生菜花状积云细节
- 光照:沿视线步进,对每个采样点向太阳方向发射次级步进计算透射率(体积阴影)
- 性能优化:每 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=0∑o−1Lscat(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 a≤b(确保能量守恒,避免 σ 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+Tr⋅cb
其中 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=d−log(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,n⋅v)t
其中 t t t 是材质厚度, n ⋅ v \mathbf{n} \cdot \mathbf{v} n⋅v 是法线与视线的点积,视线越接近切线方向,穿过的距离越长,越不透明(类比菲涅耳效应)。
14.5.2 折射(Refraction)
折射方向计算(Snell 定律):
t = ( w − k ) N − n l \mathbf{t} = (w - k)\mathbf{N} - n\mathbf{l} t=(w−k)N−nl
其中:
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(l⋅N),k=1+(w−n)(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 中有时做逆色散补偿。
屏幕空间折射方法:
- 正常渲染场景到纹理 s s s(折射物体除外)
- 渲染折射物体时,用法线的切线 xy 分量作为偏移,从 s s s 中采样扰动后的背景颜色
- 检查采样深度:若采样点比折射表面更近,则忽略(避免把表面前方的内容折射进来)
粗糙折射: 对不同粗糙度的表面,用场景的多级 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} n⋅l(光照角度)
- 第二轴: 1 / r = ∥ ∂ n / ∂ p ∥ 1/r = \|\partial\mathbf{n}/\partial\mathbf{p}\| 1/r=∥∂n/∂p∥(表面曲率,预烘焙)
曲率越高,散射对颜色的影响越大。运行时直接采样 LUT,无需复杂计算。
14.6.4 纹理空间扩散(Texture-Space Diffusion)
- 把表面的漫反射光照渲染到纹理(以纹理坐标作为光栅化位置)
- 在纹理空间对该光照图做高斯模糊(R 通道用更宽的滤波核)
- 渲染时用模糊后的光照图做漫反射着色
效果:阴影边缘柔和、有颜色渗透(红色皮肤在阴影边缘更红)。
代价较高(多次模糊 Pass),但可以缩减质量换取性能。
14.6.5 屏幕空间扩散(Screen-Space Diffusion)
在屏幕空间做 SSS,避免为每个网格渲染单独的光照图:
- 正常渲染场景,在模板缓冲区标记需要 SSS 的像素
- 对标记区域在屏幕空间做两次(水平+垂直)带颜色的双边模糊
- 用线性深度拉伸模糊宽度(近处更宽,远处更窄)
- 双边滤波防止跨深度漏光
优势:一个 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 tss⋅css⋅(v⋅−l)+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=0∑o−1Lscat(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 能力限制使体积和表面必须分开处理;随着硬件进步,统一表示有望成为可能。
各方法适用场景速查
关键公式速查
消光系数:
σ 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)=e−∫xaxbσ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=d−log(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+g2−2gcosθ)1.51−g2
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θ)21−k2,k≈1.55g−0.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−σt⋅t/max(0.001,n⋅v)
第 15 章:非真实感渲染(Non-Photorealistic Rendering, NPR)
“把’非线性科学’这个词用作术语,就好比把动物学的大部分内容称为’非大象动物研究’。”
—— Stanislaw Ulam
总览
真实感渲染(Photorealistic Rendering) 的目标是让图像与照片无法区分。
非真实感渲染(NPR) 则有截然不同的目标——不是"像照片",而是"有用、有风格、有表达力"。
NPR 的两大方向:
NPR
├── 技术插图风格(Technical Illustration)
│ 只显示对目的有用的细节
│ 例:维修手册的零件轮廓图,比照片更清晰易懂
└── 绘画/自然媒介风格(Painterly / Natural Media)
钢笔素描、炭笔、水彩……
模拟各种艺术媒介的视觉感受
本章重点:
- 卡通渲染(Toon / Cel Shading)
- 轮廓线检测与渲染(Outline Rendering)
- 笔触表面风格化
- 直线渲染
- 文字渲染
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=n⋅v
当 n ⋅ v ≈ 0 \mathbf{n} \cdot \mathbf{v} \approx 0 n⋅v≈0 时,表面几乎侧对观察者,即接近轮廓边,将其涂黑。
// 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)
核心思路: 先正常渲染正面,再用特殊方式渲染背面,使背面的边缘变得可见。
步骤:
- 正常渲染所有正面(Front Face)
- 开启前面剔除,关闭背面剔除
- 以特殊方式渲染背面
方法 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 等),再用图像处理算子在缓冲区上找不连续处。
检测哪种边
| 缓冲区 | 检测的边类型 |
|---|---|
| 深度缓冲 | 轮廓边、剪影边(深度突变) |
| 法线缓冲 | 轮廓边、折痕边(法线突变) |
| 物体 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 (n0⋅v)(n1⋅v)<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 测试会自动挡住被遮挡的部分)。
可见笔触重建:
- 给所有三角形和轮廓边分配不同的 ID 号
- 读回 ID 缓冲区,判断哪些轮廓边可见
- 把可见边段连接成连续的笔触路径
- 用风格化样式渲染这些笔触(粗细变化、抖动、超出端点等)
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)
两条线交叉时,距离远的那条线在交叉处被"断开",凸显远近关系:
无光晕: 有光晕:
----+---- ---- ----
| |
| |
(两线都完整显示) (水平线较远,交叉处被断开)
实现方法:
- 用粗四边形(代表光晕区域)把所有线段写入 z-buffer
- 再用细线正常渲染所有线(此时远处线的交叉区域被 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 再采样一次
各技术方法汇总
关键公式速查
轮廓边判断条件(两侧三角形法线与视线点积异号):
( n 0 ⋅ v ) ( n 1 ⋅ v ) < 0 (\mathbf{n}_0 \cdot \mathbf{v})(\mathbf{n}_1 \cdot \mathbf{v}) < 0 (n0⋅v)(n1⋅v)<0
SDF 边缘着色(smoothstep 抗锯齿):
α = smoothstep ( 0.5 − w , 0.5 + w , dist ) \alpha = \text{smoothstep}(0.5 - w,\ 0.5 + w,\ \text{dist}) α=smoothstep(0.5−w, 0.5+w, dist)
其中 w w w 是边缘宽度参数(控制锐利/柔和程度)。
第16章:多边形技术 — 从零理解
“三角形这么简单的图形,却永无止境。” ——Leopold Crelle
目录
总览
在实际工作中,我们拿到的三维模型数据往往"不完美"——格式不对、细节太多或太少、顶点乱序……本章就是要解决这些问题。
五大核心主题
输入的多边形数据
|
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:三角形 [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=1∑mαi⋅ni)
其中 α 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≈
px−1x−px+1xpy−1y−py+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,…,vn−1},第 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,0≤i<n−2
平均顶点数分析:若条带长度(三角形数)为 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+(m−1)=1+m2
当 m → ∞ m \to \infty m→∞, v a → 1 v_a \to 1 va→1。比如 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,0≤i<n−2
注意:相邻三角形方向(顺/逆时针)交替,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 v−e+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 f≈2v,e≈3v - 三角形数约为顶点数的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=1∑m(ni⋅v+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=216−1q×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=108≈227
单精度浮点只有约 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 v−e+f=2⟹f≈2v,e≈3v
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=1∑m(ni⋅v+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∑αi⋅ni)
标量量化还原( q q q 为量化值,范围 [ 0 , 2 16 − 1 ] [0, 2^{16}-1] [0,216−1]):
value = q 2 16 − 1 × scale + offset \text{value} = \frac{q}{2^{16}-1} \times \text{scale} + \text{offset} value=216−1q×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≈
px−1x−px+1xpy−1y−py+1y2
推荐工具与资源
- MeshLab:开源网格可视化与处理工具,实现了大量算法(网格清理、法线生成、简化等)
- Assimp:开源库,读写各种三维文件格式
- Draco / Open3DGC:三角网格压缩库,可将模型文件压缩至原来的 1/4 以下
- 参考书:Level of Detail for 3D Graphics(Luebke 等著)深入讲解简化与 LOD
更多推荐

所有评论(0)