用 Three.js 手搓一个 GIS 引擎(二):栅格瓦片与请求调度
系列第二篇。上一篇讲了坐标地基(
WebMercatorGIS+ 局部 Z-up 原点),本篇往场景里"铺地砖"------栅格瓦片。这一篇是全系列工程密度最高的一篇:双层调度架构 、混合分辨率 LOD 选片、以及三个我们反复踩坑的难点(取消的两难、父子瓦片 z-fighting、缩放白屏)都会拆开讲透。
一、栅格瓦片是什么
GIS 底图几乎都是瓦片金字塔:把地图按层级切成 256×256(或 512)的小方块,层级越高越清晰,zoom 每加 1,瓦片数量翻 4 倍。three-gis 把最常见的四种协议统一封装成了图层类:
| 协议 | 类 | 典型来源 |
|---|---|---|
| XYZ / Google | TileLayer |
Google、天地图、OSM、高德 |
| TMS | TMSLayer |
部分老牌瓦片服务(Y 轴翻转) |
| WMS | WMSLayer |
GeoServer 等动态出图服务 |
| WMTS | WMTSLayer |
标准切片服务(带 TileMatrix) |
它们的差异主要在 URL 模板 和 Y 轴方向 ,three-gis 用 TileUrlTemplate(replaceTileTemplate / getTileRequestGroup / DEFAULT_TILE_SUBDOMAINS)把模板替换和子域轮换统一掉了。
二、最小用法
一个 Google 混合图底图,几行就能挂上:
ts
import { TileLayer } from "three-gis";
const google = new TileLayer(
"https://mt{s}.google.com/vt/lyrs=y&x={x}&y={y}&z={z}",
gis,
{
minZoom: 1,
maxZoom: 19,
maxConcurrent: 50, // 该图层最大活动请求
maxRequestsPerFrame: 6, // 每帧最多发起几个请求
maxCacheSize: 240, // 内存瓦片缓存上限
subdomains: ["0", "1", "2", "3"],
},
);
scene.add(google);
// 每帧按视口驱动选片(createViewer 会自动做;手动用底层 API 时要自己接帧循环)
scene.addFrameCallback("tiles", () => {
google.updateTilesInView(lngBounds, latBounds, zoom, cameraTarget, cameraDistance, camera);
});
{s} 会被自动轮换到 mt0..mt3,分散到不同子域------这是绕过单域名并发限制的标准套路。
三、双层调度:图层队列 + 全局调度器
瓦片请求的特点是"量大、突发、可丢弃"。three-gis 没有让每个图层各发各的 fetch,而是做了两层调度:

为什么要两层? 图层内的事(这块瓦片该不该加载、谁先加载)和全局的事(整个页面一共允许多少并发、每个域名多少)是正交的。影像层 + 注记层 + 地形层同时工作时,如果各层独立限流,实际并发会相乘爆掉浏览器连接池------所以网络并发必须由全局单例统一裁决,而优先级判断留在各层内部。
四、调度要点:逐条拆开
一块瓦片从入队到进缓存要过七道防线,先上一张全景图,再逐条拆:

