返回知识库

GIS

第一帧

第一帧 封面
GISengine-webgpu渲染循环

前情回顾00 阅读指南 给出了 12 篇笔记的依赖图与术语速查表。从本篇开始进入第一个核心子系统——渲染循环

直觉问题

写下”Hello World”级 Web GIS 代码的那一刻——

// 假设这是某 Web GIS 引擎的初始化入口
const engine = new Engine(container);

回车按下,几秒钟后,地球就出现在浏览器里了。但是中间到底发生了什么?

  • 浏览器是怎么把”经纬度”变成”屏幕像素”的?这一连串动作的顺序是什么?
  • 为什么这套流程要每秒重复 60 次?地球不是静止的吗?
  • 每一帧具体在做什么?你能像列购物清单一样列出每一步的输入和输出吗?

读完本篇,你会得到三件东西:一个通用渲染循环骨架(5-6 步)、一种典型 Web GIS 实现如何细化为 8 步的拆分方式、以及一个渲染场景内的对象关系图——它们会成为后续 11 篇笔记的共同骨架。

核心概念白话讲

用剧场排练理解”渲染循环”

想象一个剧场——演员在排练,导演喊”再来一遍”,于是整套流程重来:

剧场排练渲染循环
演员收到走位指令接收用户输入(鼠标、键盘)
演员走到新位置更新相机参数、对象位置
导演看到舞台上没人的角落视锥剔除,跳过看不见的对象
灯光师打光、道具组就位调度瓦片、纹理、几何到 GPU
演员表演、观众拍照渲染到屏幕,帧提交
”再来一遍”等下一个 VSync 信号

关键认知:渲染循环不是”按需触发”——即使你不动鼠标、不缩放地图,引擎也在每秒跑 60 次循环。因为相机参数变了要重画、新瓦片下载完了要替换、动画对象要前进——任何变化都需要新一帧

用剧场组织结构理解”渲染场景”

Web GIS 引擎内部的组织结构,和剧场管理高度相似:

剧场组织渲染场景概念职责
剧场建筑渲染场景容纳所有元素的容器
舞台调度渲染器协调每帧的执行顺序
演员表渲染节点可独立渲染的单元(灯光、行星、实体)
主演行星节点场景里的地球,挂载四叉树
角色剧本四叉树决定哪些瓦片上场、哪些下场
道具瓦片段单个瓦片的几何 + 影像 + 高程
道具分类图层底图、道路、标注、矢量等透明叠加

这套关系链是:渲染场景 → 渲染器 → 渲染节点 → 行星节点 → 四叉树 → 瓦片段 → 图层。后续笔记会逐层深入,但你现在先把这个链子记住。

NOTE

这套用中文叙述的概念名(“渲染场景""渲染节点""瓦片段”等)是概念性角色,对应到 Three.js / Babylon.js / Cesium 等不同引擎的具体类名可能不同,但概念是统一的。本系列所有笔记都按这种概念性中文叙述,不绑定具体引擎的实现细节。

原理与数学/机制

1. 通用渲染循环骨架(5-6 步)

任何实时渲染引擎——从游戏引擎到 Web GIS——的渲染循环都可以归纳为 5-6 步骨架。下面是最通用的版本:

图 1:通用渲染循环骨架(任何实时渲染引擎都能套进这个 6 步框架)

每一步的角色:

步骤名称主要工作时间预算(60 FPS)
① 输入采集Input收集用户事件、传感器数据< 1 ms
② 状态更新Update相机移动、对象动画、物理积分1-3 ms
③ 可见性剔除Culling视锥剔除、遮挡剔除、LOD 选择1-3 ms
④ 绘制RenderGPU draw call、shader 执行8-12 ms
⑤ 后处理Post-processTone Mapping、抗锯齿、合成1-3 ms
⑥ 帧提交Present交换缓冲、等待 VSync< 1 ms

TIP

60 FPS 意味着每帧总时间预算 ≤ 16.67 ms1000/601000 / 60)。其中绘制(步骤 ④)通常占大头,是优化的首要目标。

2. 一种典型实现如何细化为 8 步

Web GIS 引擎在通用 6 步骨架基础上,因瓦片调度、矢量实体、大气散射、拾取等 GIS 特有需求,会把循环进一步细化。下面是一种典型实现的 8 步拆分:

