尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Cesium中GIF动画加载实战:TextureAtlas与BillboardCollection实现

Cesium中GIF动画加载实战:TextureAtlas与BillboardCollection实现 简介面向需要在Cesium三维场景中展示动态GIF图片的WebGIS开发者这份资源聚焦Cesium不直接支持GIF格式的痛点提出一套基于loaders.gl的渐进式实现方案而非单纯贴图或生成Spritesheet的简易替代。资源围绕五个关键环节展开说明依赖安装与版本搭配、GIF图像异步读取、TextureAtlas帧序列拆分、自定义Material与着色器绑定以及利用postRender事件驱动逐帧更新动画其中对于多帧GIF如何切分纹理坐标、如何规避WebGL纹理尺寸限制和性能损耗等细节也有具体交代能帮助读者梳理从图片解码到纹理上传再到动画播放的完整链路。压缩包约15.03MB体积适中以示例代码和说明为主体适合具备一定Cesium基础、正在开发动态标记、广告牌或信息弹窗特效的开发者移植复用。目前已有731人学习下载不失为一份聚焦Cesium动图应用的实用参考资料。1. Cesium 加载 GIF 图片为什么不能直接挂 URL把 GIF 地址直接塞给viewer.entities.add({ billboard: { image: url } })大概率只有第一帧固定在地球上或者干脆变成一张空白图。原因不在 Cesium而在 WebGL 的纹理接口texImage2D只接受静态图像源浏览器里的 GIF 动画播放是靠 HTML 引擎独立调度并不会自动把每一帧同步到 GPU 纹理。更麻烦的是loaders.gl/images虽然能解 GIF默认也只返回当前帧要让它真正动起来必须自己拆帧、传纹理、再按帧切换。下面从拆帧到挂接BillboardCollection写一遍完整流程适合做雷达回放、告警点、轨迹流动标记的开发者方案可以原样搬进 3D Tiles、热力图或 MVT 叠加的项目里。2. 拆帧与数据管线用 gifuct-js 把 GIF 变成逐帧位图2.1 为什么 WebGL 不认 GIF 动图WebGL 的纹理上传最终都会落到texImage2D/texSubImage2D它们接受HTMLImageElement、HTMLCanvasElement、ImageData或ImageBitmap却没有“GIF 动画序列”这个类型。浏览器渲染 GIF 动图是在img元素所在的文档渲染管线里完成的每一帧由解码器推进然后由页面合成器显示出来这个过程的结果不会自动出现在一个可供 WebGL 查询的纹理对象里。Cesium 的 Billboard 虽然支持image直接传 URL但 URL 经过异步加载后已经变成静态纹理所以你会看到第一帧。2.2 静态图让 loaders.gl/images 处理动态帧交给 gifuct-js先装依赖npm install cesium gifuct-js loaders.gl/core loaders.gl/imagesloaders.gl/images在这个场景里的价值是统一加载 JPEG、PNG、WebP 这类静态图生成ImageBitmap后可以快速传给Cesium.TextureAtlas。但对 GIF它的ImageLoader默认只拿到一帧所以动画拆帧我用gifuct-jsimport { load } from loaders.gl/core; import { ImageLoader } from loaders.gl/images; import { parseGIF, decompressFrames } from gifuct-js; async function loadGifFrames(url) { const buffer await fetch(url).then((res) res.arrayBuffer()); // 解析 GIF 头与图像控制扩展 const gif parseGIF(buffer); // 第二个参数传 true让每一帧都输出为完整帧避免增量帧不合成 const frames decompressFrames(gif, true); return frames; }这里parseGIF负责解读 GIF87a/89a 的文件结构decompressFrames负责还原每一帧的像素数据。第二个参数true是必须的很多 GIF 为了压缩体积后一帧只记录和前一个静态帧不同的区域也就是增量帧不合成的话你拿到的frame.patch只是局部像素直接上传纹理就会出现残缺画面。2.3 把帧数据转成 Canvas 帧序列decompressFrames返回的每个frame包含patch、dims、delay等字段。patch是Uint8ClampedArray顺序是 RGBA正好可以塞进ImageData。我一般会把它转成一组HTMLCanvasElement因为这是 Cesium 纹理上传兼容性最好的格式function framesToCanvas(frames, width, height) { const tempCanvas document.createElement(canvas); tempCanvas.width width; tempCanvas.height height; const ctx tempCanvas.getContext(2d); const imageData ctx.createImageData(width, height); return frames.map((frame) { imageData.data.set(frame.patch); ctx.putImageData(imageData, 0, 0); const frameCanvas document.createElement(canvas); frameCanvas.width width; frameCanvas.height height; frameCanvas.getContext(2d).drawImage(tempCanvas, 0, 0); return frameCanvas; }); }tempCanvas只是中转站每一帧都重新绘制一份独立 canvas避免后面TextureAtlas.addImage异步处理时引用了同一块正在变化的画布。width和height从gif.lsd.width/gif.lsd.height取lsd是逻辑屏幕描述符代表 GIF 画布尺寸。如果你的 GIF 有大量透明背景RGBA 的 alpha 通道会原样保留后续纹理就不容易出现黑底。字段含义使用注意frame.patch当前帧 RGBA 像素数据需要先写入ImageData再绘图frame.delay当前帧显示时长gifuct-js返回的是毫秒切换帧时直接累加frame.dims帧在画布中的位置和尺寸完整帧模式下可忽略但排错时可以对照到这里数据管线已经打通GIF 变成了一个可索引的 canvas 数组下一步要做的是把它们合并进 Cesium 的 GPU 纹理体系。3. TextureAtlas 合并帧序列让 BillboardCollection 动起来3.1 为什么不建议把 Canvas 直接传给 entity.billboard.image很多入门代码会让你把canvas塞给billboard.image然后自己用setInterval重绘画布。这在单独跑一个 GIF 时确实能显示但有两个问题Cesium 不会主动监听 canvas 内容变化要让它重新上传纹理往往得再触发一次entity.billboard.image canvas而这个赋值会重新走一遍纹理创建频繁操作会堆积纹理对象另外一个 canvas 只能服务一个 billboard当日志点数量超过几十个浏览器内存和 GPU 显存都会很难看。更稳的做法是用Cesium.TextureAtlas。3.2 创建 TextureAtlas 并挂到 BillboardCollectionTextureAtlas本质上是一张大纹理你可以把 GIF 的每一帧作为一个小图往里塞之后用imageIndex指向具体帧。Cesium 的BillboardCollection自带atlas属性新增 billboard 时只要指定imageIndex就可以在同一张大纹理上来回切换async function createGifAtlas(viewer, frameCanvases) { const atlas new Cesium.TextureAtlas({ context: viewer.scene.context, borderWidthInPixels: 1, // 避免相邻帧采样串色 }); await Promise.all( frameCanvases.map( (canvas) new Promise((resolve) { atlas.addImage(canvas, resolve); }) ) ); return atlas; } const gifAtlas await createGifAtlas(viewer, frameCanvases); const billboards viewer.scene.primitives.add( new Cesium.BillboardCollection({ atlas: gifAtlas, }) ); const billboard billboards.add({ position: Cesium.Cartesian3.fromDegrees(120.1, 30.2, 50), imageIndex: 0, scale: 1.0, verticalOrigin: Cesium.VerticalOrigin.BOTTOM, });context来自viewer.scene.context它是 Cesium 为 WebGL 上下文做的封装TextureAtlas必须用它来创建。borderWidthInPixels设为 1是为了在图集内部每个子图四周留一点空白边界防止纹理过滤时采样到相邻 GIF 帧的颜色。atlas.addImage是异步操作回调在 GPU 纹理真正更新后触发所以要用Promise包一层等全部添加完成再创建 billboard否则imageIndex可能会报错或显示成空白。3.3 postRender 驱动帧切换帧切换不需要重建图集只需在每一帧把billboard.imageIndex改为下一帧。这里我直接用viewer.scene.postRender事件let frameIndex 0; let lastSwitchAt performance.now(); viewer.scene.postRender.addEventListener(() { const now performance.now(); const delay frames[frameIndex].delay || 100; if (now - lastSwitchAt delay) { frameIndex (frameIndex 1) % frames.length; billboard.imageIndex frameIndex; lastSwitchAt now; } });frames是loadGifFrames返回的原始帧数组拿delay做时间闸门而不是每帧都切一次。GIF 的帧延时不会特别均匀有的帧 30ms有的帧 80ms直接用performance.now()累加比setInterval(fixedRate)更接近原始动画节奏。frameIndex到达末尾后回卷到 0相当于完成了循环播放如果某个 GIF 只需要播一次判断frameIndex 1 frames.length后移除即可。方案纹理上传次数内存开销适用场景entity.billboard.image canvas每次重绘重新上传低但频繁赋值容易堆积1~2 个测试用 GIFBillboardCollectionTextureAtlas只在加入图集时上传一次图集随帧数线性增长实时告警、多目标跟踪video元素直接设为 image解码器自行管理较高且透明通道支持不稳定高帧率大尺寸演示表格里顺带提了 video这是另一个可考虑的方向但没有 GIF 灵活后面第 5 章再展开。4. 实战多 GIF 标记、3DTiles 与热力图场景的叠加写法4.1 多个 GIF 标记共享一个 TextureAtlas实际项目里很少只挂一个 GIF。比如雷达回波范围、设备状态灯、车辆运行动画多个标记可以共用同一个TextureAtlas只是每个 billboard 持有不同的imageIndex。我一般会把这些状态封装成一个简单的动画管理器const animatedItems []; function addGifBillboard(options) { const billboard billboards.add({ position: options.position, imageIndex: options.startFrame || 0, scale: options.scale || 1.0, }); animatedItems.push({ billboard, frames: options.frames, currentIndex: options.startFrame || 0, lastSwitchAt: performance.now(), }); }更新时遍历这个列表按各自的delay切帧viewer.scene.postRender.addEventListener(() { const now performance.now(); for (const item of animatedItems) { const delay item.frames[item.currentIndex].delay || 100; if (now - item.lastSwitchAt delay) { item.currentIndex (item.currentIndex 1) % item.frames.length; item.billboard.imageIndex item.currentIndex; item.lastSwitchAt now; } } });这样做的收益是所有 GIF 帧都在同一张大纹理里imageIndex切换不涉及上传操作GPU 只需要按索引采样性能远好于每个标记独立创建 canvas 或 URL。4.2 让标记跟随 3DTiles 单体化对象当动态 GIF 想跟随某个 3DTiles 单体化对象时不需要把 GIF 挂到 model 上直接在postRender里更新billboard.position就行。常见做法是先用viewer.scene.pick拿到Cesium3DTileFeature从 feature 的primitive和content里算出包围球中心再转成Cartesian3const picked viewer.scene.pick(windowPosition); if (picked instanceof Cesium.Cesium3DTileFeature) { const center picked.content.boundingSphere?.center; if (center) { billboard.position center; } }这种方式对 3D Tiles 本身没有任何侵入模型照样走 LOD 调度动态告警点也不会因为频繁修改模型材质而触发重新编译着色器。与 cesium 3dtiles 单体化配套时最关键的是不要把scene.pick放进每一帧调用postRender本身就发生在渲染后此时再 pick 会多做一次 CPU 遍历我一般在鼠标点击或定时器的低频率事件里更新目标位置动画只负责切imageIndex。4.3 与 MVT、动态光照和热力图叠加时的刷新节奏场景里一旦出现 cesium 加载 MVT 格式、动态光照、热力图这类高频 CPU 计算任务就要控制 GIF 动画的刷新节奏。不要为每个 GIF 单独开setInterval统一收敛到一个postRender回调并且把帧切换和requestRender错开。如果项目已经打开requestRenderMode必须在切换imageIndex后调用viewer.scene.requestRender()否则画面不会重绘GIF 停在旧帧如果关闭了requestRenderModeCesium 默认持续渲染反而不需要额外调用但要注意 GIF 帧数别太多。代码里可以加个简单的可见性判断if (document.hidden) { return; // 后台标签页不再切帧恢复时继续 }这样在切换到后台时不会白白消耗 CPU 和 GPU尤其是同时叠加热力图和 3D Tiles 的场景后台解帧很容易把移动端设备拖到发热。刷新动作推荐频率原因scene.pick/ 3DTiles 坐标计算鼠标事件或 1s 定时器pick 是 CPU 密集操作billboard.imageIndex切换由 GIF 帧 delay 决定每帧切会浪费 GPU 采样热力图 / 动态光照重绘数据变化时调用requestRender()持续重绘对移动端功耗影响大5. 性能、内存与常见坑GIF 动画的排错清单5.1 三个高频问题排查现象常见原因处理方式只显示第一帧loaders.gl/images只解析了静态帧或没有调用imageIndex更新改用gifuct-js拆帧并在postRender里切 index画面出现彩色条纹 / 花边TextureAtlas的borderWidthInPixels设置为 0或帧之间没有留边界设置borderWidthInPixels: 1必要时加到 2透明背景变成黑色中间经过canvas.toDataURL(image/jpeg)或上传时用了错误的预乘 alpha 选项保持 canvas 默认 RGBA不要手动转 JPEG还有一个很容易踩的坑TextureAtlas.addImage是异步完成的如果你在回调前就执行billboards.add某些 Cesium 版本会直接抛Invalid image index。所以第 3 章的createGifAtlas必须处理 Promise。5.2 用 requestRenderMode 控制渲染如果场景中数据层不多我一般会开启按需渲染来省电const viewer new Cesium.Viewer(cesiumContainer, { requestRenderMode: true, maximumRenderTimeChange: Infinity, });开启后Cesium 只在场景变更时渲染GIF 切帧也要主动触发在postRender里把billboard.imageIndex改完之后调用viewer.scene.requestRender()。注意maximumRenderTimeChange会影响时钟相关动画如果同时用到时间轴把它设成一个小值而不是Infinity。这个参数的作用是告诉 Cesium当仿真时间前进多大时即使没有场景事件也要重绘一次。5.3 有没有必要转成 WebM如果 GIF 本身分辨率大、帧数多比如超过 1024 尺寸的演示动画图集方案会占用较多显存。这时可以换思路把 GIF 转成带透明通道的 WebM用video元素作为 Cesium 的图片源Cesium 对 video 元素有原生支持播放节奏由解码器控制不需要自己维护imageIndex。缺点是 video 解码会增加 CPU 占用并且移动端透明通道兼容性需要单独测试。大多数场景下TextureAtlas拆帧方案在可控帧数内反而是最平稳的因为所有帧在初始化阶段已经进入 GPU后续只是换索引没有连续解码开销。如果你是给 cesium for unity 或 Unreal 等非 Web 环境做类似功能思路也一致先拆帧再拼一张精灵图最后按时间切 UV 或索引只是 API 换成对应引擎的 TextureAtlas。本文还有配套的精品资源点击获取
返回列表