用 Three.js 手搓一个 GIS 引擎(二):栅格瓦片与请求调度

用 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;  // 粗瓦片先画,细瓦片后画
  1. renderOrder = zoom:先画粗的后画细的,混合不透明。
  2. polygonOffset 按 zoom 加权:即使共面,深度偏移也保证子瓦片赢。
  3. shader 象限遮挡 (最精妙的一层):父瓦片的材质里注入了一段 onBeforeCompile 代码,通过一个 vec4 childVisibility uniform 记录"四个象限哪些已有可渲染的子瓦片",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 解压的实现细节,还是影像贴合的坐标对齐?留言告诉我,我可以重点展开。

相关推荐
Chen7321 天前
用 Three.js 手搓一个 GIS 引擎(一):总览、架构与坐标系统
three.js
Chen7323 天前
一套飞控内核,两个渲染后端:three.js 与 CesiumJS 同构实践
three.js
夏天要喝冰可乐4 天前
用 Codex 连接 Blender,我做了一块可以走进楼宇的大屏
前端·three.js
ab1237686 天前
three.js 完整笔记
笔记·three.js
答案answer9 天前
不用写一行Three.js代码,也能搭出复杂数字孪生场景
github·three.js
纸片人10 天前
甲方只给了一张卫星截图,我用 Three.js + Blender 建了一座 3D 能源站
three.js·数据可视化
纸片人10 天前
从一座城市钻进一个房间:Three.js + Blender 做一个五级钻取的数字楼宇大屏
three.js·数据可视化
妖精的羽翼10 天前
Web3D(Three.js)应用分级 + 学习路线规划(由浅入深,贴合接单 + 求职)
学习·three.js
踩着两条虫11 天前
我在浏览器里“盖”了一个村:Vue + Three.js 大屏开发踩坑记
前端·vue.js·three.js