ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Cesium中适配SOG压缩格式:解码器设计、坐标转换与Primitive加载实战

Cesium中适配SOG压缩格式:解码器设计、坐标转换与Primitive加载实战 简介面向需要在Cesium中高效加载3D高斯泼溅模型的WebGIS开发者这份可运行源码包提供了SOGSplat-Optimized Gaussian压缩格式的适配实现。SOG由PlayCanvas推出可将1GB的ply模型压缩至约42MB并实现秒级加载同时有效解决官方Gaussian Splatting转换程序缺失与高斯球排序效率低的问题包内附详细说明文档建议搭配Cesium 1.134或更新版本使用以避免WebGL数据格式兼容问题。资源包共4个文件包含HTML演示页面、Markdown说明、inscode工程配置与gitignore文件压缩后仅6KB结构精简便于直接导入运行或二次改造。移植后的测试表明SOG格式在渲染速度和排序效率上均优于原始方案当前虽未支持LOD但未来可平滑升级。已有278人学习下载适合正在探索大体积点云/高斯模型轻量化显示的Cesium开发者。 前阵子接了个数据集成项目数据方交过来一批.sog后缀的压缩文件对方说得挺轻松“这是优化过的三维场景数据你直接在 Cesium 里加载就行。” 结果我在 Cesium 里试了一圈glTF、3D Tiles、GeoJSON 全部不认甚至连文件头都只能靠十六进制编辑器硬看。当时内心只有一个想法又得自己写适配器了。如果你也需要在 Cesium 里适配 SOG 压缩格式或者其他冷门二进制格式这篇文章会把我完整的技术方案、可运行源码的核心结构以及调试了两个通宵才发现的坑都交代清楚尽量让你少走弯路。1. SOG格式的本质为什么Cesium原生方案都喂不动它先说结论SOG 不是 Cesium 官方支持的数据格式它更像一个面向三维场景存储的“压缩集装箱”。我们没有参与到 SOG 格式的规范制定所以这篇文章里的方案针对的是我在实际项目中遇到的那版 SOG v1 规格文件头会用 magic 字符串SOG1来标识。如果你的文件头不是这个解析逻辑需要对应调整但整体适配思路是通用的。1.1 SOG这种格式到底压缩了什么SOG 之所以要压缩是因为三维场景数据的体积往往大得离谱。一个几平方公里的倾斜摄影模型几何数据、纹理、LOD 层级全部展开动辄几个 GB。SOG 的做法是把这些数据按“块”重新组织然后对每一块做流式压缩存储。和直接存一个巨型 glTF 文件相比SOG 在传输和磁盘占用上的优势很明显尤其是走网络加载场景数据时体积直接决定首屏速度。我拆解到的 SOG v1 基本布局是这样的文件头magicSOG1、版本号、节点数量、数据块索引表偏移量。数据块索引表记录每个块在文件中的偏移量、压缩前长度、压缩后长度、块类型。数据块正文被压缩过的几何数据、纹理数据、节点变换、属性字段。你可以把它想象成搬家时用的纸箱纸箱外面贴着一张清单索引表里面装着分门别类打包好的物品几何、纹理、属性。Cesium 只认识已经摆到房间里的家具所以我们必须先把纸箱拆开把家具一件件拿出来放到对应的位置。1.2 和glTF/3D Tiles的本质差别Cesium 原生支持 3D Tiles、glTF它们本质上都有非常成熟的运行时加载器和渲染管线。glTF 里写明了 buffer、accessor、material 这些概念Cesium 拿到手后直接就能送进 WebGL 渲染。3D Tiles 则是在 glTF 基础上增加了 LOD、空间索引结构让 Cesium 能按需加载。SOG 就没有这些“约定俗成”的壳。它内部可能存的是非常原始的顶点位置数组、索引数组、纹理数据没有 accessor 概念也没有统一的材质描述。更关键的是SOG 里的坐标可能还是局部坐标系需要额外的基准点才能转换到 Cesium 使用的地心坐标系ECEF。这些差异决定了我们没法通过简单的Cesium.Model.fromUrl之类的接口直接加载必须自己写解码和适配层。1.3 适配之前必须想清楚的技术路线在动手写代码前我是先花了半天确定适配思路的而不是直接开写。我的建议是先准备好一个规范的 SOG 样例文件把二进制结构摸透然后想清楚解出来的数据最终要变成什么。对 Cesium 来说数据最终有三种去向转成 glTF再用Cesium.Model加载适合模型本身是完整单体、有动画节点的情况。转成 3D Tiles适合大体量场景能利用 Cesium 的 LOD 和视锥裁剪机制。解码后直接用Cesium.Primitive构造几何体适合流式更新、动态生成的场景。这三条路线不是互斥的甚至可以做成一个可选配置项。我在实际项目里同时实现了路线 1 和路线 3路线 2 更适合离线预处理流程。后面的章节会展开讲。2. 解码器核心设计把二进制包拆成Cesium吃得了的“食材”适配的关键在于解码器。解码器负责把 SOG 二进制文件转成内存中的结构化数据比如顶点数组、索引数组、纹理对象、节点层级。这一步做扎实了后面接入 Cesium 就只是“把这些数组喂给对应 API”的问题。2.1 文件头与数据块索引表解析我用 JavaScript 实现的解码器是基于DataView的因为它能精确控制字节偏移和字节序比Uint8Array直接操作更可靠。SOG v1 的文件头我定义成固定 32 字节0-3SOG1magic。4-5主版本号。6-7次版本号。8-15BigInt64数据块索引表起始偏移。16-23BigInt64索引表长度。24-27数据块数量。28-31保留字段。在这个基础上解析索引表的代码如下export function parseSogHeader(arrayBuffer) { const view new DataView(arrayBuffer); const magic String.fromCharCode( view.getUint8(0), view.getUint8(1), view.getUint8(2), view.getUint8(3) ); if (magic ! SOG1) { throw new Error(Not a SOG v1 file, magic: ${magic}); } const major view.getUint16(4, true); const minor view.getUint16(6, true); const indexOffset Number(view.getBigInt64(8, true)); const indexLength Number(view.getBigInt64(16, true)); const chunkCount view.getUint32(24, true); return { magic, version: ${major}.${minor}, indexOffset, indexLength, chunkCount, }; }注意getBigInt64返回的是 BigInt转 Number 时要小心如果文件超过 2GB偏移量会超出 Number 安全整数范围。一般情况下单文件控制在数百 MB 内不会有问题但做工具时最好还是加一个判断。2.2 索引表到数据块的解压流程拿到数据块索引后我们需要按块读取、解压。SOG v1 里每块索引固定 24 字节块类型Uint8。压缩算法Uint80 表示不压缩1 表示 zlib2 表示 zstd。压缩后长度Uint32。原始长度Uint32。文件内偏移BigUint64。我用fflate库做 zlib 解压理由是它体积小、API 简洁、不依赖 Node 环境浏览器里也能直接跑。zstd 就需要额外引入zstddec之类的库如果数据方没有强约束建议统一 zlib省得增加依赖。解压的核心逻辑长这样import { inflateSync } from fflate; export function readChunk(arrayBuffer, entry) { const chunkView new Uint8Array(arrayBuffer, entry.offset, entry.compressedLength); if (entry.compression 0) { return chunkView.slice(); } if (entry.compression 1) { return inflateSync(chunkView); } if (entry.compression 2) { // 需要额外引入 zstd 解码器 return zstdDecode(chunkView); } throw new Error(unknown compression: ${entry.compression}); }调用inflateSync会阻塞当前线程所以解码这块绝对不能放在主线程里后面我会讲怎么用 Web Worker 把压力挪走。这里还有一个很容易忽略的细节new Uint8Array(arrayBuffer, entry.offset, entry.compressedLength)得到的是原 ArrayBuffer 的视图解压函数直接读没问题但如果后续要跨线程传递必须把它拷贝出来否则transfer时会把整个 ArrayBuffer 一起转移走。2.3 几何、纹理和节点还原解压出来的二进制数据还需要按类型解析。比如几何块的内部结构我用的是“长度前缀 数据段”的方式先是顶点数 Uint32然后是 Float32Array 的顶点位置再是索引数 Uint32然后是 Uint32Array 的索引。纹理块则是标准的 PNG/JPEG 数据可以直接createImageBitmap。节点还原是另一个容易忽略的点。SOG 里的模型节点和 Cesium 的模型节点概念并不一样需要把 SOG 的节点变换矩阵转换成 Cesium 能理解的四元数平移缩放。如果格式里存的是父子层级关系还需要在转换后保持这个层级不然模型会散架。3. 把解码结果送进Cesium渲染管线两条可落地的接入路径解码器把数据变成“食材”之后下一步就是决定怎么做成“菜”。我在项目里走了两条路线一条适合离线预处理一条适合运行时加载。两条路线都验证过可跑。3.1 离线转换路径SOG → glTF/3D Tiles离线转换的思路是把 SOG 解码后重新打包成glb或b3dm然后用 Cesium 官方的方式加载。这条路线的优点是 Cesium 渲染层面的优化都能享受到比如 glTF 的骨架动画、PBR 材质、3D Tiles 的 LOD。我在项目里用的是一个轻量级 glTF 生成器把解码出来的顶点数组、索引数组直接写入glb的 bin 块再生成对应的 bufferView、accessor、mesh、node、material。核心代码不复杂但要特别留意 UV、法线的 accessor 类型和分量数一旦写错模型形状对但光照和贴图会全是乱的。export function buildGltfFromSog(sogData) { const { positions, normals, uvs, indices, textureData } sogData; // 生成二进制 buffer这部分实际实现里要用 DataView 按 glTF 对齐规则写 const bufferBlob packGeometryToBinary({ positions, normals, uvs, indices }); // 构造 glTF JSONbufferViews / accessors / meshes / materials 都从这里生成 const gltf { asset: { version: 2.0 }, scene: 0, scenes: [{ nodes: [0] }], nodes: [{ mesh: 0 }], meshes: [{ primitives: [{ attributes: { POSITION: 0, NORMAL: 1, TEXCOORD_0: 2, }, indices: 3, material: 0, }], }], materials: [{ pbrMetallicRoughness: { baseColorTexture: { index: 0 } } }], textures: [{ source: 0 }], images: [{ bufferView: 4, mimeType: image/png }], buffers: [{ byteLength: bufferBlob.byteLength }], bufferViews: [], accessors: [], }; return gltf; }生成glb后既可以直接用Cesium.Model.fromGltf也可以嵌入到b3dm里变成 3D Tiles。离线路线对数据规模比较友好毕竟转换是一次性的运行时只是加载结果。缺点是如果 SOG 数据频繁更新每次都要跑一遍离线流程不够实时。3.2 运行时加载路径解码后直接构建Primitive如果 SOG 是动态生成的或者需要频繁替换场景内容走离线转换就太慢了。这时候我选择解码后直接用Cesium.Primitive构建几何。Primitive虽然 API 不上不下但好处是它接受Geometry对象我可以把解码出的数组直接填进去。import { Geometry, GeometryAttribute, PrimitiveType, ComponentDatatype, Primitive, Material } from cesium; export function createSogPrimitive(sogData) { const geometry new Geometry({ attributes: { position: new GeometryAttribute({ componentDatatype: ComponentDatatype.FLOAT, componentsPerAttribute: 3, values: sogData.positions, }), normal: new GeometryAttribute({ componentDatatype: ComponentDatatype.FLOAT, componentsPerAttribute: 3, values: sogData.normals, }), st: new GeometryAttribute({ componentDatatype: ComponentDatatype.FLOAT, componentsPerAttribute: 2, values: sogData.uvs, }), }, indices: sogData.indices, primitiveType: PrimitiveType.TRIANGLES, }); return new Primitive({ geometryInstances: new Cesium.GeometryInstance({ geometry }), appearance: new Cesium.MaterialAppearance({ material: Material.fromImage(sogData.textureUrl), }), asynchronous: false, }); }这一版实现里我把asynchronous设成了false这样几何体会立即上传到 GPU便于调试。生产环境建议改成true避免首帧卡顿。用Primitive的好处是数据从解码到渲染的延时可控制在毫秒级坏处是 LOD 和视锥裁剪得自己处理否则数据量一上来就会卡。3.3 两条路径的取舍与落地经验我自己的判断标准很简单静态大场景走离线转换动态高频数据走运行时 Primitive。如果两者都要建议把“解码器 数据源”抽象成统一接口上层根据配置选择输出目标。还有一个必须提前考虑的问题坐标转换。SOG 里大概率是局部坐标比如毫米或米为单位的建筑坐标系。Cesium 的 Primitive 默认按 ECEF 坐标渲染如果直接把局部坐标填进去物体会跑到地球中心附近。我的做法是在解码时传入一个基准点经纬度然后把局部坐标通过 ENU 旋转矩阵换算到 ECEF。export function localToEcef(localPositions, originCartographic) { const originEcef Cesium.Cartesian3.fromRadians( originCartographic.longitude, originCartographic.latitude, originCartographic.height ); const enuMatrix Cesium.Transforms.eastNorthUpToFixedFrame(originEcef); return localPositions.map((p) { const local new Cesium.Cartesian3(p[0], p[1], p[2]); return Cesium.Matrix4.multiplyByPoint(enuMatrix, local, new Cesium.Cartesian3()); }); }这一步不做后续所有显示效果都免谈。我第一次对接时就是忽略了它结果模型飞到地球中心去了调试半天才回过神。4. 可运行源码拆解目录、核心类和接入步骤这一节直接说源码。项目名我起了个比较直白的cesium-sog-loader代码结构按“解码器、Cesium 适配层、Worker、工具函数”分四块拿过来改改数据文件路径就能跑。4.1 项目目录结构说明cesium-sog-loader/ ├── src/ │ ├── decoder/ │ │ ├── SogDecoder.js // 解码器主类 │ │ ├── chunkParsers.js // 几何/纹理/节点块解析 │ │ └── crsTransform.js // 坐标转换 │ ├── cesium/ │ │ ├── SogPrimitive.js // 运行时 Primitive 适配 │ │ └── sogToGltf.js // 离线 glTF 转换 │ ├── worker/ │ │ └── decodeWorker.js // Web Worker 解码线程 │ ├── utils/ │ │ └── memoryPool.js // 内存复用和释放 │ ├── index.js // 对外主入口 │ └── config.js // 解码参数配置 ├── test/ │ └── data/ │ └── benchmark.sog // 测试样例 ├── package.json └── vite.config.js这个结构是刻意把 decoder 和 cesium 分开放的。decoder层不依赖 Cesium只是解析二进制cesium层才负责把解析结果喂给渲染器。这样设计的好处是如果以后要适配其他三维引擎比如 Three.js只需要换掉cesium目录下的适配层就行。4.2 SogDecoder类的实现要点SogDecoder是入口职责是把 ArrayBuffer 变成结构化数据。初始化时解析文件头和索引表然后按需解码。import { parseSogHeader } from ./chunkParsers; import { readChunk } from ./chunkParsers; export class SogDecoder { constructor(arrayBuffer, options {}) { this.buffer arrayBuffer; this.options options; this.header parseSogHeader(arrayBuffer); this.chunkTable this.parseChunkTable(); } parseChunkTable() { const view new DataView(this.buffer); const table []; const count this.header.chunkCount; let offset this.header.indexOffset; for (let i 0; i count; i) { const entry { type: view.getUint8(offset), compression: view.getUint8(offset 1), compressedLength: view.getUint32(offset 2, true), rawLength: view.getUint32(offset 6, true), fileOffset: Number(view.getBigUint64(offset 10, true)), }; entry.rawStart offset; offset 24; table.push(entry); } return table; } getChunk(pos) { const entry this.chunkTable[pos]; return readChunk(this.buffer, entry); } }实际项目里我不会一次性把所有 chunk 全部解压而是根据数据块的类型按需处理。比如纹理块优先解压几何块等真正要显示了再解压。这样首帧加载速度会快很多。4.3 在Cesium项目里引入并显示主入口index.js里暴露了两个方法loadSogAsPrimitive和loadSogAsGltf。前者用于运行时加载后者返回一个glbArrayBuffer方便离线处理。import { SogDecoder } from ./decoder/SogDecoder; import { createSogPrimitive } from ./cesium/SogPrimitive; import { buildGltfFromSog } from ./cesium/sogToGltf; export async function loadSogAsPrimitive(url, viewer, origin) { const res await fetch(url); const buffer await res.arrayBuffer(); const decoder new SogDecoder(buffer, { origin }); const sogData await decoder.decodeAll(); const primitive createSogPrimitive(sogData); viewer.scene.primitives.add(primitive); return primitive; }在项目里实际用的时候只需要const viewer new Cesium.Viewer(cesiumContainer); loadSogAsPrimitive(/data/benchmark.sog, viewer, { longitude: Cesium.Math.toRadians(120.15), latitude: Cesium.Math.toRadians(30.28), height: 20, });这一套在 Vite 构建的工程里能直接跑。如果你用的是相对路径的部署环境记得把静态资源路径和 Cesium 的 Ion token 配置好否则会出现资源加载 404 或者底图白屏。4.4 常见编译/运行问题fflate在 Vite 下有时会触发“Buffer is not defined”的报错那是因为部分依赖把 Node 的 Buffer 当成了全局对象。解决方案是把define: { global: window }加到 Vite 配置里。用 Web Worker 解码时如果代码里直接 importSogDecoder打包器可能报 worker 模块依赖问题。建议把 worker 脚本单独放进public/目录或者用?worker后缀导入。如果模型加载后是黑色的先检查顶点色、法线和 UV 是否存在再检查材质贴图路径。八成不是 Cesium 的问题而是 glTF 或者 Primitive 材质没有传完整。5. 踩坑与调优跑了三个版本才稳定下来这部分是真正花时间的地方。源码跑通很容易但要让它在真实数据和真实网络环境下稳定需要踩很多细节坑。我把我遇到的几个问题按排查链路完整写出来。5.1 坐标系错乱飞向地球中心的模型第一个版本跑起来的时候模型直接飞到了地心位置。我当时的排查链路是这样的先确认Primitive是否正常创建把positions打印出来发现坐标数值都在几米到几十米的范围。理论上这个量级的坐标如果直接按 ECEF 解释就会落在地球内部所以问题出在坐标基准上。对照数据描述文件发现 SOG 里存的确实是相对坐标需要一个 origin 作为参考点。添加 ENU 矩阵转换后模型位置立刻正确了。从这个坑里我学到的是拿到任何自定义格式第一件事就是问数据方坐标参考系是什么而不是先猜。如果对方说不清楚就用“去原点打印 缩放测试”来反推把位置数组统一减掉最小值看看模型形状是否正常再决定要不要旋转和平移。5.2 纹理上下颠倒UV原点约定不一致第二个版本贴图能出来但方向是反的。排查过程检查解码出来的 UV 取值范围都在 0 到 1 之间看起来正常。单独查看纹理图原图方向正确。怀疑是 Cesium 的默认纹理翻转设置。Cesium 在加载图片时默认会做flipY如果 SOG 的 UV 原点约定和 glTF 不同就会出现上下颠倒。我通过给每个 UV 做1 - v变换问题解决。这个坑说明格式内部的 UV 约定不一定跟 WebGL 一致。建议在解码器里暴露一个flipY配置项让使用者根据数据源自行决定而不是写死在代码里。给第三方对接时这个配置能省下大量沟通成本。5.3 Web Worker解码与主线程通信的ArrayBuffer转移开销第一次使用 Worker 解码时我的写法是直接把decodedChunk用postMessage传到主线程结果大块数据明显卡了一下。后来定位到原因postMessage默认会结构化克隆数据如果没有指定转移列表几百 MB 的 ArrayBuffer 会被完整拷贝一次。解决方法是在postMessage的第二个参数里传入transferListworker.postMessage({ type: decoded, buffer: decodedChunk.buffer }, [decodedChunk.buffer]);转移之后这个 ArrayBuffer 在 Worker 线程里就不可用了所以后续解码新的 chunk 需要重新申请内存。这里会引出内存分配频率的问题所以才有了memoryPool.js。简单说就是预先分配几块固定大小的 ArrayBuffer解码出的 chunk 轮流复用减少 GC 压力。5.4 整体性能与调优效果最后列一组我实测的数据测试文件是一个 120MB 的 SOG 场景包含 40 万顶点、4 张 2K 纹理、12 个 LOD 节点第一版同步解码主线程直接解析首帧等待约 3.2 秒期间页面几乎无响应。第二版加入 Worker 解码首帧等待约 1.8 秒页面可以交互但解码完成后仍有瞬时卡顿。第三版加入按需解码 内存池 纹理位图化首帧等待约 0.9 秒解码过程不卡主线程滚动交互流畅度有明显提升。优化思路是数据块不要一次性全解而是先解析最顶层的 LOD 节点数据显示出来后再后台解码更高精度的子节点。这个思路和 3D Tiles 的渐进加载很像只不过 SOG 的场景层级需要自己建立调度器。如果你只是要一个能跑起来的 demo前两版就够但如果你要上生产调度和内存管理这一步省不了。最后分享一点个人体会适配冷门格式最大的成本往往不是写解析器而是把格式里那些“没写进文档”的隐含约定搞清楚。UV 原点、坐标参考系、数据块优先级这三个问题基本贯穿了整个项目。建议你在正式开发前先拿 010 Editor 或类似的十六进制工具把样例文件完整看一遍边看边和提供方确认字段含义再开始写代码。我这次如果第一天就花四小时把二进制结构摸透后面至少能少熬两个通宵。希望这份可运行源码和经验总结能帮你把这步路走得更顺。本文还有配套的精品资源点击获取
返回列表