返回知识库

GIS

渲染管线

渲染管线 封面
GISengine-webgpu渲染管线Tone Mapping大气散射

前情回顾07 矢量与实体 讲清了 GPU 实例化、SDF/MSDF 字体渲染和 Billboard 技术。当数万标记、折线和地名标签被高效提交到 GPU 后,它们是如何在屏幕上变成最终像素的?一篇优秀的 3D 地图为什么看起来”通透”、“有空气感”?本篇回答:GPU 渲染管线的工作原理,以及如何管理颜色和光。

直觉问题

打开任意一个现代 3D 地图引擎,观察这些现象:

  • 为什么远处的山看起来是”蓝”的? 现实中空气对光的散射(Rayleigh 散射)会让远山偏蓝。在计算机里,这种”有空气感的画面”是怎么模拟的?
  • 为什么高亮的云层像”炸开”一样过曝? 将 HDR(高动态范围)光照直接显示在屏幕上时,过亮的区域会丢失细节。Tone Mapping 是如何把”无限亮度的光”压到屏幕能显示的 0~1 范围内的?
  • 8 步帧循环里的每一步都在干什么? 第 1 步”相机更新”和第 4 步”视锥剔除”谁先谁后?
  • 透明物体(如半透明多边形)为什么不能和不透明物体一起画? 如果先画了半透明地面再画山脉,会发生什么?
  • Picking 的颜色编码为什么不直接在最前面做? 如果 Picking 和最终渲染使用同一管线,会有什么问题?

读完本篇,你能回答:GPU 渲染管线每个阶段发生了什么?为什么 Tone Mapping 不是”加个滤镜”而是物理上必要的?大气散射如何用数学模拟?

核心概念白话讲

GPU 渲染管线:从顶点到像素的流水线

想象你是一个 GPU——你的工作是:接收一堆三维坐标,把它们变成屏幕上的像素颜色。你不能”逐个点慢慢画”,你必须像工厂流水线一样高效处理。

渲染管线(Graphics Pipeline) 就是这个工厂流水线的规范,定义了数据从顶点输入到像素输出的标准路径:

GPU 渲染管线四个阶段

NOTE

关键认知:现代 GPU 的渲染管线是高度并行的。顶点处理阶段可以同时处理成千上万个顶点;像素阶段可以同时计算成千上万个像素。CPU 一次只能做一件事,GPU 天生就是”多线程狂魔”。

8 步帧循环:每一帧的完整舞蹈

上一篇提到的 8 步循环,本质上是CPU 侧的逻辑组织,但它决定了 GPU 在每个阶段接收什么数据:

步骤阶段CPU 在做什么GPU 在做什么
1. 相机更新输入计算视图矩阵、投影矩阵
2. 事件处理控制响应输入、更新状态
3. 批量绘制输入收集可见对象到 Draw List
4. 视锥剔除优化过滤视锥外的对象
5. 瓦片调度调度计算需要加载/卸载的瓦片
6. 实体渲染渲染提交 Draw List顶点 → 光栅 → 像素
7. 透明排序渲染按深度排序透明物体混合计算
8. 拾取渲染拾取生成颜色编码的 Picking Buffer快速渲染

TIP

为何分成 8 步? 这是一种逻辑分离——把”决定画什么”(步骤 15)和”真正画出来”(步骤 68)分开。前者在 CPU 上串行完成,后者把数据一次性批量提交给 GPU 并行处理。

HDR vs LDR:光照范围的”分辨率”

LDR (Low Dynamic Range):屏幕像素只能存 0~1 的颜色。1.0 就是纯白,0.0 就是纯黑。

HDR (High Dynamic Range):光照值可以远超 1.0——正午的阳光可能是 10000,室内灯光只有 5。如果直接把 HDR 值截断到 1.0,亮部全部变成死白,丢失所有细节。

特性LDRHDR
颜色精度8-bit (0~255)16-bit / 32-bit float
最大亮度1.0无上限 (> 10000)
是否截断是,> 1.0 变成白否,保留全部细节
最终显示直接输出需要 Tone Mapping

NOTE

直观类比:LDR 像只能记录 0°C ~ 100°C 的温度计,HDR 像能记录 -273°C ~ 10000°C 的精密仪器。但屏幕只能显示 0~100°C 的范围,所以需要 Tone Mapping 这把”翻译尺”把 HDR 的值映射到屏幕能显示的范围。

Tone Mapping:不只是”加个滤镜”