图 2:一种典型 Web GIS 实现的渲染循环细化为 8 步
步骤名称输入输出
输入与状态预处理用户事件、时间增量 Δt\Delta t更新后的相机参数、待处理事件队列
视锥与可见性更新相机参数当前帧视锥体、粗筛后的瓦片集合
LOD 与瓦片调度粗筛瓦片、相机到瓦片距离加载/卸载决策、本帧绘制列表
不透明几何绘制影像瓦片、地形网格不透明场景缓冲(Color + Depth)
矢量实体绘制点/线/面/Billboard 数据实体几何缓冲(合并到主场景)
透明物体与大气绘制半透明对象、大气参数合成后场景缓冲(HDR)
后处理与屏幕合成HDR 场景缓冲LDR 屏幕画面(Tone Mapping 后)
拾取与帧同步鼠标坐标、上一帧 Picking 缓冲命中对象 ID、帧结束信号

IMPORTANT

渲染循环的步骤数因引擎而异——Three.js 通常 3-4 步、Babylon.js 5-7 步、Cesium 6-8 步。本篇讲的 8 步是一种典型 Web GIS 实现,不是唯一标准。理解了通用骨架(图 1),具体引擎的步骤数只是”在哪里再细分一刀”的问题。

通用骨架与 8 步细化的对应关系

通用骨架步骤对应的 8 步细化细化原因
① 输入采集步骤一合并时间增量预处理
② 状态更新步骤一、二拆出”视锥更新”独立步骤
③ 可见性剔除步骤二、三拆出”LOD 调度”独立步骤(瓦片特有)
④ 绘制步骤四、五、六按不透明/矢量/透明分三遍绘制
⑤ 后处理步骤七
⑥ 帧提交步骤八合并 Picking 读回与帧同步

为什么要拆?三个原因:①瓦片调度需要单独的 LOD 判断(GIS 特有);②不透明/矢量/透明必须按渲染顺序分遍(深度测试要求);③拾取需要异步读 GPU 缓冲,不能阻塞主循环。

3. 渲染场景内的概念对象关系

把”渲染场景 → 渲染器 → 渲染节点 → 行星节点 → 四叉树 → 瓦片段 → 图层”这条链画成图:

图 3:渲染场景内的概念对象关系(中文叙述,不绑定具体引擎类名)

关键关系

  • 渲染场景是顶层容器,里面挂着所有渲染节点 + 渲染器
  • 渲染器不持有数据,只负责”每帧按 8 步流程跑一遍”
  • 渲染节点是一个抽象——灯光、行星、矢量实体都是它的子类
  • 行星节点是 Web GIS 特有的核心节点,挂载四叉树
  • 四叉树递归地管理瓦片段的加载/卸载/可见性
  • 瓦片段是渲染的最小单元——一个瓦片对应一组几何 + 多层影像纹理
  • 图层是数据源抽象——同一瓦片段可以叠加多个图层(底图 + 道路 + 标注)

4. 第一帧初始化时序

new Engine(container) 回车按下,到屏幕上第一次出现地球像素,发生了什么?

图 4:第一帧初始化时序(DOM → canvas → 上下文 → 引擎 → 场景 → 行星 → 首帧提交)

关键观察

  • 第 0 帧通常不是”完整地球”——根瓦片(z=0,全球图)下载需要时间,可能先显示一个纯色球体
  • 第 1-N 帧瓦片陆续到达,四叉树根据相机位置递归分裂,更高 LOD 的瓦片替换粗 LOD
  • 稳态通常在第 30-60 帧达到(约 0.5-1 秒),此时四叉树根据相机距离稳定在某个 LOD 深度

5. 多视锥存在的根本原因

WARNING

这是最容易被误解的设计——很多人以为多视锥是”为了看得更远”,其实根本原因是深度缓冲精度

问题:单视锥的深度精度耗尽

Web GIS 场景的相机到目标的距离范围极大——从近处的 1 米(看一栋楼)到远处的 10710^7 米(看整个地球)。如果用单个视锥覆盖这个范围,深度缓冲(Depth Buffer)精度会迅速耗尽,导致 z-fighting(深度冲突,两个面闪闪烁烁分不清谁在前)。

深度缓冲的非线性分布公式(透视投影,深度归一化到 [0,1][0, 1]):

dnormalized=zfar(zznear)z(zfarznear)d_{\text{normalized}} = \frac{z_{\text{far}} \cdot (z - z_{\text{near}})}{z \cdot (z_{\text{far}} - z_{\text{near}})}

其中 zz 是相机空间的视图距离。对 zz 求导得到精度分辨率:

Δzz2(zfarznear)2Dzfarznear\Delta z \approx \frac{z^2 \cdot (z_{\text{far}} - z_{\text{near}})}{2^D \cdot z_{\text{far}} \cdot z_{\text{near}}}

其中 DD 是深度缓冲位数(通常 24)。代入 znear=1z_{\text{near}} = 1 m、zfar=107z_{\text{far}} = 10^7 m、D=24D = 24z=104z = 10^4 m:

Δz(104)21072241071=10151.68×101360 m\Delta z \approx \frac{(10^4)^2 \cdot 10^7}{2^{24} \cdot 10^7 \cdot 1} = \frac{10^{15}}{1.68 \times 10^{13}} \approx 60 \text{ m}

也就是说,在 10 公里外,两个面距离 60 米以内就分不清前后——这就是 z-fighting 的根源。

解决方案:多视锥拆分

把单个 [1,107][1, 10^7] 的视锥拆成 3 个子视锥:

子视锥近裁面 znearz_{\text{near}}远裁面 zfarz_{\text{far}}比例
1 m1 000 m10310^3
1 000 m100 000 m10210^2
100 000 m10710^7 m10210^2

每个子视锥的近远比例都控制在 10210^2 - 10310^3,深度精度提升数千倍。绘制时按”远 → 中 → 近”顺序,每遍绘制前清空深度缓冲,最后合成。

NOTE

多视锥的附带收益才是”看得更远”——因为单视锥为了 z-fighting 不得不缩短远裁面(如 100 km),所以远处看不见。拆成多视锥后远裁面可以拉到 10710^7 m,地平线效果就出来了。但这是副作用,不是设计目的。

6. 通用离屏 Framebuffer 分类

渲染循环里多次提到”Color 缓冲""Depth 缓冲""Picking 缓冲”——它们都是离屏 Framebuffer(FBO)。GPU 渲染不只画到屏幕,还会画到多个离屏缓冲,最后合成。

通用分类(按用途):

类型用途写入时机读取时机是否 GPU-only
Color主场景画面(不透明 + 透明)步骤四、五、六步骤七(后处理输入)
Depth深度测试(每像素存视图距离)步骤四(不透明遍)步骤五、六(透明遍深度测试)
Picking拾取辅助——每对象编码为唯一颜色步骤四(与 Color 同步)步骤八(异步读回 CPU)否(需 CPU 读)
HDR高动态范围场景(亮度 > 1.0)步骤六(含大气散射)步骤七(Tone Mapping 前)
Screen最终屏幕画面(LDR)步骤七(Tone Mapping 后)浏览器合成否(直接显示)

为什么需要这么多缓冲

  • Color + Depth 分离:深度测试需要单独的缓冲记录”每像素最近距离”,不能与颜色混在一起
  • Picking 独立:拾取信息(对象 ID)只在点击时需要,平时不显示,独立缓冲避免污染主画面
  • HDR 独立:大气散射等效果会产生 > 1.0 的亮度值,普通 LDR(0-1)缓冲会截断,必须用浮点 HDR
  • Screen 是最终出口:所有处理完成后,画面写到屏幕缓冲,浏览器接管显示

可视化对比与动手实验

对比一:通用 5-6 步骨架 vs 典型实现 8 步细化

维度通用骨架(5-6 步)典型 Web GIS 实现(8 步)
步骤数5-68
适用范围任何实时渲染Web GIS 特化
瓦片调度隐含在”状态更新”里独立步骤三(LOD 与瓦片调度)
矢量绘制与不透明合并独立步骤五(深度排序要求)
透明绘制与不透明合并独立步骤六(大气、半透明效果)
拾取不在循环里独立步骤八(异步读回)
复杂度简单复杂但可控

对比二:单视锥 vs 多视锥

维度单视锥多视锥(近/中/远 三段)
远裁面受 z-fighting 限制(~100 km)可拉到 10710^7 m(看地平线)
深度精度远处 Δz60\Delta z \approx 60 m远处 Δz0.6\Delta z \approx 0.6 m
Draw call 数1 倍3 倍(每子视锥重画一遍)
渲染开销高 2-3 倍
LOD 配合难(精度差无法远处切换 LOD)易(每子视锥独立选 LOD)
适用场景小场景(一栋楼、一个园区)大场景(城市、省份、地球)

对比三:5 类离屏 Framebuffer

