三维地球上的 500 个标注广告牌:DOM 与 Canvas 贴图的两条优化路线

三维地球上的 500 个标注:DOM 脏检查与 Canvas 贴图的两条优化路线

0. 先看问题

我的项目是一个三维地球(WebGIS)监测平台:在三维场景上叠加几百个标注——监测点信息牌、设备状态图标,跟随地理坐标移动,支持 hover、点击弹窗、动画。

功能上线后用户反馈:拖动地球时卡成 PPT。更奇怪的是——静止不动时,风扇也在转

这篇文章记录定位和解决的全过程。最终方案不是单一技巧,而是两条路线的并行:

交互重的信息牌:DOM 标签 + 脏检查,做到相机静止时零开销;
量大的设备图标:Canvas 离屏绘制 + toDataURL 贴成 billboard,彻底离开 DOM 渲染管线。

1. 选型:WebGL 场景里标注的两条路

三维引擎里叠加 HTML 标注,天然有两条路线:

DOM 标签 Canvas 贴图 billboard
开发效率 HTML + CSS,圆角渐变动画随便写 Canvas 2D API 手绘一切
交互 原生 hover/click 引擎拾取(pick),自己接线
文字排版 浏览器白送 手动量宽
性能 受浏览器渲染管线拖累 交给 WebGL 管线,天然快

我们的信息牌有复杂层级(图标 + 名称 + 预警色边框 + 呼吸动画),交互要求高,老艺术家喜欢操作DOM,且 DOM 功底远强于 Canvas——先选了 DOM,代价是性能,于是有了下面三节的故事。而设备图标数量大、样式简单,后来走了 Canvas 路线——第 6 节展开。

2. 定位:卡顿到底卡在哪

打开 Chrome Performance 面板录一段拖动操作,主线程被 Recalculate StyleLayout 塞满,调用栈全部指向每帧执行的 postRender 回调。示例代码:

// 每帧、每个标签各执行一次
viewer.scene.postRender.addEventListener(() => {
  const pos = projectToScreen(this.position);   // 经纬度 → 屏幕坐标
  this.div.style.left = pos.x + 'px';           // 写 style
  this.div.style.bottom = pos.y + 'px';         // 写 style
  const w = this.div.offsetWidth;               // 读几何属性 ⚠️
  this.div.style.transform = `scale(${s})`;     // 写 style
});

关键知识点:style 是廉价的(浏览器只标记脏,攒到下一帧统一重算),昂贵的是"写后读"——刚写完 style 立刻读 offsetWidth,浏览器被迫立刻执行同步布局(forced reflow)。原代码的序列是写→写→读→写,每个标签每帧强制一次回流,几百个标签 60fps 下每秒上万次布局计算。凶手锁定。

还有一层当时没意识到的问题:定位用的是 left/bottom,这类属性一变就触发 layout。就算没有"写后读",拖动时几百个标签每帧写一遍,也会攒出一次全量布局。更优的姿势是 transform: translate3d(x, y, 0)——它走合成器,不触发 layout。这个改进我们最终没做(脏检查上线后拖动时的布局成本已可接受),但值得记录。

"静止时风扇还在转"也解释了:postRender 每帧都触发,相机不动也照常执行——全是无效功,CPU 照单全收。

顺带一提,引擎层面还有个更彻底的开关:viewer.scene.requestRenderMode = true,让 Cesium 只在场景变化时渲染,静止时连渲染循环都停。我们最终靠脏检查解决了问题,但如果场景里没有持续动画,这个开关值得优先尝试——它省的是整个渲染循环,而不只是标签更新。

3. 路线一(上):从 N 个每帧回调到 1 个

第一刀砍结构:原实现每个标签各自 addEventListener,N 个标签 = N 个每帧回调,还各自算一遍相机高度。改成模块级注册表 + 单个共享监听:

// 标签管理器:共享帧回调
function sharedPostRender() {
  if (labelRegistry.size === 0 || !sharedViewer || !sharedViewer.scene) {
    return;
  }
  // 相机高度每帧只算一次(原先每个标签各算一次)
  let cameraHeight;
  try {
    cameraHeight = sharedViewer.camera.positionCartographic.height;
  } catch (err) {
    return;
  }
  labelRegistry.forEach(label => {
    try {
      label.updateFrame(cameraHeight);
    } catch (err) {
      // 单个标签更新异常不影响其余标签
    }
  });
}