Tone Mapping 的核心任务:将 HDR 高动态范围的颜色映射到 LDR 显示设备的有限动态范围,同时保留视觉对比度和细节

它不是”压暗高光、提亮阴影”那么简单——这是有物理意义的:

  • 保留局部对比度:暗部和亮部都要有细节,不能”一刀切”
  • 防止过曝和欠曝:亮部不糊、暗部不黑
  • 审美调节:不同算法产生不同的”胶片感”

大气散射:为什么天空是蓝的?

Rayleigh 散射:空气分子(如 N、O)的尺寸远小于可见光波长(约 400~700 nm),根据瑞利散射定律,散射强度与波长的四次方成反比:

I(λ)1λ4I(\lambda) \propto \frac{1}{\lambda^4}

这意味着蓝光(波长约 450 nm)比红光(波长约 650 nm)散射强约 5 倍。所以:

  • 白天:太阳光中的蓝光被天空到处散射 → 天是蓝的
  • 日出/日落:阳光穿过更厚的大气层,蓝光被散射殆尽,剩下红光 → 天是红的

Mie 散射:大气中的微粒(尘埃、水滴)尺寸与光波长相近或更大。散射强度与波长关系不大,所以散射光呈现白色(所有波长混合)。

散射类型粒子尺寸波长依赖散射方向颜色
Rayleigh<< λ强(λ⁻⁴)各向同性(近似)
Mie≈ λ前向为主白/灰

NOTE

关键洞察:3D 地图中的”空气感”不是后期加滤镜,而是用物理大气模型在渲染时实时计算光线穿过大气层时的散射和吸收。这是让地球看起来”活着”的关键。

原理与数学/机制

1. GPU 渲染管线详解

1.1 顶点阶段:世界 → 屏幕

顶点阶段的核心任务:把三维世界坐标变换为二维屏幕坐标。这通过三个矩阵级联完成:

Vclip=PVMVlocalV_{clip} = P \cdot V \cdot M \cdot V_{local}

其中:

  • M (Model):把局部坐标变换到世界坐标
  • V (View):把世界坐标变换到相机坐标系
  • P (Projection):把相机坐标投影到裁剪空间(Clip Space)

裁剪空间:一个 [1,1]3[-1, 1]^3 的立方体。GPU 硬件自动丢弃立方体外的顶点,这就是几何裁剪

经过透视除法(除以 w 分量),顶点从裁剪空间进入归一化设备坐标 (NDC)[1,1]3[-1, 1]^3

最后,NDC 坐标被视口变换映射到屏幕像素坐标:

xscreen=(xNDC+1)width2+viewportxx_{screen} = (x_{NDC} + 1) \cdot \frac{width}{2} + viewport_x

yscreen=(1yNDC)height2+viewportyy_{screen} = (1 - y_{NDC}) \cdot \frac{height}{2} + viewport_y

IMPORTANT

深度值 z_NDC 不是线性的! 经过透视投影后,z 值被非线性地压缩到 [0, 1] 区间(近裁剪面附近密集,远裁剪面稀疏)。这意味着在远处进行深度比较时,精度会急剧下降。这就是深度精度问题——也是为什么远处物体重叠时会出现 z-fighting。

1.2 光栅化:三角形 → 像素

光栅化是 GPU 的”魔法”——给定三个顶点,自动算出三角形覆盖的所有像素。对于每个像素,GPU 会:

  1. 用重心坐标 (u,v,w)(u, v, w)u+v+w=1u + v + w = 1 计算该像素的属性插值
  2. 对每个顶点属性(颜色、UV、法线)进行线性插值
  3. 得到该像素的属性后,送入片元(Fragment)阶段

重心坐标插值公式(以颜色为例):

colorpixel=ucolor0+vcolor1+wcolor2color_{pixel} = u \cdot color_0 + v \cdot color_1 + w \cdot color_2

WARNING

纹理 UV 的插值并不是简单的重心坐标线性插值!在透视投影下,UV 需要除以 w 分量做透视校正插值(Perspective-Correct Interpolation),否则纹理在远处会扭曲。

1.3 片元阶段:像素着色

片元阶段是渲染管线的”大脑”——这里决定每个像素最终的颜色。对于 3D 地图,典型的片元着色流程:

输入:像素坐标 (x,y),插值后的 UV、法线、世界位置
步骤:
1. 从纹理采样基础颜色 (albedo)
2. 计算光照(环境光 + 方向光/点光)
3 come idiot3. 用 normal map 扰动法线(如果有)
4. 可选:反射/折射采样 cube map
5. 应用大气散射(后处理或逐像素)
6. 输出 RGBA

