
Cesium imageSubRegion 多动图闪烁问题排查
记录一次把序列帧动图迁移到业务标绘库后出现多实例闪烁的排查过程,重点分析 Cesium 1.125 与 1.140 在 Billboard 子区域贴图机制上的差异。
Kevin
这篇文章记录什么问题
这次问题发生在一个看起来很确定的迁移场景里:Demo 中已经验证过的 Cesium 动画 Billboard 方案,需要迁移到业务标绘库中,让 draw_apng_billboard 支持 APNG、animated WebP 和 GIF 的序列帧播放。
Demo 的方案是先用 ImageDecoder 把动图解码成帧,再把所有帧合成一张 sprite sheet。Cesium 侧只创建一个 Billboard,播放时通过 setImageSubRegion() 切换当前帧所在的贴图子区域。这个方案在 Demo 中运行很平滑,多个动画点同时播放也没有明显抖动。
但迁移到业务库后,单个动图基本正常,从第二个动图开始就会陆续出现不同程度的闪烁。降低 speed 只能缓解播放频率,不能消除问题。鼠标移动、标绘事件、重复加载销毁、贴图 padding、ticker 合并等方向都排查过后,问题仍然存在。
最后根因收敛到一个很容易被忽略的地方:Demo 与业务测试页虽然都叫 Cesium,但底层版本和 Billboard 贴图图集实现并不一样。序列帧方案依赖的 imageSubRegion 在不同 Cesium 版本里的行为差异,足以让同一套业务代码表现完全不同。
最初的几个错误判断
第一个判断是播放速度过快。因为问题出现时,视觉上像是动画瞬间跑完一轮,又像所有实例一起闪了一下。但实际调整 speed 后可以确认,播放倍率是生效的。问题不是动画时间计算错了,而是每次切帧引发了额外的渲染抖动。
第二个判断是业务库的鼠标事件影响渲染。标绘系统本身会注册点击、移动、拾取、拖拽等交互事件,确实可能带来 CPU 压力。但关闭或绕开相关事件后,闪烁仍然存在,说明事件不是根因。
第三个判断是加载链路重复触发。业务库里存在 loadAllSymbol、loadFeature、addFeatures 等多个入口,如果同一个动图被重复创建、销毁、再创建,确实会造成纹理 atlas 抖动。这个方向修过之后,重复加载问题得到收敛,但多实例闪烁仍然存在。
第四个判断是 sprite sheet 串帧。帧与帧之间没有 padding 时,线性采样可能读到相邻帧边缘,因此加了默认 padding,并把边缘像素扩展到 padding 区域。这个修复对抖边有价值,但仍然解释不了“第二个实例开始集体闪烁”的现象。
这些判断都不是完全离谱,只是它们都停留在业务层或资源层。真正的问题在 Cesium 内部:每次 setImageSubRegion() 究竟会不会改变 texture atlas 结构。
关键环境差异
Demo 使用的是官方 npm 包:
{
"cesium": "^1.140.0"
}
业务测试页并不是通过 npm import 获取 Cesium,而是在页面中直接加载静态文件:
<link href="/Cesium/Widgets/widgets.css" rel="stylesheet" />
<script src="/Cesium/Cesium.js"></script>
这份业务 Cesium 是二开包,基于原生 Cesium 1.125 开发。表面上 1.125 到 1.140 只差十几个版本,但对应的 engine 版本跨度更大:
cesium@1.125.0 -> @cesium/engine@13.1.0
cesium@1.140.0 -> @cesium/engine@24.0.0
这不是普通的小补丁差异。Billboard、TextureAtlas 和子区域贴图逻辑在这中间发生过结构性变化。
技术流程图
正在渲染流程图...
1.125 的子区域机制
在 Cesium 1.125 时代,Billboard 的 setImageSubRegion() 会重新进入 _loadImage(),再调用 TextureAtlas.addSubRegion()。
调用链大致是:
Billboard.setImageSubRegion(id, subRegion)
-> Billboard._loadImage()
-> TextureAtlas.addSubRegion(id, subRegion)
addSubRegion() 的核心逻辑是把传入的矩形转换成新的纹理坐标,并追加到 _textureCoordinates 中。
const newIndex =
that._textureCoordinates.push(new BoundingRectangle(x, y, w, h)) - 1
that._indexHash[id] = newIndex
that._guid = createGuid()
这里有两个很关键的点。
第一,1.125 的 addSubRegion() 没有按 id + subRegion 做缓存。即使同一个 sprite sheet、同一个帧矩形之前已经添加过,再次切到这一帧时仍然会创建新的 subRegion index。
第二,每次新增 subRegion 都会刷新 texture atlas 的 _guid。这对于静态图标不是大问题,因为 setImageSubRegion() 调用频率很低;但对序列帧动画来说,每个实例每一帧都会调用它。
BillboardCollection 又会根据 texture atlas 的 guid 判断是否需要重建渲染数据。
const textureAtlasGUID = textureAtlas.guid
const createVertexArray =
this._createVertexArray || this._textureAtlasGUID !== textureAtlasGUID
于是问题就串起来了:多个动图同时播放时,每个实例都在高频 setImageSubRegion(),每次又可能新增 subRegion、刷新 atlas guid,最终导致 BillboardCollection 频繁重建或重写整批 Billboard buffer。视觉上就变成了闪烁、抖动、像瞬间刷帧。
1.140 做了什么改变
在 Cesium 1.140 中,这块已经被拆成了新的结构:
Scene/Billboard.js
Scene/BillboardTexture.js
Renderer/TextureAtlas.js
Billboard 不再自己直接管理完整的贴图加载细节,而是通过 BillboardTexture 处理图像和子区域状态。TextureAtlas 也从旧的 addSubRegion() 变成了 addImageSubRegion()。
更重要的是,新版本有了子区域缓存查询:
TextureAtlas.prototype.getCachedImageSubRegion = function (
id,
subRegion,
imageIndex
) {
const imagePromise = this._indexPromiseById.get(id)
for (const [index, parentIndex] of this._subRegions.entries()) {
if (imageIndex === parentIndex) {
const boundingRegion = this._rectangles[index]
if (boundingRegion.equals(subRegion)) {
return imagePromise ? imagePromise.then(() => index) : index
}
}
}
}
新增子区域前会先查缓存:
let index = this.getCachedImageSubRegion(id, subRegion, imageIndex)
if (defined(index)) {
return index
}
index = this._nextIndex++
this._subRegions.set(index, imageIndex)
this._rectangles[index] = subRegion.clone()
这意味着同一个 sprite sheet 的同一个帧区域,只会注册一次。后续再次切回这帧时,拿到的是已有 index,不会无休止追加 texture coordinates,也不会像旧版本那样每次都让 atlas 结构看起来发生变化。
所以 Demo 在 1.140 上平滑,并不代表这套写法天然适配所有 Cesium 版本。它刚好依赖了新版 Cesium 对 imageSubRegion 的缓存和状态管理能力。
为什么预热和 padding 没能彻底解决
排查过程中做过两类看似合理的优化。
一类是播放前预注册所有帧区域。这个思路在新版本里很有价值,因为可以避免播放过程中首次切到某帧时才异步创建子区域。但在 1.125 里,即使预注册过,后续 setImageSubRegion() 仍然会再次进入 addSubRegion()。旧版本没有“这个 subRegion 已经存在”的缓存判断,因此预热不能阻止重复追加。
另一类是给 sprite sheet 加 padding。padding 能解决线性采样读到相邻帧的问题,尤其是火焰、烟雾这类透明边缘资源。如果看到的是边缘串色、抖边、相邻帧残影,这个方向有效。但这次主要问题是整批 Billboard 因 atlas guid 变化而发生渲染层面的抖动,padding 只能解决图像采样问题,不能解决 atlas 状态变化问题。
这也是这次最大的教训之一:同样叫“闪烁”,背后可能是完全不同的层级。资源采样问题、播放计时问题、渲染缓冲重建问题,视觉上都可能像闪烁,但修法完全不一样。
更可靠的排查顺序
这次如果重新来一遍,我会把排查顺序调整为下面这样。
第一步,先确认 Demo 和业务页使用的 Cesium 是否真的是同一份运行时。不要只看 package.json,也要看页面是否直接引入 /Cesium/Cesium.js,以及 window.Cesium.VERSION 或静态文件头部标识。
第二步,确认目标 API 是否只是名字相同,底层行为也相同。imageSubRegion 在 1.125 和 1.140 都存在,但旧版本走 addSubRegion(),新版本走 addImageSubRegion() 和 BillboardTexture,这就是典型的“API 可用但语义不同”。
第三步,抓住高频路径。序列帧动画不是偶尔调用一次 setImageSubRegion(),而是每个实例每一帧都调用。任何看起来只有一点开销的内部逻辑,在多实例动画里都会被放大。
第四步,再看业务层加载和事件。业务层重复加载、鼠标拾取、拖拽事件当然可能影响性能,但它们应该建立在底层渲染路径已经稳定的前提下排查,否则容易被表象牵着走。
后续方案选择
如果运行环境可以升级,最干净的方案是把业务 Cesium 的 Billboard 子区域机制升级到接近 1.140 的实现。这样 Demo 的 sprite sheet + imageSubRegion 播放模型可以继续保留,代码复杂度最低,也最接近 Cesium 新版本的设计方向。
如果运行环境短期不能升级,就不应该继续在 1.125 上高频调用 setImageSubRegion()。旧版本兼容方案需要绕开这条会反复 addSubRegion() 的路径,例如改成一次性创建固定帧 Billboard、只切换可见性,或者在二开 Cesium 内部给 addSubRegion() 补上 id + rectangle 缓存,避免每帧刷新 atlas guid。
这两条路线的取舍不只是技术实现问题,也涉及业务库的兼容策略。如果业务库要求支持旧 Cesium,那么动画 Billboard 工具就需要显式识别运行时能力,而不是默认认为新版 Cesium 行为存在。
经验教训
第一,迁移 Demo 代码时,不能只迁移业务逻辑,还要迁移它成立的运行时条件。这个 Demo 成立的条件之一,就是新版 Cesium 对 imageSubRegion 做了更好的子区域缓存。
第二,遇到“Demo 正常、业务库异常”时,要优先对比环境,而不是立刻假设业务代码写错。框架版本、全局脚本、二开包、构建产物,都可能让同一个 API 出现不同底层行为。
第三,性能问题要沿着高频路径排查。序列帧动画的高频点是每帧切图,最终就应该落到 setImageSubRegion() 在当前 Cesium 版本里到底做了什么。
第四,不要被症状名字限制。这里用户看到的是“闪烁”,但根因既不是动画速度,也不是鼠标事件,而是 texture atlas guid 高频变化触发的 BillboardCollection 重建压力。
第五,技术方案要标注版本边界。ImageDecoder -> sprite sheet -> Billboard.imageSubRegion 是一个好方案,但它不是对所有 Cesium 版本都等价稳定。文档、工具注释和业务接入说明里都应该写清楚对 Cesium 版本的要求。
小结
这次问题最值得记录的地方,不是某个具体 bug,而是排查方向的转折。
一开始我们围绕业务层修了很多合理的问题:加载收敛、共享 ticker、共享 atlas imageId、delta 限制、纹理 ready、subRegion 预热、sprite sheet padding。它们各自都有价值,但没有击中最终根因。
真正的根因在 Cesium 版本差异:1.125 的 TextureAtlas.addSubRegion() 会在每次切帧时追加子区域并刷新 atlas guid,而 1.140 的 TextureAtlas.addImageSubRegion() 已经具备子区域缓存和更完整的 BillboardTexture 状态管理。
所以以后再做 Cesium 序列帧动画时,要先问一个问题:当前运行时的 imageSubRegion 是新版的稳定切帧能力,还是旧版的 atlas 子区域追加能力?
这个问题问对了,后面的方案才不会跑偏。