GIS
调优手册
前情回顾:04 影像图层 讲清了多层影像混合与纹理管理,05 地形与 Worker 讲清了 RGB/BIL 高程编码与异步管线加载,08 渲染管线 深入 GPU 五阶段、Tone Mapping 与大气散射。当引擎遇到卡顿、内存涨、瓦片不加载时,怎么定位问题?资源什么时候释放?缓存满了怎么办?本篇回答:性能瓶颈排查与资源调优。
直觉问题
打开一个运行了一段时间的 3D 地图应用,三个最常见的困扰:
- 引擎跑了一段时间后 FPS 从 60 掉到 20——不是突然变慢的,是 gradually degrading。哪里出问题了?CPU 算不过来了?GPU draw call 太多了?内存泄漏了?
- 飞过太平洋又飞回来,瓦片会重新加载吗? ——之前的瓦片纹理会一直占着内存不释放吗?如果用户一直漫游,内存会不会无限制增长?
- 地图加载到一半,某个区域的瓦片永远空白——是网络超时没重试?是剔除算法把偏远瓦片误判为不可见?还是瓦片解码崩溃了?
读完本篇,你能回答:“如何区分 CPU-bound 和 GPU-bound 的性能瓶颈?""性能优化的第一原则是什么?""LRU 缓存的淘汰逻辑是什么?“
核心概念白话讲
用”水管阻塞”理解 GPU 性能瓶颈
- CPU-bound:CPU 算得太慢,GPU 空闲 → 优化:减少 JS 计算、优化数据结构
- GPU-bound:GPU 太忙,CPU 等 GPU → 优化:减少 draw call、简化 shader
- Memory-bound:内存带宽成为瓶颈 → 优化:减少纹理采样、压缩纹理
- Network-bound:网络下载太慢 → 优化:减少并发、启用缓存
用”酒店房间”理解 LRU 缓存
- 每个瓦片纹理 = 一个房间
- 缓存大小 = 酒店总房间数
- LRU (Least Recently Used):满房时,清退最久未访问的房客
- 核心假设:时空局部性——最近访问的更可能再次访问
用”航班时刻表”理解帧预算管理
60 FPS = 每帧 16.67ms:
| 阶段 | 预算 | 超出时 | 优化 |
|---|---|---|---|
| JS 逻辑 | ~5ms | 主线程卡死 | 拆长任务、放 Worker |
| 渲染提交 | ~3ms | draw call 瓶颈 | 实例化、批处理 |
| GPU 渲染 | ~6ms | 画面无法刷新 | 简化 shader |
| 浏览器合成 | ~2ms | 浏览器响应慢 | 减少 DOM 操作 |
| 缓冲 | ~0.67ms | 应对突发 | 留足 headroom |
NOTE
关键:60 FPS 的目标是所有阶段总和不超过 16.67ms。JS 逻辑时间经常被忽略,但它经常超过 GPU 渲染时间。
原理与数学/机制
1. 帧预算分配与瓶颈定位
60 FPS 等价于每帧 16.67ms。这 16.67ms 不是”GPU 专用时间”,而是 CPU 调度、JS 执行、渲染命令提交、GPU 绘制、浏览器合成五阶段的总和。我们把它们放进一个”航班时刻表”:
当总耗时超过 16.67ms 时,浏览器只能以 30 FPS(33.33ms/帧)呈现,用户会明显感知卡顿。性能优化的第一原则不是”把每个阶段都优化到极致”,而是”找到最窄的瓶颈并优先扩展它”——就像疏通水管的堵塞点,而不是把所有水管都换成钻石材质。
帧预算分配不是固定的。在简单场景(仅底图、无地形、无 3D 建筑)中,JS 逻辑可能只消耗 1ms,GPU 渲染也只需 2ms,此时你的性能余量高达 13ms。但在复杂场景(地形 + 倾斜摄影 + 实时大气 + 大规模矢量标绘)中,任何一个阶段都可能突破预算。
IMPORTANT
性能分析不是先优化,而是先测量。没有 DevTools 数据支撑的优化,就像在黑暗房间里找黑猫。
2. 性能指标阈值速查
| 指标 | 健康阈值 | 告警阈值 | 危险阈值 |
|---|---|---|---|
| FPS | ≥ 55 | 30–55 | < 30 |
| JS 耗时/帧 | < 5ms | 5–8ms | > 10ms |
| Draw Calls/帧 | < 200 | 200–500 | > 800 |
| GPU 顶点数/帧 | < 1M | 1M–3M | > 5M |
| 纹理上传/帧 | < 2MB | 2–5MB | > 8MB |
| 并发网络请求 | < 6 | 6–10 | > 15 |
这张表的价值在于”先看数据再动手”。当用户报告”卡”时,先用 DevTools 确认是哪一列超标,再针对性优化。
3. LRU 缓存数学与时空局部性
LRU(Least Recently Used)是 WebGIS 瓦片缓存中最经典的淘汰策略。它的数学基础是时空局部性原理:最近被访问的数据,在未来也极有可能被再次访问。
假设缓存容量,LOCATION,PLACE,CONCEPT,ENGINEERING capacity 为 ,访问序列为 。命中率定义为:
对于瓦片缓存而言,一次”Miss”意味着需要从网络下载一张 256×256 像素的 PNG/JPEG(通常 20KB–100KB),或者从磁盘/内存中读取解码后的 RGBA 纹理数据。在网络 I/O 成为瓶颈的场景下,缓存命中率每提升 10%,用户感知的加载延迟就能降低一半以上。
LRU 的平均时间复杂度:
- 读取/写入缓存:(使用哈希表 + 双向链表实现)
- 淘汰最久未访问项:
NOTE
为什么不用 LFU? LFU(Least Frequently Used)淘汰访问频率最低的项。在地图漫游场景中,用户可能突然放大到某个新区域,此时 LFU 会错误地保留旧区域的高频瓦片,反而降低命中率。LRU 对”突发访问模式”更鲁棒。
4. 两阶段清理与 GPU 内存管理
WebGL 资源(纹理、Buffer、FBO、Shader Program)的生命周期由 JavaScript 对象引用和 GPU 驱动两层共同管理。JS 层通过 gl.deleteTexture() 向 GPU 驱动发信号”可以释放了”,但真正的显存回收由 GPU 驱动的垃圾回收器在帧间隙异步执行。
如果材质和纹理之间互相引用,一次性 dispose 会导致:
- 纹理被释放,但材质仍持有野指针
- 下一帧渲染时,材质尝试绑定一个已被删除的纹理 → WebGL CONTEXT_LOST
因此必须采用两阶段清理:
IMPORTANT
为什么必须等一帧? 因为材质和纹理之间、纹理和 FBO 之间、FBO 和渲染目标之间可能存在多层引用关系。延迟一帧确保 WebGL 的 delete 命令已发送到 GPU 驱动,且上一帧的渲染命令已执行完毕,避免”使用中释放”的竞态条件。
5. GPU 显存占用估算
一张瓦片纹理的 GPU 内存占用取决于其尺寸、格式和 Mipmapping:
其中 MipmapOverhead ≈ 1.33(因为 Mipmap 链总和是原图的 1 + 1/4 + 1/16 + … ≈ 1.33 倍)。
以一张 256×256 的 RGBA 瓦片为例:
| 格式 | Bytes/Pixel | 单张内存 | 1000 张缓存 |
|---|---|---|---|
| RGBA8 (未压缩) | 4 | 256 KB | ~250 MB |
| DXT1 / BC1 | 0.5 | 32 KB | ~32 MB |
| ETC2 | 0.5 | 32 KB | ~32 MB |
| ASTC 4×4 | 1 | 64 KB | ~64 MB |
TIP
经验法则:移动设备 GPU 显存通常在 1–4GB 之间,其中浏览器可能被限额到 512MB–1GB。如果你的缓存策略允许 2000 张未压缩的 256×256 RGBA 瓦片,就已经突破了上限。启用纹理压缩(如 KTX2 / ETC2)可以将内存占用降低 4–8 倍。
6. Draw Call 优化与渲染状态排序
| 方案 | Draw Calls | 总 CPU 耗时 | 适用场景 |
|---|---|---|---|
| 传统(逐个渲染) | N | N × ~50μs | 小规模场景 |
| 实例化 (Instancing) | 1 | ~50μs + 极小额外 | 大量同构对象(树木、建筑) |
| 批处理 (Batching) | 1 | ~50μs + 合并开销 | 静态几何合并 |
| 状态排序 (Sorting) | 不变 | 不变 | 减少管线状态切换 |
实例化(Instancing)是 WebGIS 中最常用的 Draw Call 优化手段:将 N 棵相同的树放入一个 draw call,通过 gl_InstanceID 区分每棵树的变换矩阵。
状态排序(State Sorting) 则是另一个容易被忽视的优化点:GPU 管线状态切换(绑定纹理 → 设置 blend mode → 切换 shader)每次都有固定开销。如果渲染顺序是”树 → 石头 → 树 → 石头”,每帧都要切换 4 次状态;如果排序为”树 → 树 → 石头 → 石头”,只需切换 2 次。在 GIS 场景下,合理的渲染顺序通常是:背景瓦片 → 地形瓦片 → 3D 建筑 → 矢量线 → 矢量面 → 标注 → UI。
7. Overdraw 与 Fill Rate
Overdraw(过度绘制)指同一个像素被多次写入。在 3D 地图中,大量半透明图层叠加(大气层 + 云层 + 矢量标注 + UI 面板)会导致同一个像素被着色 5–10 次。GPU 的 Fill Rate(像素填充率)是固定的,Overdraw 越高,有效 Fill Rate 越低。
减少 Overdraw 的方法:
- 不透明物体从前向后渲染(Early-Z 剔除)
- 半透明物体从后向前渲染(正确混合),但尽量减少半透明对象数量
- 使用遮挡查询(Occlusion Query)跳过被遮挡的 3D 建筑
可视化对比与动手实验
实验 1:性能排查表(12 项)
| # | 症状 | 可能根因 | 排查工具 | 修复方案 |
|---|---|---|---|---|
| 1 | 瓦片加载卡住 | 网络限流 / CORS | DevTools Network | 调整并发数 |
| 2 | FPS 突然下降 | Draw call 暴涨 | Spector.js | 实例化 / 批处理 |
| 3 | 内存持续增长 | 资源未释放 | Memory Snapshot | dispose / 自动清理 |
| 4 | 相机位置抖动 | 高精度坐标计算精度丢失 | 检查模型矩阵拆分 | 启用 RTE / 拆分高低精度坐标 |
| 5 | 大气层颜色异常 | 光照参数 upload 错误 | 着色器输出颜色 | 检查太阳方向 / 时间参数 |
| 6 | Picking 结果错位 | 拾取纹理与实际帧不同步 | Picking FBO 截图对比 | 同步渲染时序 |
| 7 | 地形坑洞 | NoData 高程值未处理 | 高程原始数据检查 | 插值 / 父级兜底 |
| 8 | 文字模糊 | 字体图集未加载 | atlas 纹理检查 | 确认字体名 / 重新生成 |
| 9 | 3D Tiles 位置偏 | 地理锚定矩阵错 | bounding box 可视化 | 检查变换矩阵乘法顺序 |
| 10 | 浏览器崩溃 | GPU 内存溢出 | chrome://gpu | 限制缓存 / 复用纹理 |
| 11 | 滚动 / 缩放卡顿 | 主线程长任务阻塞 | Performance Profile | 拆分长任务 / 防抖 |
| 12 | 触摸不灵敏 | 事件冒泡 / 高频触发 | Event Listener 面板 | 阻止冒泡 / 节流 |
实验 2:调试工具对比
| 工具 | 核心功能 | 适用场景 | 易用性 |
|---|---|---|---|
| Chrome DevTools | CPU/内存/网络/GPU 全量分析 | 日常性能监控 | ★★★☆☆ |
| Spector.js | WebGL API 抓帧与回放 | 渲染管线调试 | ★★★☆☆ |
| Stats.js | 实时 FPS/MS/MB 面板 | 快速性能概览 | ★★★★★ |
| WebGL Inspector | 纹理/Buffer/Shader 检查 | 资源泄漏排查 | ★★☆☆☆ |
实验 3:缓存策略对比
| 策略 | 核心思想 | 命中率 | 复杂度 | 适用场景 |
|---|---|---|---|---|
| LRU | 淘汰最近最少使用 | 高 | 低 | 地图漫游、瓦片缓存 |
| LFU | 淘汰访问频率最低 | 中 | 中 | 热点数据集 |
| FIFO | 先进先出 | 低 | 极低 | 流式数据、简单队列 |
| ARC | LRU + LFU 自适应 | 极高 | 高 | 混合访问模式 |
常见误区
WARNING
误区 1:关闭图层 = 释放资源 关闭图层只是移出场景,材质和纹理不会自动释放。必须显式 dispose。
WARNING
误区 2:Worker 越多越快 实际上 2-4 个最优。太多了线程切换成本超过并行收益。
WARNING
误区 3:浏览器会自动 GC GPU 资源 JS GC 只管理堆内存,不管理 GPU 资源(纹理、Buffer、FBO)。必须显式释放。
WARNING
误区 4:LOD 越细越好 远距离用低 LOD、近距离用高 LOD 才最优。全部最高 LOD 会 GPU 过载。
WARNING
误区 5:把帧时间全部归到 GPU JS 计算时间经常超过 GPU 渲染时间,特别是在复杂地理坐标转换时。
延伸阅读与自测
延伸阅读
- WebGL Best Practices - MDN:https://developer.mozilla.org/en-US/docs/Web/API/WebGL_API/WebGL_best_practices
- Spector.js:https://github.com/BabylonJS/Spector.js
- Chrome DevTools Performance:https://developer.chrome.com/docs/devtools/performance/
- High Performance Browser Networking:https://hpbn.co/
- Stats.js:https://github.com/mrdoob/stats.js
自测题
- 计算题:60 FPS 场景,JS 8ms + 渲染提交 4ms + GPU 7ms = 19ms。是否掉帧?提出 2 个优化方向。
- 概念辨析:为什么说”两阶段清理”不能简化为”一次性释放所有资源”?
- 方案对比:缓存容量 200,用户持续单向漫游。LRU 和 FIFO 哪个更适合?
- 排错实战:地图加载时”瓦片闪烁 + 内存持续增长 + FPS 下降”同时出现,分析根因并列出排查步骤。
- 设计题:设计一个自动化性能监控系统,实时采集 FPS、JS/GPU 耗时、内存,FPS < 30 持续 3 秒时告警。写出核心数据结构和伪代码。
下一篇预告:11 综合练习 将前面 10 篇的所有知识点串联起来——从底图、地形、矢量标绘到相机飞行、拾取交互,完成一个真实可用的 GIS 应用 Demo,并给出 5+ 扩展练习方向。