1.4 输出阶段:深度测试与混合

这是 GPU 的”智能守门员”:

深度测试 (Depth Test)

  • 读取当前像素的深度缓冲值 zbuffer(x,y)z_{buffer}(x, y)
  • 比较新像素的深度 znewz_{new}
  • 如果 znew<zbufferz_{new} < z_{buffer}(更近),更新颜色缓冲和深度缓冲
  • 否则丢弃该像素

混合 (Blending)

  • 用于半透明物体
  • output=srccolor×srcfactor+dstcolor×dstfactoroutput = src_{color} \times src_{factor} + dst_{color} \times dst_{factor}
  • 常见模式:srcfactor=srcalphasrc_{factor} = src_{alpha}, dstfactor=1srcalphadst_{factor} = 1 - src_{alpha}

IMPORTANT

半透明为什么必须排序? 混合公式中 dstcolordst_{color} 是目标颜色缓冲的当前值。如果不按从远到近顺序绘制,后面的半透明物体拿不到正确的 dstcolordst_{color},导致颜色混合错误。这就是透明排序的必要性。

2. Tone Mapping 的数学原理

2.1 Reinhard Tone Mapping

最简单的 Tone Mapping 方法,将无界亮度映射到 [0, 1]:

Lout=Lin1+LinL_{out} = \frac{L_{in}}{1 + L_{in}}

优点:简单、连续、不会过曝 缺点:整体发灰,对比度损失

2.2 Uncharted 2 Tone Mapping

由 Naughty Dog 的 John Hable 提出,模仿胶片响应曲线:

Lout=L(AL+CB)L(AL+B)+CDCFEL_{out} = \frac{L \cdot (A \cdot L + C \cdot B)}{L \cdot (A \cdot L + B) + C \cdot D} - \frac{C \cdot F}{E}

常数(经验值):A=0.15,B=0.50,C=0.10,D=0.20,E=0.02,F=0.30A = 0.15, B = 0.50, C = 0.10, D = 0.20, E = 0.02, F = 0.30

优点:保留亮部细节,暗部对比度好 缺点:几个魔法常数需要调参

2.3 ACES (Academy Color Encoding System)

电影工业标准,精确模拟胶片扫描仪的响应:

Lout=(L(aL+b))(L(cL+d)+e)L_{out} = \frac{(L \cdot (a \cdot L + b))}{(L \cdot (c \cdot L + d) + e)}

其中常数 a,b,c,d,ea, b, c, d, e 由 ACES 标准定义。

优点:行业标准、色域宽、最自然的视觉体验 缺点:计算更复杂

TIP

何时用哪种? 3D 地图引擎通常用 Uncharted 2(平衡性能和效果),电影级应用用 ACES。简单 demo 用 Reinhard 就够了。

2.4 色温和 White Balance

Tone Mapping 之后还要处理白平衡——确保”白色”真的是中性的。不同光源有不同色温:

光源色温 (K)色调
阴天天空6500中性
正午日光5500暖白
白炽灯2700橙 Threshold
烛光1850深橙

Rwb=Rout1whiterR_{wb} = R_{out} \cdot \frac{1}{white_r}

Gwb=Gout1whitegG_{wb} = G_{out} \cdot \frac{1}{white_g}

Bwb=Bout1whitebB_{wb} = B_{out} \cdot \frac{1}{white_b}

NOTE

白平衡不是”调色”! 它的物理意义是告诉相机/人眼”当前光源下什么是白色”。一个表面在日光灯下看起来偏蓝,不是因为它本身蓝,而是因为光源偏蓝——白平衡就是去除这种光源偏色,还原物体真实的颜色。

3. 大气散射的物理模型

3.1 Rayleigh 散射模型

对于从光源到观测者路径上的每一点,散射强度为:

Iscatter(λ)=I0π2(n21)22N1λ4P(θ)I_{scatter}(\lambda) = I_0 \cdot \frac{\pi^2 \cdot (n^2 - 1)^2}{2N} \cdot \frac{1}{\lambda^4} \cdot P(\theta)

其中:

  • I0I_0:入射光强度
  • nn:空气折射率
  • NN:分子数密度
  • P(θ)P(\theta):相位函数(散射方向分布)

NOTE