三个细节:

  1. 相机高度算一次,作为参数传下去——共享计算是合并循环的额外红利;
  2. 单个标签抛异常被 try-catch 吞掉——否则一个标签的数据坏了,整帧循环中断,所有标签一起冻结;
  3. destroy() 时从注册表注销并移除 DOM,防止场景重建后残留"每帧更新一个已脱离文档的 div"的僵尸回调。

4. 路线一(中):脏检查——值没变就不写

核心观察:用户看地图的绝大部分时间相机不动。相机不动 → 投影不变 → 每帧写入的值全部相同。那为什么每帧都写?

// 屏幕坐标脏检查:与上一帧相同(相机静止)则跳过 DOM 写入
// 注意 scale 也在比对条件里——为什么,见坑三
const x = Math.round(this.windowPosition.x);
const y = Math.round(this.windowPosition.y);
if (x === this._lastX && y === this._lastY && s === this._lastScale) {
  return;
}
this._lastX = x;
this._lastY = y;
this._lastScale = s;

相机静止时,每帧 DOM 写入从 ~4N 次降到 0。几行代码,三个坑:

坑一:不取整,脏检查永远不命中

投影计算输出的是浮点数,相机即使"静止",帧间的浮点尾差也可能让 x123.0000001123.0000002 之间抖动——x === this._lastX 永远为 false,脏检查形同虚设。必须 Math.round 取整,把像素抖动归零。

(副作用是标签位置失去亚像素精度,但信息牌类标签对此无感。)

坑二:隐藏时必须重置缓存

标签有可见性控制(距离太远/被地球遮挡时隐藏)。可见性切换同样做了脏检查——display 只在状态翻转时写一次:

// 标签实例:可见性切换
_setVisible(visible) {
  if (this._lastVisible === visible) {
    return;
  }
  this._lastVisible = visible;
  if (this.div) {
    this.div.style.display = visible ? 'block' : 'none';
  }
  if (!visible) {
    // 隐藏期间坐标可能变化,重置缓存以便重新显示时强制刷新
    this._lastX = null;
    this._lastY = null;
  }
}

注意最后四行:隐藏时把坐标缓存重置为 null。这是典型的"优化引入的 bug"——如果隐藏期间用户转动了地球,重新显示时标签的坐标已经变了,但脏检查的缓存还是旧值,比对结果"没变",直接跳过写入——标签钉死在旧位置,直到用户再动一下相机。

坑三:脏检查的粒度——scale 不能被坐标连带跳过

最初的版本只比对 x/y,命中就 return,把 scale 的写入也一并跳过了。这藏着一个反直觉的场景:用户正对某个标注滚轮缩放时,屏幕坐标不变、相机距离在变——x/y 命中缓存直接返回,标签不缩放,直到平移一下才恢复。这正是本节要防的"优化引入的 bug",它自己先犯了。

规则升级成两条:脏检查必须按属性各自比对——x、y、scale 各自缓存、各自判断,谁变了写谁;脏检查的缓存,必须在"跳过写入可能导致状态失真"的所有路径上主动失效——隐藏就是这样的路径。

还可以再往上一层做相机级脏检查:每帧先比对相机位置和姿态,没变就整帧跳过整个循环,把每帧 O(N) 次投影计算降到 O(1) 次比对。我们的标签量级下收益有限,标注规模上千时值得做。

5. 路线一(下):offsetWidth 一生只读一次

回到第 2 节锁定的主凶——每帧读 offsetWidth 触发强制回流。

标签内容(图标 + 名称)在创建后不变,宽度恒定,读一次就够:

// 标签实例:宽度缓存
_getLabelWidth() {
  // 用 undefined 做哨兵而不是 !cachedWidth:宽度合法值可能是 0
  // 且只在可见时测量——display:none 的元素 offsetWidth 恒为 0,测了也是错的
  if (this._cachedWidth === undefined && this._isVisible()) {
    this._cachedWidth = this.div.offsetWidth;
  }
  return this._cachedWidth;
}

