北斗网格性能优化(四):Web Worker 与 Transferable
Web Worker 是浏览器里独立于页面主线程的 JavaScript 执行环境,主线程通过消息派任务、收结果。Worker 不能访问 DOM,也拿不到 Cesium 的 WebGL 上下文 —— 它只能算,不能画。
Transferable 用来在线程之间转移 ArrayBuffer 的所有权:缓冲区不复制,发送方失去访问权,接收方直接拿到这块内存。这也是唯一一个能把大数组跨线程搬过去、又不付出一次完整拷贝的办法。
这套北斗网格的做法是:一个 Worker,主线程前面配一个按距离排序的队列,同一时刻只跑一个任务;Worker 把实例矩阵、颜色、网格索引、占用标记四组类型化数组用 Transferable 交回主线程。
它把生成搬出主线程,不减少计算量。GPU 上传仍然在主线程完成,Transferable 只免掉线程之间的复制,不会把数据直接变成显存。
已加载的网格很流畅,但相机进入未生成区域会卡
前几篇的技术都作用在已经生成的数据上。一旦相机飞进一片没生成过的区域,主线程仍然会顿一下。
卡的不是绘制,是生成:按包围盒枚举编码、解码每个编码拿到经纬高边界、和多边形做相交判断、再把每个格子的变换矩阵填进数组。这是一串纯 CPU 循环,占着主线程期间,相机拖动和下一帧提交都被推迟。
把它塞进 Promise.then 或者 setTimeout 没有用 —— 那还是同一个主线程,只是换了执行时机。要真正让出主线程,只有开一条新的线程。
把生成搬出去:Worker 入口只有 29 行
gridWorker.ts 的全部内容就是接收消息、调一个函数、把结果发回去:
1 | // gridWorker.ts —— 全文 |
没有类、没有状态机、没有任务调度 —— 调度全在主线程那边。Worker 只是一个纯粹的「进来一个请求,出去一批数组」。
真正有内容的是 postMessage 的第二个参数。
Transferable:移交所有权,不是复制
postMessage(message, transfer) 的第二个参数是转移列表。列进去的 ArrayBuffer 不再被结构化克隆复制,而是所有权直接搬过去:发送方的那个 buffer 变成 detached 状态,byteLength 归零,访问会抛错;接收方拿到的就是原来那块内存。
对这批数据来说,省下的不是一星半点。一个满的 L7 切片有 8192 个实例,矩阵是 8192 × 16 × 4 = 524,288 字节,加上颜色和索引接近 0.6 MB;如果按结构化克隆走一遍,主线程要在收到之后再复制一份,恰好是在它最需要响应交互的时候。
转移的时机也讲究。注释里那句「零拷贝」不是修辞 —— 数据在 Worker 的堆上生成,出来的时候没有复制过第二次。
代码里转移的是四个 buffer,对应四组类型化数组:
| 数组 | 类型 | 内容 |
|---|---|---|
matrices |
Float32Array |
每实例 16 个数,列主序变换矩阵 |
colors |
Float32Array |
每实例 4 个数,RGBA |
gridIndices |
Int32Array |
每实例 3 个数,网格的 x/y/z 索引 |
isOccupied |
Uint8Array |
每实例 1 个标记 |
注意 matrices.buffer —— 转移的是底层的 ArrayBuffer,不是 TypedArray 对象本身。TypedArray 只是一层视图,真正占内存的是它指向的 buffer。
有一个不能转移的东西:codes。它是 string[],字符串不在 ArrayBuffer 里,所以它老老实实按结构化克隆复制。这也是为什么每实例的体积主要看那三个数字数组。
Worker 内部:四步流水,贵的那步放最后
generateTileResult 是 Worker 的全部逻辑,处理流程在源码注释里列得很清楚:
1 | // gridWorkerUtils.ts |
这个顺序本身就是优化。
第 2 步先解码,是被 3 和 4 共用的。 「网格中心在不在这个矩形里」和「网格和多边形相不相交」都要先知道每个格子的经纬高边界,如果两步各解一次,解码就白做一遍。
第 3 步排在第 4 步前面,是拿便宜的操作给贵的操作削输入。 clipGridsByOwnership 只是四次数值比较,一边扫一边丢;filterGridsInPolygon 走的是 turf 的多边形相交,贵得多。先做裁剪能显著减少进入 turf 的格子数。源码注释把这句话写在了函数头上:
1 | /** |
这也解释了一个看起来多余的步骤:上一层的 GridTileIndex 明明已经按多边形筛过一遍 Tile 了,Worker 里为什么还要再筛一次网格?因为 Tile 是矩形,区域是多边形,落在 Tile 里但不在多边形里的格子得在这一层剔掉。
每一步都记了时间,随结果一起回传:
1 | return { |
这三段计时在主线程就能拿到。哪一段变慢了一眼可见 —— 如果 generateTime 涨了,是候选枚举出了问题;filterTime 涨了,是裁剪没有把输入削下来;instanceTime 涨了,才是矩阵打包本身。这种把耗时拆开回传的做法,比事后加采样器省事得多。
同一根竖直柱子,只测一次相交
第 4 步里藏着一个和三维数据特点高度相关的优化:
1 | // gridWorkerUtils.ts —— filterGridsInPolygon |
关键在于:多边形是二维的。 一根竖直柱子上叠着的所有格子,水平位置完全相同,它们和多边形相不相交的答案必然一致 —— 只要求一次就够了。
这个优化在北斗网格上收益特别大。第一篇那张表里,天津-禁飞区01 平面只有 36×60 格,高度却要切 390 层;南开大学左侧禁飞区是 793×263 平面、260 层。高度层数和水平格子数是同一量级的,所以按 (x, y) 去重大致能把 turf 调用次数压到原来的三分之一到几百分之一 —— 层数越多,省得越多。
被缓存的是「相交」这个布尔结论。不相交的格子不进 grid2DSet,会重复判断,这是有意的:不相交的格子本来就少(它们大多已经被上一步的矩形裁剪干掉了),为它们再维护一个负缓存,收益不抵一次 Set 查找。
单 Worker,加一个按距离排序的队列
示例配的是 workerCount: 1,实现也是固定的单 Worker。为什么不上多线程池?两个副作用:
一是线程启动开销。每个 Tile 建一个 Worker 或者搞一个池,线程创建和消息通道的建立本身就比很多小任务还贵。二是结果洪峰。多个任务同时完成,主线程会在同一时刻收到一大批数组,集中做 GPU 上传 —— 这一段仍然在主线程,反而制造了一次更大的卡顿。
单 Worker 意味着任务天然串行。队列的入队逻辑一共十几行:
1 | // ViewportGridManager.ts —— _enqueueTask |
入队时排一次序,出队就是从头取。priority 是相机到这块的距离,所以近处优先:用户视线中心的东西先出来。
超过上限时从队尾丢。这一个字母的选择值得说一句:pop() 丢的是最远的,shift() 丢的是最近的。相机拖过一片新区域时,队列里往往堆着一串「刚掠过但已经不在视野中心」的块,丢最远的正好把这批清掉,保留住用户真正停下来的地方。
队列的三道清理
除了排序和丢尾,队列还有三处处理。
去重。同一个块可能在多次调度中被反复选中,靠两个地方挡住:
1 | private _isTileLoadPending(cacheKey: string): boolean { |
已经在跑的、已经在排的,都不会被重复入队。少了这一道,队列会被同一个块灌满,而丢尾逻辑会把远处真正需要的块挤出去。
剪枝。每轮调度都会先算一遍当前视口想要的 cacheKey 集合,然后拿它去过滤队列:
1 | // ViewportGridManager.ts —— _pruneQueuedTasks |
这是「移动中先粗、停下来补细」真正的落点。相机快速拖动时,上一轮排进去的精细块在下一轮就已经不在 desired 集合里了,直接出队 —— 它们连生成的机会都没有。
按区域取消。区域被移除时,_cancelAreaTasks 按 areaId 一次性清掉该区域所有排队任务,不用等它们跑完再发现没人要。
这三道清理加上排序,让一个单线程队列在相机的连续拖动下仍然只做有用的事。
一个工程问题:Worker 的路径怎么打进去
写 Worker 有一个和算法无关、但一定会撞上的问题:new Worker(path) 需要一个能被浏览器解析的 URL,而在打包工具里,Worker 文件可能被拆成独立 chunk、可能被内联、也可能因为 base URL 不对而解析失败。项目踩到的是最后一种:
1 | // Vite ?worker&inline:打包时将 gridWorker + 全部依赖内联为 Blob URL, |
?worker&inline 让打包器把 Worker 和它引用的全部代码(gridWorkerUtils、turf、网格工具)打成一个 Blob URL。这样这个库被别人用 Webpack 消费时也不出错 —— 否则 Worker 的相对路径会在宿主项目的 base 上解析,直接报 Invalid base URL。
这个问题和网格算法一点关系都没有,但它是「把 Worker 写进一个要被别人引用的库」时必须解决的一件事。@ts-ignore 也说明类型系统在这条路径上帮不上忙。
降级的兜底
Worker 不一定可用 —— 严格的内容安全策略、某些打包环境的异常都可能让构造失败。所以构造被包在 try 里:
1 | } catch (error) { |
这里能顺手降级,是因为 generateTileResult 是一个不依赖 Cesium、不依赖 DOM 的纯函数 —— 它要的东西全在 GridGenRequest 里:bbox、clipBbox、层级、高度范围、多边形坐标、颜色。Worker 只是为了把它挪到另一条线程,不是它能工作的前提。
这也是拆 gridWorkerUtils.ts 的实际好处:它自己实现了 geodeticToEcef 和 buildEnuBasis,用 WGS84 椭球参数直接算,没有 import Cesium。Worker 里本来也拿不到 Cesium 的渲染上下文,逼出来的这个约束反而让代码可以两边跑。
Worker 不减少计算量
最后把账算清。Worker 改变的只有「在哪儿算」,没有改变「要算多少」。
生成的总量一模一样。 原来占主线程 20 毫秒的循环,现在占 Worker 线程 20 毫秒。收益是主线程在这 20 毫秒里可以去处理相机拖动和提交下一帧 —— 交互不再被卡住,但细节出现的总时长没有变短。
GPU 上传仍然在主线程。 数据回到主线程后,Cesium.Buffer.createVertexBuffer 要把它们喂给 WebGL,这一步没有 Worker 可用 —— WebGL 上下文只属于主线程。Transferable 免掉的是线程之间的复制,不是上传。
它也不解决队列淤积。 相机持续快速移动时,生成速度跟不上新候选出现速度,队列会一直有东西。这一条要靠上一节那三道清理来缓解:与其想办法算得更快,不如让不该算的别算。
所以 Worker 的位置很清楚:它是响应性的优化,不是吞吐量的优化。要减少总工作量,还得回到前面那几篇 —— 视锥剔除砍掉视野外的,LOD 砍掉分不清的,预算砍掉设备承担不起的。
北斗网格性能优化(四):Web Worker 与 Transferable
https://wywzixin.github.io/beidou-grid-performance/04-worker-transferable/