公式解读:为什么天空是蓝的?关键在 λ4\lambda^{-4} 项。蓝光波长约 450 nm,代入得 λ42.4×1013\lambda^{-4} \approx 2.4 \times 10^{-13};红光波长 650 nm,λ45.7×1012\lambda^{-4} \approx 5.7 \times 10^{-12}。蓝光散射强度是红光的约 5.5 倍。

3.2 Mie 散射模型

Mie 散射的强度计算需要用到更复杂的 Mie 理论(涉及贝塞尔函数),但近似公式为:

Imie(θ)=I0k2S(θ)2I_{mie}(\theta) = \frac{I_0}{k^2} \cdot |S(\theta)|^2

其中 S(θ)S(\theta) 是散射振幅函数,k=2π/λk = 2\pi/\lambda 是波数。

Mie 散射的相位函数(方向分布)明显前向偏置——光线主要沿着入射方向散射。这让太阳周围出现明显的光晕 (Glory)雾霭 (Aureole)

3.3 光学深度 (Optical Depth)

光线穿过大气层时,部分被吸收或散射。定义光学深度 τ\tau

τ=s0s1β(s)ds\tau = \int_{s_0}^{s_1} \beta(s) \, ds

其中 β(s)\beta(s) 是沿路径的消光系数。光学深度越大,衰减越严重。

Beer-Lambert 定律(描述光强衰减):

I=I0eτI = I_0 \cdot e^{-\tau}

3.4 完整的逐像素大气散射计算

在 3D 地图中,通常从相机位置 CC 向像素方向 ω\omega 追踪一条射线:

for each step t along [near, far]:
    计算当前点 P = C + t * ω 到地心的距离 r
    如果 r > 大气层外半径:break

    // 计算该点的散射系数
    ρ_rayleigh = exp(-(r - R_earth) / H_rayleigh)
    ρ_mie = exp(-(r - R_earth) / H_mie)

    // 太阳光到该点的光学深度
    τ_sun = optical_depth(P, sun_direction)

    // 该点对相机的散射贡献
    scatter_rayleigh += ρ_rayleigh * phase_rayleigh(θ) * exp(-τ_sun) * sun_color
    scatter_mie += ρ_mie * phase_mie(θ) * exp(-τ_sun) * sun_color

最终将散射结果叠加到原图上:

colorfinal=colorsceneeτtotal+scatterrayleigh+scattermiecolor_{final} = color_{scene} \cdot e^{-\tau_{total}} + scatter_{rayleigh} + scatter_{mie}

IMPORTANT

性能优化:逐像素实时计算大气散射很贵。实际工程中通常用预计算 LUT (Lookup Texture)——把不同太阳高度角、观察角度的散射结果提前烘到一张 128x128 或 256x64 的纹理中,运行时只需要查表+插值。

4. 瓦片数据的多纹理混合

3D 地图一个瓦片可能同时包含影像纹理法线纹理粗糙度纹理等。如何混合?

Modulate 混合(颜色乘积)

colorbase=texcolorvertexcolorcolor_{base} = tex_{color} \cdot vertex_{color}

Overlay 混合(基于基色亮度)

当基色 base<0.5base < 0.5 时用 2baseblend2 \cdot base \cdot blend,否则用 12(1base)(1blend)1 - 2 \cdot (1 - base) \cdot (1 - blend)

Normal Map 扰动: 将纹理采样到的法线(通常存为 [1,1][-1, 1] 映射的 RGB)从切线空间变换到世界空间,用于光照计算。

可视化对比与动手实验

实验 1:不同 Tone Mapping 算法对比

场景ReinhardUncharted 2ACES无 Tone Mapping
过曝高光偏灰、丢失细节保留亮部层次最自然的亮部纯白、完全丢失
暗部对比整体发灰暗部有层次暗部深邃暗部死黑
整体观感”洗白”感胶片感,对比适中电影级,色彩丰富截断生硬
计算开销中高

实验 2:大气散射效果观察

条件无大气散射有 Rayleigh完整大气 (Rayleigh + Mie)
天空颜色纯黑色蓝色蓝色 + 日出日落红霞
远山颜色与近山相同偏蓝偏蓝 + 雾霭层次感
太阳光晕明显的亮斑和光晕
地平线锐利略有渐变自然的雾霭过渡

实验 3:深度测试与透明排序

在浏览器中观察以下现象:

操作预期结果错误原因
先画半透明面再画山山被半透明面遮住深度测试失败,山被丢弃
半透明面从远到近排序后绘制正确显示山穿半透明面正确的混合顺序
关闭深度测试所有后画的覆盖先画的没有深度关系
关闭混合半透明面变成不透明的没有颜色混合