缓冲像素格式典型分辨率显存占用(1080p)
ColorRGBA8(每通道 8 bit)1920×10808 MB
DepthD24S8(24 bit 深度 + 8 bit 模板)1920×10808 MB
PickingRGBA8(编码对象 ID)1920×10808 MB
HDRRGBA16F(半精度浮点)1920×108016 MB
ScreenRGBA8(最终输出)1920×10808 MB

总开销:约 48 MB,仅 Framebuffer 部分。这还不算瓦片纹理、几何缓冲、临时缓冲——Web GIS 是显存大户。

动手实验:在浏览器模拟 60 FPS 循环

打开浏览器 DevTools Console,粘贴下面代码,观察每帧时间戳:

let lastTime = performance.now();
let frameCount = 0;

function frame(now) {
  const delta = now - lastTime;
  lastTime = now;
  frameCount++;

  if (frameCount % 60 === 0) {
    // 每 60 帧(约 1 秒)打印一次统计
    console.log(`第 ${frameCount} 帧,本帧耗时 ${delta.toFixed(2)} ms`);
  }

  requestAnimationFrame(frame);
}

requestAnimationFrame(frame);

观察点

  • 切换到其他标签页再切回来,delta 会变成几百毫秒——浏览器后台会自动降帧
  • 滚动页面或拖拽窗口时,delta 会偶尔超过 16.67 ms——掉帧
  • 把代码改成 while 死循环(不用 requestAnimationFrame),CPU 直接占满——这就是为什么必须用 rAF

TIP

requestAnimationFrame 是浏览器原生的”等待 VSync”机制。它把回调安排在下一次屏幕刷新前执行,既保证不浪费 CPU,又保证不撕裂画面。

常见误区

WARNING

误区 1:以为渲染循环是按需触发(地球静止时就不跑了)。 ✅ 正确:渲染循环固定每秒约 60 次(由显示器刷新率决定),即使无变化也跑。新瓦片下载、动画对象前进、HUD 更新都依赖循环持续运行。

WARNING

误区 2:以为本篇讲的”渲染场景”等价于某个具体渲染库的”场景对象”。 ✅ 正确:这里讲的是概念层角色——所有 Web GIS 引擎都有类似的容器概念,但具体类名、API、组织方式各不相同。本系列按概念性中文叙述,不绑定具体引擎。

WARNING

误区 3:以为多视锥只是为了”看得更远”。 ✅ 正确:根本原因是深度缓冲精度。单视锥的 zfar/znearz_{\text{far}} / z_{\text{near}} 比例太大,远处 z-fighting 严重。“看得更远”只是附带收益。

WARNING

误区 4:以为 Picking(拾取)只有 ray cast(射线检测)一种实现。 ✅ 正确:拾取有多种实现——ray cast(射线与几何求交)、颜色编码(每对象分配唯一 RGB 渲染到 Picking 缓冲)、depth-based(读深度反推世界坐标)。本篇不展开,留到 06 相机与拾取 详细讨论。

WARNING

误区 5:以为所有引擎的渲染循环步骤数都一样。 ✅ 正确:步骤数因引擎而异(3-10 步不等)。本篇的 8 步是一种典型 Web GIS 实现——理解了通用 5-6 步骨架,就能在任何引擎里”按图索骥”找到对应步骤。

延伸阅读与自测

权威参考

自测题

  1. 步骤映射:把通用 6 步骨架的”可见性剔除”映射到典型实现的 8 步细化里,对应哪几步?为什么 GIS 引擎要在这里”再切几刀”?
  2. 第一帧时序:从 new Engine() 回车按下,到第一帧画面出现,至少需要几次 GPU draw call?为什么第 0 帧通常不是完整地球?
  3. 深度精度数学:给定 znear=1z_{\text{near}} = 1 m、zfar=106z_{\text{far}} = 10^6 m、24-bit 深度缓冲、z=1000z = 1000 m,计算该距离的深度分辨率 Δz\Delta z。如果改用多视锥拆成两段(近 [1,1000][1, 1000]、远 [1000,106][1000, 10^6]),同样的 z=1000z = 1000 m 处 Δz\Delta z 变成多少?
  4. Framebuffer 设计:为什么 Picking 缓冲不能和 Color 缓冲合并?至少给出两个理由。
  5. 循环开销:8 步流程中,哪几步可以并行?哪几步必须严格串行?为什么?

下一篇导引02 坐标与投影 将打开 GIS 最大的认知坎——经纬度是怎么变成屏幕像素的?WGS84 椭球的数学定义、ECEF 笛卡尔坐标、Web 墨卡托投影的失真与限制,都会在那篇逐一讲透。