GIS
第一帧
前情回顾: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 步骨架。下面是最通用的版本:
每一步的角色:
| 步骤 | 名称 | 主要工作 | 时间预算(60 FPS) |
|---|---|---|---|
| ① 输入采集 | Input | 收集用户事件、传感器数据 | < 1 ms |
| ② 状态更新 | Update | 相机移动、对象动画、物理积分 | 1-3 ms |
| ③ 可见性剔除 | Culling | 视锥剔除、遮挡剔除、LOD 选择 | 1-3 ms |
| ④ 绘制 | Render | GPU draw call、shader 执行 | 8-12 ms |
| ⑤ 后处理 | Post-process | Tone Mapping、抗锯齿、合成 | 1-3 ms |
| ⑥ 帧提交 | Present | 交换缓冲、等待 VSync | < 1 ms |
TIP
60 FPS 意味着每帧总时间预算 ≤ 16.67 ms()。其中绘制(步骤 ④)通常占大头,是优化的首要目标。
2. 一种典型实现如何细化为 8 步
Web GIS 引擎在通用 6 步骨架基础上,因瓦片调度、矢量实体、大气散射、拾取等 GIS 特有需求,会把循环进一步细化。下面是一种典型实现的 8 步拆分:
| 步骤 | 名称 | 输入 | 输出 |
|---|---|---|---|
| 一 | 输入与状态预处理 | 用户事件、时间增量 | 更新后的相机参数、待处理事件队列 |
| 二 | 视锥与可见性更新 | 相机参数 | 当前帧视锥体、粗筛后的瓦片集合 |
| 三 | 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. 渲染场景内的概念对象关系
把”渲染场景 → 渲染器 → 渲染节点 → 行星节点 → 四叉树 → 瓦片段 → 图层”这条链画成图:
关键关系:
- 渲染场景是顶层容器,里面挂着所有渲染节点 + 渲染器
- 渲染器不持有数据,只负责”每帧按 8 步流程跑一遍”
- 渲染节点是一个抽象——灯光、行星、矢量实体都是它的子类
- 行星节点是 Web GIS 特有的核心节点,挂载四叉树
- 四叉树递归地管理瓦片段的加载/卸载/可见性
- 瓦片段是渲染的最小单元——一个瓦片对应一组几何 + 多层影像纹理
- 图层是数据源抽象——同一瓦片段可以叠加多个图层(底图 + 道路 + 标注)
4. 第一帧初始化时序
从 new Engine(container) 回车按下,到屏幕上第一次出现地球像素,发生了什么?
关键观察:
- 第 0 帧通常不是”完整地球”——根瓦片(z=0,全球图)下载需要时间,可能先显示一个纯色球体
- 第 1-N 帧瓦片陆续到达,四叉树根据相机位置递归分裂,更高 LOD 的瓦片替换粗 LOD
- 稳态通常在第 30-60 帧达到(约 0.5-1 秒),此时四叉树根据相机距离稳定在某个 LOD 深度
5. 多视锥存在的根本原因
WARNING
这是最容易被误解的设计——很多人以为多视锥是”为了看得更远”,其实根本原因是深度缓冲精度。
问题:单视锥的深度精度耗尽
Web GIS 场景的相机到目标的距离范围极大——从近处的 1 米(看一栋楼)到远处的 米(看整个地球)。如果用单个视锥覆盖这个范围,深度缓冲(Depth Buffer)精度会迅速耗尽,导致 z-fighting(深度冲突,两个面闪闪烁烁分不清谁在前)。
深度缓冲的非线性分布公式(透视投影,深度归一化到 ):
其中 是相机空间的视图距离。对 求导得到精度分辨率:
其中 是深度缓冲位数(通常 24)。代入 m、 m、、 m:
也就是说,在 10 公里外,两个面距离 60 米以内就分不清前后——这就是 z-fighting 的根源。
解决方案:多视锥拆分
把单个 的视锥拆成 3 个子视锥:
| 子视锥 | 近裁面 | 远裁面 | 比例 |
|---|---|---|---|
| 近 | 1 m | 1 000 m | |
| 中 | 1 000 m | 100 000 m | |
| 远 | 100 000 m | m |
每个子视锥的近远比例都控制在 - ,深度精度提升数千倍。绘制时按”远 → 中 → 近”顺序,每遍绘制前清空深度缓冲,最后合成。
NOTE
多视锥的附带收益才是”看得更远”——因为单视锥为了 z-fighting 不得不缩短远裁面(如 100 km),所以远处看不见。拆成多视锥后远裁面可以拉到 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-6 | 8 |
| 适用范围 | 任何实时渲染 | Web GIS 特化 |
| 瓦片调度 | 隐含在”状态更新”里 | 独立步骤三(LOD 与瓦片调度) |
| 矢量绘制 | 与不透明合并 | 独立步骤五(深度排序要求) |
| 透明绘制 | 与不透明合并 | 独立步骤六(大气、半透明效果) |
| 拾取 | 不在循环里 | 独立步骤八(异步读回) |
| 复杂度 | 简单 | 复杂但可控 |
对比二:单视锥 vs 多视锥
| 维度 | 单视锥 | 多视锥(近/中/远 三段) |
|---|---|---|
| 远裁面 | 受 z-fighting 限制(~100 km) | 可拉到 m(看地平线) |
| 深度精度 | 远处 m | 远处 m |
| Draw call 数 | 1 倍 | 3 倍(每子视锥重画一遍) |
| 渲染开销 | 低 | 高 2-3 倍 |
| LOD 配合 | 难(精度差无法远处切换 LOD) | 易(每子视锥独立选 LOD) |
| 适用场景 | 小场景(一栋楼、一个园区) | 大场景(城市、省份、地球) |
对比三:5 类离屏 Framebuffer
| 缓冲 | 像素格式 | 典型分辨率 | 显存占用(1080p) |
|---|---|---|---|
| Color | RGBA8(每通道 8 bit) | 1920×1080 | 8 MB |
| Depth | D24S8(24 bit 深度 + 8 bit 模板) | 1920×1080 | 8 MB |
| Picking | RGBA8(编码对象 ID) | 1920×1080 | 8 MB |
| HDR | RGBA16F(半精度浮点) | 1920×1080 | 16 MB |
| Screen | RGBA8(最终输出) | 1920×1080 | 8 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:以为多视锥只是为了”看得更远”。 ✅ 正确:根本原因是深度缓冲精度。单视锥的 比例太大,远处 z-fighting 严重。“看得更远”只是附带收益。
WARNING
误区 4:以为 Picking(拾取)只有 ray cast(射线检测)一种实现。 ✅ 正确:拾取有多种实现——ray cast(射线与几何求交)、颜色编码(每对象分配唯一 RGB 渲染到 Picking 缓冲)、depth-based(读深度反推世界坐标)。本篇不展开,留到 06 相机与拾取 详细讨论。
WARNING
误区 5:以为所有引擎的渲染循环步骤数都一样。 ✅ 正确:步骤数因引擎而异(3-10 步不等)。本篇的 8 步是一种典型 Web GIS 实现——理解了通用 5-6 步骨架,就能在任何引擎里”按图索骥”找到对应步骤。
延伸阅读与自测
权威参考
- WebGL Fundamentals — Rendering:WebGL 渲染管线最友好的入门材料
- Real-Time Rendering, 3rd Edition 第 2 章:图形渲染管线的工业级教材
- Babylon.js Documentation — Render Pipeline:一种典型 Web 引擎的渲染循环实现
- Three.js Documentation — Animation Loop:另一种典型实现的循环骨架
- Khronos Wiki — Framebuffer Object:FBO 通用规范
- Wikipedia — Rendering (computer graphics):渲染基础概念
自测题
- 步骤映射:把通用 6 步骨架的”可见性剔除”映射到典型实现的 8 步细化里,对应哪几步?为什么 GIS 引擎要在这里”再切几刀”?
- 第一帧时序:从
new Engine()回车按下,到第一帧画面出现,至少需要几次 GPU draw call?为什么第 0 帧通常不是完整地球? - 深度精度数学:给定 m、 m、24-bit 深度缓冲、 m,计算该距离的深度分辨率 。如果改用多视锥拆成两段(近 、远 ),同样的 m 处 变成多少?
- Framebuffer 设计:为什么 Picking 缓冲不能和 Color 缓冲合并?至少给出两个理由。
- 循环开销:8 步流程中,哪几步可以并行?哪几步必须严格串行?为什么?
下一篇导引:02 坐标与投影 将打开 GIS 最大的认知坎——经纬度是怎么变成屏幕像素的?WGS84 椭球的数学定义、ECEF 笛卡尔坐标、Web 墨卡托投影的失真与限制,都会在那篇逐一讲透。