deck.gl 二进制数据实战指南:从 TypedArray 输入到 GPU Buffer 属性直传
【免费下载链接】deck.glWebGL2 powered visualization framework项目地址: https://gitcode.com/GitHub_Trending/de/deck.gl
本文基于 binary-data-rfc.md(deck.gl v7.2 时代由 Ib Green 与 Xiaoji Chen 起草、状态标记为Implemented的 RFC)展开。该 RFC 本身即按开发者指南文章的形式撰写,其核心内容随后被整理并沉淀进 developer-guide/performance.md 的 “Use Binary Data” 章节,并在 layer.md 的 data prop 文档 中固化为正式 API。本文在此基础上,结合当前仓库的源码实现与测试用例,完整覆盖「扁平化 TypedArray 作为 data 输入」「TypedArray / GPU Buffer 直接作为图层属性」两条技术路线,并给出各核心图层二进制内存布局的速查与底层原理印证。
一、为什么要用二进制数据?
deck.gl 的所有图层默认都面向「经典 JavaScript 对象数组」设计——即data是[{position, radius, color}, ...]这样的结构,每一条记录再通过getPosition、getRadius、getFillColor等 accessor 取出字段。但真实世界中,应用的数据来源往往并不长这样:
- 数据可能由后端、Web Worker 以二进制形式送达。TypedArray 可以直接以二进制传输(例如
postMessage的 transferable 语义可以几乎零成本地转移底层ArrayBuffer的所有权),而普通对象数组通常需要 JSON 序列化再解析; - 高频更新的场景(如逐帧动画、实时数据流)中,在 CPU 主线程反复重建对象数组既耗时又占内存。
RFC 将二进制数据的应用场景归纳为两个层次:
- 用 TypedArray 作为图层输入数据——数据以二进制扁平数组形式存在,应用不再需要解包成对象数组;
- 用 TypedArray 或 GPU Buffer 直接作为图层属性——输入数据本身就是 GPU 与 deck.gl shader 期望的内存格式,跳过图层内置的属性生成流程。
注意,RFC 也明确声明:直接喂二进制数据在版本语义上属于「实验性」能力——二进制格式不保证在 minor release 之间保持不变。虽然社区会尽量避免变更,但为了优化或实现新功能,必要时仍然可能调整布局。
二、方案一:把扁平化 TypedArray 当作data输入
2.1 反面教材:先把二进制解包成对象数组
设想后端送来的数据是如下布局的Float32Array,每个点占 6 个分量:经度、纬度、半径、R、G、B。
// lon1, lat1, radius1, red1, green1, blue1, lon2, lat2, ... const binaryData = new Float32Array([ -122.4, 37.78, 1000, 255, 200, 0, -122.41, 37.775, 500, 200, 0, 0, -122.39, 37.8, 500, 0, 40, 200 ]);最直观(但低效)的做法是先重建一个经典对象数组:
const data = []; for (let i = 0; i < binaryData.length; i += 6) { data.push({ position: binaryData.subarray(i, i + 2), radius: binaryData[i + 2], color: binaryData.subarray(i + 3, i + 6) }); } new ScatterplotLayer({ data, getPosition: d => d.position, getRadius: d => d.radius, getFillColor: d => d.color });RFC 明确指出这种做法的代价:除了要编写自定义的解包代码,这个数组会占用宝贵的 CPU 时间去创建,存储开销也显著大于二进制形式。在持续推送大批量数据的性能敏感型应用(例如动画)中,这种方式远不够高效。这也被正式写入 developer-guide/performance.md 的 "Use Binary Data" 章节 并被标注为 "Bad practice"。
2.2 推荐做法:非可迭代data对象 + 自定义 accessor
deck.gl 允许向data传入一个非可迭代对象(既不是Array也不是TypedArray)。此时该对象必须包含一个length字段,用于声明数据对象的个数。由于data不可迭代,每个 accessor 收到的第一个参数object不会是有效的对象,accessor 需要自行负责解读输入数据的缓冲区布局:
const data = {src: binaryData, length: 3}; new ScatterplotLayer({ data, getPosition: (object, {index, data, target}) => { target[0] = data.src[index * 6]; target[1] = data.src[index * 6 + 1]; target[2] = 0; return target; }, getRadius: (object, {index, data}) => { return data.src[index * 6 + 2]; }, getFillColor: (object, {index, data, target}) => { target[0] = data.src[index * 6 + 3]; target[1] = data.src[index * 6 + 4]; target[2] = data.src[index * 6 + 5]; target[3] = 255; return target; } });accessor 的第二个参数info中,index是当前对象的序号,data就是你传入的那个非可迭代对象本身,因此可以直接通过data.src访问原始缓冲区。target是 deck.gl 预分配的结果数组:把值写入target并返回它,可以避免在每次访问时创建新的临时对象,从而进一步降低 GC 压力。
一个容易踩的坑(layer.md 文档 中也有专门注释):binaryData.length并不等于对象个数。Float32Array.length返回的是元素数量(这里是 18),而我们要告诉图层的是对象数量(3),所以必须用{src: binaryData, length: binaryData.length / 6}这样的包装方式。
2.3 让拾取(Picking)事件也能返回可用的object
使用非可迭代 data 时,图层内置的拾取流程只能拿到索引,拿不到真实对象。此时可以可选地提供getPickingInfo回调,把info.object补全成一个可用的对象:
new ScatterplotLayer({ // ... getPickingInfo: ({info, data}) => { const i = info.index * 6; info.object = { position: data.src.subarray(i, i + 2), radius: data.src[i + 2], color: data.src.subarray(i + 3, i + 6) }; return info; } })从源码看,getPickingInfo是 Layer 基类 暴露的可覆写生命周期方法,默认实现对info不做加工;拾取流程在 pick-info.ts 中会调用layer.getPickingInfo({info, mode, sourceLayer})来填充信息对象。因此在这个回调里返回新的info,即可让上层(tooltip、事件监听)拿到结构化的拾取结果。
2.4 变长消息:用startIndices描述分段布局
二进制缓冲区并不总是固定步长的。RFC 以PathLayer为例展示了如何把变长路径切分成多条消息:
// lon1, lat1, alt1, lon2, lat2, alt2, ... const positions = new Float32Array([ -122.426942, 37.801537, 0, -122.425942, 37.711537, 0, ... ]); // path1_start_index, path2_start_index, ... const pathStartIndices = new Uint16Array([0, 36, 72, 147]); const data = {positions, pathStartIndices, length: 4}; new PathLayer({ data, getPath: (object, {index, data}) => { const {positions, pathStartIndices} = data; const startIndex = pathStartIndices[index]; const endIndex = pathStartIndices[index + 1] || positions.length / 3; return positions.subarray(startIndex * 3, endIndex * 3); }, getWidth: 10, getColor: [255, 0, 0] })每条路径通过pathStartIndices记录起点下标,endIndex取下一个起点或数组末尾,从而支持每段长度不同(如折线顶点数不一)的数据。
这与底层的数据长度推导机制是吻合的:Layer 基类的getStartIndices()优先读取props.startIndices,其次读取state.startIndices,默认返回null;而getNumInstances()在props.numInstances未显式提供时,会调用 count() 从容器推导元素个数——count()的推导顺序依次是count()方法、size属性、length属性,这也解释了为什么非可迭代对象只要带上length就能被图层正确统计。
三、方案二:把 TypedArray / GPU Buffer 直接喂给图层属性
3.1 基本思路
deck.gl 图层会为每个属性名在 props 中查找外部数据:如果应用提供了 TypedArray,图层会把它上传为 GPU Buffer;如果直接提供了 GPU Buffer,则直接使用,而不再基于data自行构建缓冲区。
在 attribute-manager.ts 中,当data.attributes[accessorName]存在时,属性管理器会优先从外部 buffer 直接更新属性,其次尝试从外部 typed array 设置打包值——这正是「跳过内置属性生成」的源码依据。
3.2data.attributes的正式格式
根据 layer.md 中data.attributes的文档,当使用非可迭代data对象时,可以可选地包含attributes字段。其键名与要替换的 accessor 名对应(例如getPosition、getColor),每个值可以是以下三种形式之一:
- luma.gl
Buffer实例:直接使用现成 GPU Buffer; - TypedArray:由图层用它创建 Buffer;
- 对象,包含以下可选字段(语义对齐 WebGL
vertexAttribPointer):buffer(Buffer)——GPU Buffer 实例;value(TypedArray)——CPU 侧的属性数据;type——WebGPU 风格数据类型,取值为"uint8" | "sint8" | "unorm8" | "snorm8" | "uint16" | "sint16" | "unorm16" | "snorm16" | "uint32" | "sint32" | "float32";size(number)——每个顶点属性包含的元素个数;offset(number)——缓冲区内第一个顶点属性的字节偏移;stride(number)——相邻顶点属性起点之间的字节间距(用于交错布局);normalized(boolean)——数据是否归一化。注意:deck.gl 图层的所有颜色属性默认都是归一化的。
3.3 实战:把属性生成搬进 Web Worker
官方文档 developer-guide/performance.md 的 "Supply attributes directly" 小节 给出了完整链路。以PointCloudLayer为例,先在 Worker 中生成扁平化属性:
// Worker // 按 precision 需求,positions 可用 float32 或 float64 // point[0].x, point[0].y, point[0].z, point[1].x, ... const positions = new Float64Array(POINT_CLOUD_DATA.flatMap((d) => d.position)); // point[0].r, point[0].g, point[0].b, point[1].r, ... const colors = new Uint8Array(POINT_CLOUD_DATA.flatMap((d) => d.color)); // 把底层 ArrayBuffer 的所有权转移回主线程,几乎零拷贝 postMessage({pointCount: POINT_CLOUD_DATA.length, positions, colors}, [positions.buffer, colors.buffer]);主线程直接消费:
// Main thread:data 来自 worker const layer = new PointCloudLayer({ data: { // 必须提供,图层靠它知道要画多少个点 length: data.pointCount, attributes: { getPosition: {value: data.positions, size: 3}, getColor: {value: data.colors, size: 3}, } }, // 常量 accessor 不需要原始数据也能工作 getNormal: [0, 0, 1] });这就是「零 JSON 序列化 + 零 CPU 属性生成」的最高吞吐方案。RFC 与官方文档均强调:预计算属性在吞吐上可达到理论最大值,常被重度性能敏感型应用采用,能完全绕开主线程上受 CPU 约束的属性生成瓶颈——代价是应用自己要维护属性格式的正确性。
3.4 交错(interleaved)与自定义布局
同一份缓冲区还可以通过offset/stride描述交错布局,位置与颜色交替存放:
// Worker:x0,y0,z0, r0,g0,b0, x1,y1,z1, ... const positionsAndColors = new Float32Array( POINT_CLOUD_DATA.flatMap((d) => [ d.position[0], d.position[1], d.position[2], // 以浮点发送的颜色必须归一化 d.color[0] / 255, d.color[1] / 255, d.color[2] / 255 ]) ); postMessage({pointCount: POINT_CLOUD_DATA.length, positionsAndColors}, [positionsAndColors.buffer]);// Main thread const buffer = deckInstance.device.createBuffer({data: data.positionsAndColors}); const layer = new PointCloudLayer({ data: { length: data.pointCount, attributes: { getPosition: {buffer, size: 3, offset: 0, stride: 24}, getColor: {buffer, size: 3, offset: 12, stride: 24}, } }, getNormal: [0, 0, 1] });仓库中还有配套的完整可运行示例 examples/experimental/interleaved-buffer,展示了交错缓冲区的端到端用法。
3.5 使用限制(务必注意)
- 只适用于基础图层(primitive layers),不适用于复合图层(composite layers)——复合图层往往需要在传给子图层前对数据做预处理;
- 变宽数据图层(如
PathLayer、SolidPolygonLayer)需要随data.attributes一并提供额外的分段信息(对应startIndices机制),使用前务必查阅各图层的文档; - 使用外部属性时,可以配合常量 accessor(如示例中的
getNormal: [0, 0, 1]),它在没有原始数据的情况下依然有效。
四、核心图层的二进制格式速查
RFC 还给出了核心图层目录的二进制格式总览。它的定位是概览而非穷举——每个图层最终需要定义自己的二进制格式,拿不准时请直接查阅图层源码。
4.1 一对一(instanced)图层
这类图层二进制表示最直白,属性几乎可以在一一对应的实例维度上直接生成(甚至可以在服务端预生成后随数据一起下发):
| 图层 | 类型 | 关联 accessor |
|---|---|---|
PointCloudLayer | 1-to-1 | instancePositions、instanceColors等 |
ScatterPlotLayer | 1-to-1 | instancePositions、instanceColors等 |
LineLayer | 1-to-1 | instancePositions、instanceColors等 |
ArcLayer | 1-to-1 | — |
GridCellLayer | 1-to-1 | — |
HexagonCellLayer | 1-to-1 | — |
IconLayer | 1-to-1 | — |
TextLayer | 1-to-1 | — |
4.2 自定义几何图层
此类图层布局更复杂,包含三类成分:
- 主几何体(positions 及其补充属性)——部分图层布局较复杂,见各图层说明;
- 逐顶点复制值——若干属性(颜色、高程等)是某个 per-object 值的 per-vertex 拷贝,可以自动化生成;
- 每个对象可能使用自定义的顶点数,或每个对象对应自定义的实例数。
涉及图层:PathLayer、PolygonLayer、SolidPolygonLayer、GeoJsonLayer。
其中PathLayer的二进制内存布局在 RFC 中被明确列出(以 3 分量顶点 v0..vn-1 为例):
| 属性 | 布局 |
|---|---|
startPos | v0.xv0.yv0.zv1.xv1.yv1.z...vn-2.xvn-2.yvn-2.z |
endPos | v1.xv1.yv1.zv2.xv2.yv2.z...vn-1.xvn-1.yvn-1.z |
leftDelta | v0 - vn-2,v1 - v0,...,vn-2 - vn-3 |
rightDelta | v1 - v0,v2 - v1,...,vn-2 - vn-3 |
也就是说,路径的一段线段由startPos/endPos两个顶点构成,再配合相邻顶点差分的leftDelta/rightDelta实现描边扩展与连接处的几何细节——这也是为什么 PathLayer 这类变宽数据图层在使用外部属性时需要额外的分段信息。
4.3 聚合图层
RFC 撰写时,聚合图层的二进制支持尚未纳入考虑(标注为 TBD),设想的思路是「以二进制形式提供输入数据,并让聚合算法直接作用在扁平化数据上」:
| 图层 | 类型 | 关联 accessor |
|---|---|---|
HexagonLayer | aggregating | 参见HexagonCellLayer |
GridLayer | aggregating | 参见GridCellLayer |
ScreenGridLayer | aggregating | ... |
五、背景知识:CPU 内存 vs GPU 内存
最终所有 deck.gl 渲染都在 GPU 上完成,所有内存都必须通过「上传」到 GPU 内存(Buffer)才能被 GPU 使用。预先创建 GPU Buffer 并传给图层,是应用对内存管理拥有最大控制力的方式:
import {Buffer} from 'luma.gl'; const buffer = new Buffer(gl, {data: /* typed array */});GPU Buffer 拥有许多高级特性,例如可以交错排布多个属性。luma.gl 的Accessor类正是用来描述 GPU Buffer 应该如何被读取(大小、步长、偏移、归一化等)的工具——它对应的概念正是 3.2 节中data.attributes对象里的那些字段。
在动手之前,RFC 也建议读者确保自己熟悉 JavaScript TypedArray 与 GPU Buffer 的基本概念与 API,否则很容易在字节偏移、归一化、步长等问题上踩坑。
六、总结与决策建议
| 方案 | 适用场景 | 代价 |
|---|---|---|
非可迭代data+ 自定义 accessor | 数据已是二进制扁平数组、不想解包成对象数组 | accessor 需自行解读布局;拾取需补getPickingInfo |
data.attributes传 TypedArray | 属性已在 Worker/服务端生成,想绕过 CPU 属性生成 | 需自行保证布局、length、size等正确 |
data.attributes传 GPU Buffer | 需要最大内存控制权、使用交错布局或复用已有 GPU 资源 | 需管理 Buffer 生命周期与 GPU 内存 |
两条路线的共同前提是理解「对象数组 → accessor → 扁平属性数组 → GPU Buffer」这条 deck.gl 内置的数据流水线;二进制方案的本质就是在流水线的不同位置接管数据生产。最后再次提醒:二进制格式属于实验性能力,升级 deck.gl 时请留意 release note 中的格式变更;以当前仓库为准,本指南所有代码示例均与 dev-docs/RFCs/v7.2/binary-data-rfc.md、docs/developer-guide/performance.md 与 docs/api-reference/core/layer.md 保持一致。
【免费下载链接】deck.glWebGL2 powered visualization framework项目地址: https://gitcode.com/GitHub_Trending/de/deck.gl
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考