首次可见时测量一次并缓存,之后每帧的读取变成纯内存访问。

必须诚实写出这个方案的边界:如果标签内容会动态变化(比如预警等级更新导致文字变长),缓存就是错的,需要在内容变更处主动清空 _cachedWidth;同理,web font 异步加载完成会改变文字排版,需要在 document.fonts.ready 之后清一次缓存重测。我们的标签内容不可变、字体随首屏加载完成,所以缓存安全——缓存的前提不是"值大概率不变",而是"值变了有明确的失效时机"

到这里,DOM 路线优化完毕:拖动时强制回流归零,静止时开销趋近于零。但它的天花板也暴露了——DOM 节点本身还在渲染管线里,规模再上一个数量级,合成与命中测试照样吃满主线程。设备图标正是这个量级,于是换路线。

6. 路线二:Canvas 离屏绘制 + toDataURL 贴图

思路一句话:把标注画成一张图,交给三维引擎当贴图(billboard),让 WebGL 管线接管一切——距离缩放、可见性剔除、遮挡,全是引擎的原生能力,DOM 世界里需要手写的每一样,这里都不存在。

先交代一个选型问题:Cesium 自带 LabelCollection(SDF 文字渲染),性能极好,但样式能力限于字体、描边、背景色,画不出渐变圆角卡片和图标组合。设备图标需要"图标 + 文字 + 预警配色"的复合视觉,所以走 Canvas 自绘。

6.1 绘制:把 CSS 的活儿抢回来干

Canvas 上依次画四层:渐变圆角背景 → 文字 → 设备图标 → 指示线:

// Canvas 绘制:渐变圆角背景
_drawLabelBackground(ctx, x, height, width, warningLevel) {
  const gradient = ctx.createLinearGradient(0, 0, 0, height);
  const colorConfig = globalColorList[warningLevel] || globalColorList[DEFAULT_WARNING_LEVEL];
  gradient.addColorStop(0, colorConfig.start);
  gradient.addColorStop(1, colorConfig.end);

  ctx.fillStyle = gradient;
  ctx.roundRect(x, 0, width, height, DEFAULT_RADIUS);
  ctx.fill();
}
// Canvas 绘制:文字
_drawLabelText(ctx, text, canvasWidth, fontSize, warningLevel) {
  ctx.font = `bold ${fontSize}px sans-serif`;
  ctx.textAlign = 'center';
  ctx.textBaseline = 'top';
  ctx.fillStyle = globalTextColorList[warningLevel] || globalTextColorList[DEFAULT_WARNING_LEVEL];
  ctx.shadowBlur = 0;
  ctx.fillText(text, canvasWidth / 2, TEXT_Y_OFFSET);
}

DOM 里一行 border-radius + linear-gradient 的事,这里要手动建渐变、手动 roundRect。便利性和性能的交换,从第一行代码就开始了。

一个工程细节:ctx.roundRect 是较新的 API(Chrome 99+ / Safari 16.4+),老浏览器需要 polyfill 或退回 arcTo 手绘路径——Canvas 路线连"浏览器兼容"都要自己扛。

6.2 第一个坑:文字宽度没有浏览器帮你算

DOM 路线里 offsetWidth 白送(虽然贵),Canvas 路线里画布尺寸得先算出来才能创建:

// 计算标签尺寸
const labelWidth = (finalConfig.fontSize + LABEL_WIDTH_OFFSET) * text.length;
const labelHeight = finalConfig.fontSize * LABEL_HEIGHT_MULTIPLIER;

(字号 + 2) × 字符数——这是按等宽字符估算的。对中文基本准确,对英文会偏宽。正确做法是先建一块临时 canvas 用 measureText 量宽——它就是排版引擎给出的精确宽度(含字距),只是要先有 canvas 可用、且要在设好 font 之后量。离开排版引擎的第一笔学费。

还有一笔隐藏学费:HiDPI。canvas 按 CSS 像素尺寸绘制,在 retina 屏上会糊。需要把画布尺寸乘以 devicePixelRatio、用 ctx.scale 放大绘制内容,billboard 的 scale 再相应除回来。DOM 路线里浏览器自动处理的事,这里又多了一件。