实验 4:Web Shader 性能基准

着色器复杂度每帧顶点数片元着色器指令数预期帧率 (1080p)
简单 (纯色)10万< 10120+ fps
中等 (纹理 + 光照)10万50~10060 fps
复杂 (PBR + 阴影)10万200+30 fps
超复杂 (大气 + 体积)10万500+< 15 fps

TIP

实际 WebGPU 驱动的地图引擎中,通常使用 LOD(细节层次)瓦片可见性剔除 来控制每帧的实际顶点数,确保复杂 shader 在可接受范围内。

常见误区

WARNING

误区 1:Tone Mapping 就是加个滤镜 Tone Mapping 是物理必要的——HDR 值包含远超屏幕显示范围的信息,如果不做 Tone Mapping 直接截断,亮部会完全丢失细节。它是把物理光照正确呈现到屏幕的必要步骤。

WARNING

误区 2:大气散射就是”加个蓝雾” “蓝雾”是简单的距离雾(Fog),只是随距离线性混合蓝色。大气散射是基于物理的积分计算,考虑太阳角度、海拔、散射类型,会随时间(一天中的不同时间)和观察方向变化。“蓝雾”是静止的,大气散射是动态的。

WARNING

误区 3:深度测试可以关闭来避免 z-fighting 关闭深度测试确实不会出现 z-fighting,但会导致”画家算法”失效——后画的物体总是覆盖先画的,无论远近。正确的做法是通过增加深度精度(如 Logarithmic Depth Buffer)、调整近裁剪面距离、或使用 polygon offset 来缓解 z-fighting。

WARNING

误区 4:GPU 渲染管线的顺序可以随意打乱 GPU 管线有其固定顺序(顶点 → 光栅 → 片元 → 输出),不能跳过阶段,也不能反转顺序。例如不能先混合再深度测试(否则深度测试就失去了意义)。

WARNING

误区 5:Picking Buffer 可以和最终画面共用同一张图 Picking 通常需要在场景内容不变但鼠标移动时频繁重绘(每一帧都重绘)。如果和最终画面共用同一管线,会导致不必要的全屏重绘。通常 Picking 使用独立的、简化的渲染流程(关闭光照、关闭 Tone Mapping),只输出颜色编码的物体 ID。

延伸阅读与自测

延伸阅读

  1. Real-Time Rendering (4th Edition) —— Tomas Akenine-Moller 等:计算机图形学”圣经”,涵盖渲染管线所有细节
  2. Physically Based Rendering (PBR) —— Matt Pharr 等:物理渲染的理论基础
  3. Tone Mapping 论文 —— Reinhard et al. “Photographic Tone Reproduction for Digital Images”:Tone Mapping 的奠基之作
  4. Precomputed Atmospheric Scattering —— Eric Bruneton & Fabrice Neyret:实时大气散射的经典论文
  5. ACES Filmic Tone Mapping —— Academy of Motion Picture Arts and Sciences:电影级 Tone Mapping 标准

自测题

  1. 计算题:给定 HDR 颜色值 (R=2.5,G=1.2,B=0.8)(R=2.5, G=1.2, B=0.8),使用 Reinhard Tone Mapping 计算映射后的 LDR 值。

  2. 概念辨析:GPU 渲染管线中,光栅化阶段片元阶段有什么区别?一个三角形经过光栅化后产生了多少个片元?这个数量和什么有关?

  3. 推导题:给定透视投影矩阵 PP,证明在 NDC 空间中 zz 值与相机空间 zz 值是非线性关系。解释为什么这会导致远处的深度精度下降(z-fighting)。

  4. 设计题:假设你要在一个 3D 地球仪上渲染大气散射效果,但目标设备是低端手机(带宽和计算有限)。你会如何设计一个”降级版”的大气散射方案?列出 3 种可行的优化策略。

  5. 综合题:在 8 步帧循环中,步骤 6(实体渲染)使用 PBR(基于物理的渲染)shader,步骤 7(透明排序)按深度排序半透明物体。如果把 PBR 的粗糙度值设得很低(镜面反射),对步骤 7 的透明排序有什么影响?为什么镜面反射的透明物体需要特殊处理?


下一篇预告09 3D Tiles 与 BIM 将深入 OGC 3D Tiles 标准、空间索引(BVH/R-tree)和地理锚定变换——带你理解”海量建筑模型如何在浏览器里流畅加载”。