1. 优先级:不是"先来先服务",而是"离视线近的先走"
每块瓦片的 priority 就是它到相机目标点的距离(取瓦片包围盒最近点,后面 LOD 一节细说)。数值越小越优先。相机移动时,enqueue 对已在队列里的瓦片只更新优先级,不重复入队:
ts
const existing = this.pending.find((r) => r.key === key);
if (existing) {
existing.priority = priority; // 相机移动后距离可能变化
return;
}
调度器侧排序用 priority - priority || id - id 做稳定排序------同优先级按入队顺序,避免饥饿。
2. 并发上限:全局 50,每域名 6
浏览器对单域名 HTTP/1.1 并发有硬限制(Chrome 6 个),超出的请求只能排队。调度器主动限制每 origin 的活动请求数,把排队权收回到自己手里:
ts
private pump(): void {
if (this.activeRequests >= this.maximumRequests) return;
this.pending.sort((a, b) => a.priority - b.priority || a.id - b.id);
let index = 0;
while (index < this.pending.length && this.activeRequests < this.maximumRequests) {
const task = this.pending[index];
const serverCount = task.throttleByServer
? (this.activeByServer.get(task.serverKey) ?? 0)
: 0;
const serverHasCapacity =
!task.throttleByServer || serverCount < task.maximumRequestsPerServer;
if (!serverHasCapacity) { index++; continue; } // 跳过满载域名,不阻塞别人
this.pending.splice(index, 1);
task.run();
}
}
注意 serverHasCapacity 不满足时是 index++ 跳过而不是阻塞------A 域名满了不能挡住 B 域名的瓦片。
还有一个细节:每次 schedule() 都可能触发 pump,一帧入队几十块就会调几十次。用 queueMicrotask 加 pumpScheduled 标志合并到微任务里只泵一次。
3. 去重与队列上限
同一瓦片绝不重复入队(isInFlight 检查 pending + loading 两处)。等待队列上限 256,满了怎么办?踢掉优先级最低的;如果新来的请求优先级更低,直接丢弃------队列永远是"当前视口下最值得加载的前 256 块"。
4. 帧预算:一帧最多发 4 个请求
processQueue 每帧最多启动 maxRequestsPerFrame(默认 4)个新请求。看似保守,实则是保帧率的刚需:每个请求要创建 Image、分配纹理、触发排序,几十个挤在同一帧里主线程必然卡顿。
5. 失败冷却:别让 404 打爆服务器
服务端偶发 404/500 的瓦片,如果没有冷却机制,会每帧重新入队重试------等于每秒对服务器发 60 次同样的坏请求。failureCooldownMs 让失败瓦片在冷却期内不再入队,成功加载会清掉冷却记录。
6. 组限流:一个 token 多层共用
天地图有 8 个子域(t0-t7),影像层和注记层都会请求它们。如果按 origin 限流,8 个子域 × 6 并发 = 48 个并发全打到同一个 token 上,会被服务端限流拒绝。requestGroup + registerGroupLimit 解决这个问题:
ts
// 天地图所有层共用一个总控额度
requestScheduler.registerGroupLimit("tianditu", 8);
// 各层的请求都挂在同一组
requestScheduler.schedule({
url: "https://t0.tianditu.gov.cn/vec_w/wmts?...",
requestGroup: "tianditu", // 限流按组而不是按子域
priority: 0,
load: (signal) => loadTile(url, signal),
});
实际生效值 = min(单请求携带的 maximumRequestsPerServer, 组级上限),取小者,防止单层请求把组额度吃光。
五、难点一:取消的两难
视口一移动,旧视口的瓦片请求就作废了。但"取消"这件事比看上去微妙------pending 和 in-flight 要区别对待:
- pending(还没发出去):直接从数组里删掉,零成本。
- in-flight(网络请求已发出) :理论上有
AbortController,但我们的实现里cancelExcept故意不 abort 在途请求:
ts
cancelExcept(visibleKeys: Set<string>) {
// 清理pending
this.pending = this.pending.filter((r) => visibleKeys.has(r.key));
// Let active image requests finish. Aborting and immediately re-creating
// Image objects while the camera moves can exhaust browser resources;
// completed tiles are cached and stale meshes are removed on the next view update.
}
这是踩过坑换来的经验:相机连续平移时,每帧 abort 几十个 Image 又立刻为同一批瓦片创建新 Image,浏览器图像解码管线会被打爆(内存暴涨、解码排队)。而让在途请求跑完的代价很小------完成的纹理进 LRU 缓存,离开视口的 mesh 下一帧清理,用户再移回来还能免请求复用。
乱序完成的兜底 :请求完成时先检查自己是否还在 loading 表里,不在说明已被取消------纹理直接 dispose(),一个字节都不浪费:
ts
.then((texture) => {
if (!this.loading.has(req.key)) { // 已被取消
texture.dispose(); // 迟到的纹理,立刻释放
return;
}
...
})
没有这个检查,快速拖动地图时会稳定泄漏显存。
六、LOD 策略:混合分辨率四叉树
这是本篇的核心。最常见的偷懒做法是"整个视口用同一个 zoom,加载不出来就降级到父级"------但在低俯仰角(斜着看地面)时,屏幕上同时出现"离相机 500 米的前景"和"离相机 20 公里的地平线",一个 zoom 不可能两头兼顾:前景糊掉。
three-gis 的 collectAdaptiveTileSelection 做混合分辨率选片,思路是逐层四叉树细分,每片叶子独立决定 zoom:

1. 有效 zoom:距离每翻倍,降一级
ts
protected getEffectiveZoom(distance: number, baseZoom: number, nearRadius: number): number {
if (distance <= nearRadius) return baseZoom;
const ratio = distance / nearRadius;
const zoomOffset = Math.floor(Math.log2(ratio)); // 距离每翻倍降一级
const clampedOffset = Math.min(zoomOffset, this.maxLodLevels); // 最多降 4 级
return Math.max(this.minZoom, baseZoom - clampedOffset);
}
这是信息论意义上的正解:zoom 加 1,分辨率翻倍,所以"距离翻倍 → 降一级"能在屏幕像素密度上保持大致恒定。maxLodLevels(默认 4)兜底防止地平线方向的瓦片 zoom 掉到没法看。
2. 距离用"最近点"而不是"中心点"
tileDistanceToBounds 把目标点 clamp 到瓦片包围盒上再算距离。用中心点的话,一块很大的低级瓦片会因为它"中心很远"而被降级------即使你正站在它的边缘上。
3. 两轮选片:先保前景,再花预算
ts
// 第一轮:保证相机目标点有一条连续的高细节路径
while (true) {
const focus = [...leaves.values()]
.filter((tile) => tile.distance <= 1e-6 && tile.zoom < baseZoom)
.sort((a, b) => a.zoom - b.zoom)[0];
if (!focus || !expand(focus)) break;
}
// 第二轮:剩余预算按"detailNeed"从大到小花在地平线上
const detailNeed = b.targetZoom - b.zoom - (a.targetZoom - a.zoom);
第一轮保证镜头正下方永远是全分辨率------这是"跟手感"的来源。第二轮按"还差几级到目标 zoom"排序扩张,把 maxTilesPerView(默认 512)的预算优先花在缺细节最多的地方。每步扩张前都做视锥剔除 + 经纬度包围盒剔除,屏幕外的瓦片永远不进队列。
七、难点二:父子瓦片共存的 z-fighting
混合分辨率必然带来一个渲染问题:父级 fallback 瓦片和它的高清子瓦片会同时显示,两者几何完全共面。深度缓冲在共面三角形上会产生 z-fighting,画面闪烁雪花纹。
我们用了三层手段叠加:

ts
const mat = new THREE.MeshBasicMaterial({
polygonOffset: true,
// 细节瓦片往相机方向推,zoom 越高推得越多
polygonOffsetFactor: -Math.max(zoom, 1) * 2,
polygonOffsetUnits: -2,
});
mesh.renderOrder = zoom; // 粗瓦片先画,细瓦片后画
renderOrder = zoom:先画粗的后画细的,混合不透明。polygonOffset按 zoom 加权:即使共面,深度偏移也保证子瓦片赢。- shader 象限遮挡 (最精妙的一层):父瓦片的材质里注入了一段
onBeforeCompile代码,通过一个vec4 childVisibilityuniform 记录"四个象限哪些已有可渲染的子瓦片",fragment shader 里对被覆盖象限直接discard:
glsl
vec2 childUv = floor(clamp(vRasterUv, vec2(0.0), vec2(0.999999)) * 2.0);
bool childHidden =
(childUv.x < 0.5 && childUv.y < 0.5 && childVisibility.x > 0.5) || ...;
if (childHidden) discard;
关键在于只隐藏被覆盖的象限,而不是整个父瓦片------子瓦片逐块就绪时,父瓦片被逐象限"擦掉",缩放过渡全程无白屏、无闪面。
八、难点三:缩放切换的白屏
快速滚轮缩放时,旧 zoom 的瓦片全部失效、新 zoom 的瓦片还在下载------如果直接把旧的清掉,用户看到的就是一段白屏(或背景色)。computeFallbackKeys 解决这个问题:

ts
// 子瓦片还在 queued / loading / failed 时,保留它的祖先(和已加载的后代)
const unresolved = tilesToLoad.filter((tile) => !this.loadedTiles.has(tile.key));
for (const [key] of this.loadedTiles) {
if (visibleKeys.has(key)) continue;
for (const ft of unresolved) {
if (this.isAncestor(tx, ty, tz, ft.x, ft.y, ft.zoom) ||
this.isDescendant(tx, ty, tz, ft.x, ft.y, ft.zoom)) {
retain.add(key);
break;
}
}
}
祖先判断用 floor(child / 2^diff) === parent 的整数除法,纯位运算级别开销。一个容易忽略的细节:加载失败的子瓦片也必须算 unresolved------注释里专门写了这条:如果失败的子瓦片把 fallback 踢掉了,用户会看到已经显示正常的区域突然变白。
配合 LRU 缓存(maxCacheSize 默认 200 块):离开视口的瓦片不销毁而是隐藏入缓存,回到视口时 restoreFromCache 直接可见,零网络请求 。缓存满时按插入序驱逐最老的,驱逐时 texture.dispose() + geometry.dispose() 完整释放。
九、性能优化清单
把散在各处的优化点汇总一下,都是实际测出来的收益:
| 优化点 | 做法 | 收益 |
|---|---|---|
用 Image 而非 fetch |
与 TextureLoader 同机制,绕开 fetch 对 CORS 的额外限制 |
兼容性 |
| 帧预算 | 每帧最多 4 个新请求 | 主线程不卡顿 |
| 微任务合并 pump | 一帧几十次 schedule 只泵一次 | 减少无效排序 |
| 队列上限 + 劣汰 | 256 上限,踢最低优先级 | 内存有界 |
| 失败冷却 | 冷却期内不再入队 | 不打爆服务器 |
| 迟到纹理 dispose | 取消后返回的纹理立即释放 | 显存不泄漏 |
| 极简几何 | 每瓦片 6 顶点 2 三角 | 512 块也只有 3 千三角面 |
| LRU 缓存 | 200 块复用 | 回看区域零请求 |
anisotropy: 4 + mipmap |
斜视角纹理清晰 | 观感 |
十、勘误与小结
勘误 :本文最早一版把
revealSpreadMs等参数写成了栅格瓦片层的能力------实际上"入场节拍"是地形层CesiumTerrainLayer的实现,下一篇会专门讲它。
- 双层调度:图层内管优先级/去重/帧预算,全局单例管跨层并发与按 origin 限流。
- 六个调度要点:优先级动态更新、跳过式 pump、队列劣汰、帧预算、失败冷却、组限流。
- 取消的两难:pending 秒删,in-flight 让它跑完------abort 风暴比浪费带宽更可怕。
- 混合分辨率 LOD :
log2(距离比)降级公式 + 最近点距离 + 两轮选片(前景路径 → detailNeed 花预算)。 - 三个渲染难点:z-fighting 三层组合拳(renderOrder / polygonOffset / shader 象限遮挡)、白屏防护(fallback 保留)、显存防护(迟到纹理 dispose + LRU 驱逐)。
下一篇预告
底图铺好了,但它是"平的"。下一篇《地形引擎》讲 CesiumTerrainLayer 怎么把 Cesium 的 quantized-mesh 地形接到 Three.js 里------包括 Worker 池解压 、影像贴合 、入场节拍 (就是上面勘误里说的那套)和服务不可用时的 fallback。
下一篇想先看地形里的哪块?Worker 解压的实现细节,还是影像贴合的坐标对齐?留言告诉我,我可以重点展开。