6.3 第二个坑:图片加载是异步的,绘制链会塌成金字塔

图标和指示线是两张图片,drawImage 必须等 onload。两层依赖就是两层嵌套:

// 图片加载嵌套链
deviceImg.onload = () => {
  try {
    ...
    lineImg.onload = () => {
      try {
        ...
        // 全部就绪才能创建实体
        this._createEntity(canvas, posObj, targetData, deviceType, maxHeight, warningLevel);
      } catch (error) { ... }
    };
  } catch (error) { ... }
};

注意每层都要 onerror + try-catch——任何一环图片挂了,这个图标就静默消失,必须有日志兜底。依赖层级再深就该上 Promise.all 重构了,两层是嵌套写法的容忍上限。

但回头看,金字塔的根源其实是每个标签各自 new Image() 加载同一批图片。更好的做法是模块级预加载:初始化时把所有图标加载完、共享 Image 实例,之后每次绘制都是同步的 drawImage——嵌套链整个消失,比"容忍两层"高明得多。

6.4 上贴图:toDataURL 与引擎原生的距离行为

画完的 Canvas 转成 data URL,作为 billboard 贴图创建实体:

// 创建 billboard 实体
const base64 = canvas.toDataURL(IMAGE_FORMAT);
...
const entity = this.viewer.entities.add({
  position: position,
  targetData: targetData,
  deviceType: deviceType,
  show: true,
  billboard: {
    image: base64,
    horizontalOrigin: this.Cesium.HorizontalOrigin.CENTER,
    verticalOrigin: this.Cesium.VerticalOrigin.BOTTOM,
    scaleByDistance: new this.Cesium.NearFarScalar(
      SCALE_BY_DISTANCE_NEAR,
      SCALE_BY_DISTANCE_NEAR_SCALE,
      SCALE_BY_DISTANCE_FAR,
      SCALE_BY_DISTANCE_FAR_SCALE
    ),
    eyeOffset: new this.Cesium.Cartesian3(0, 0, 0),
    scale: this._createScaleProperty(warningLevel),
    distanceDisplayCondition: new this.Cesium.DistanceDisplayCondition(0, maxHeight)
    // eyeOffset 用默认值 (0,0,0),无需显式传
  }
});

这一段是两条路线的分水岭,逐项对照 DOM 路线的手写实现:

  • scaleByDistance: NearFarScalar(0, 1.0, 500, 0.3)——近处原大、500 米外缩到 0.3。DOM 路线里这是我们每帧算 distance / visibleHeight 手写的;
  • distanceDisplayCondition: (0, maxHeight)——超距隐藏。DOM 路线里这是 _setVisible 脏检查管的;
  • 跟随相机移动——DOM 路线里是整个 postRender + 投影 + 脏检查体系。

WebGL 管线里这些全是渲染期原生计算,不碰 DOM,不存在回流。 toDataURL 本身有成本(同步回读画布),但只在创建时执行一次——一次性成本和每帧成本,是性能决策里最值得区分的两种成本。

复盘一下,toDataURL 其实不是最优解:billboard 的 image 可以直接传 canvas 元素,引擎自己处理上传——省掉 base64 编码 + 解码 + 一次异步 Image 加载,还避免了 500 个实体创建期的同步回读卡顿。当时用 data URL 是图调试直观(能直接贴到 <img> 里看效果),回头看直接传 canvas 更优。

还有一笔必须算的账:纹理内存。500 个标注各一张 canvas,按 200×60 像素、RGBA 4 字节算约 24MB,DPR 2 下直接 ×4。而设备图标的内容高度重复(类型 × 预警等级的组合有限),按内容 key 缓存 canvas、相同内容共享同一张贴图,500 个实体可能只需要十几种贴图——纹理内存和创建时间都降一个量级。

6.5 动画:CallbackProperty——引擎里的"每帧回调"

预警图标要呼吸动画。CSS keyframes 没了,引擎给的原语是 CallbackProperty——每帧回调你一个值:

// 呼吸动画:CallbackProperty
return new this.Cesium.CallbackProperty(function changeScale(time, result) {
  const curTimestamp = Date.now();
  if (curTimestamp - timestamp > ANIMATION_INTERVAL) {
    speed += speedDelta;
    curScale = speed ** 2;
    timestamp = curTimestamp;

    if (curScale >= MAX_SCALE) {
      speedDelta *= -1;
    } else if (curScale <= MIN_SCALE) {
      speedDelta *= -1;
    }
  }
  return curScale;
}, false);

看着眼熟吗?这又是一个"每帧回调"——和 DOM 路线的 postRender 同构。但注意内部那个 200ms 的时间戳节流:值本身按 200ms 粒度更新,回调只是返回缓存值。而且这个回调的返回值进入 WebGL 渲染,不触发任何样式失效——同样是每帧跑,成本差了一个数量级。

小细节:回调签名里给了 time(JulianDate),用它替代 Date.now() 更贴近引擎时钟,也避免系统时间被调整时动画跳变。

这印证了第 2 节的结论:贵的从来不是"每帧执行",是"每帧执行里碰了 DOM"。

6.6 交互:拾取代替事件

billboard 没有 addEventListener。点击交互改成引擎拾取:监听 canvas 的点击 → scene.pick 拿到 entity → 读挂在实体上的 targetData(见 6.4 创建时直接把业务数据塞进了实体)。

数据随实体走,拾取即拿到上下文——这是 Canvas 路线里交互接线的正确姿势,比维护一张"实体 ID → 数据"的映射表干净。

6.7 Canvas 路线的边界:状态更新

第 5 节诚实交代了 DOM 缓存的边界,这里也得交代 Canvas 路线的短板:内容不是免费的,改一次就要重画一次。设备状态变化(比如预警等级升级)时,DOM 路线改个 class 就完事,Canvas 路线要重绘 canvas、重新生成贴图、更新 billboard.image——如果做了 6.4 说的内容级贴图缓存,还要维护缓存的生命周期。

所以两条路线的适用边界可以再精确一步:内容静态、数量大 → Canvas;内容高频变化、数量小 → DOM。我们的设备图标状态变化频率低(秒级/分钟级),重绘成本摊得足够薄,Canvas 路线才成立。

7. 效果与对比

  • DOM 路线:拖动时强制回流从 ~N 次降到 0;静止时所有标签在脏检查处直接返回,开销不随 N 增长;
  • Canvas 路线:创建期一次性 toDataURL 成本,运行期零 DOM 参与,距离缩放/剔除/动画全在 WebGL 管线内;
  • 两条路线并存后,信息牌 + 设备图标合计几百个标注,拖动流畅。静止且视野内无动画标注时,主线程接近空闲;视野内有呼吸动画时 Cesium 仍每帧渲染,但单帧成本已从毫秒级降到微秒级(Performance 面板可验证)。

自测方法:Performance 面板录制静止状态 10 秒,优化前能看到密集的 Recalculate Style / Layout 条带,优化后主线程几乎全空;再用 Memory 面板确认没有 detached DOM 残留、GPU 纹理内存没有异常膨胀。

(待补:优化前后的 FPS、CPU 占用、实测标签规模等量化数据——数据比形容词有力。)

8. 沉淀:四条可迁移的原则

  1. 高频回调里,先合并再降频——N 个独立监听合并成 1 个共享循环,顺便提取公共计算;
  2. 写操作做脏检查,读操作做缓存——写 style 前比对旧值,读几何属性后缓存结果。浏览器渲染管线的成本不在"写",在"写后读";
  3. 优化必须设计失效路径——隐藏→重显要重置坐标缓存,内容变更要清空宽度缓存。没有失效机制的缓存,不是优化是地雷;
  4. 先问"该不该在这条管线上",再问"怎么优化这条管线"——脏检查把 DOM 路线优化到极限,也到不了 Canvas 路线的起点。有时最优解不是更快地做,而是换地方做。

9. 结语

两条路线没有胜负:信息牌交互重、数量少,DOM + 脏检查开发效率最高;设备图标样式简单、数量大,Canvas 贴图性能天花板最高。

没有银弹,只有 trade-off——而工程师的价值,是知道每个 trade-off 的切换点在哪。

posted on 2026-09-23 15:46  中文还在写码  阅读(41)  评论(0)    收藏  举报