北斗网格性能优化(二):GPU Instancing 与 DrawCommand

GPU Instancing 让一批形状相同的物体共享一份几何,各自只提供变换、颜色这些实例属性,一次提交画完整批。 它减少的是 CPU 反复组织和提交绘制的开销,不减少 GPU 要处理的实例数量。

DrawCommand 是 Cesium 对一次绘制任务的描述,把几何缓冲、Shader、渲染状态和绘制数量组织在一起。给顶点属性配上 instanceDivisor、给命令填上 instanceCount,Instancing 就接进了 Cesium 的渲染流程;不带这些配置时,它描述的也是一次普通绘制。

这套北斗网格的做法是:一份中心在原点的单位立方体,每个格子存一份 64 字节矩阵和 16 字节颜色,按 Tile 分批,每个 Tile 最多两条 DrawCommand —— 一条画面,一条画棱。两条命令共用同一批实例缓冲,只有索引和着色器不同,所以只显示线框时不必重传任何实例数据。

GPU 仍要逐实例处理顶点。所以后面还需要 LOD、视锥剔除和活动预算,一起控制真正参与绘制的格子数量。

10 万个盒子,先忙坏的是 CPU

先把问题简化:所有盒子形状相同,全部要显示,只考虑一次普通的颜色绘制。

如果每个盒子都独立组织绘制,渲染器每一帧要遍历大量对象,处理变换、可见性和渲染状态,再逐个提交。每一步都很快,乘上 10 万也会变得明显。这里其实是两笔账:一笔是JavaScript 对象管理成本 —— 大量矩阵和状态要被创建、访问和回收;另一笔是渲染提交成本 —— CPU 要反复告诉图形系统用哪些数据、采用什么状态、画多少元素。

在没有额外合批、每个对象一个 pass 的简化假设下,10 万个独立 Mesh 对应约 10 万次绘制调用;按 60 帧算就是每秒 600 万次。这是用来理解规模的推算,不是实测值。Cesium 的 Primitive 可以包含多个 GeometryInstance,引擎也可能合批,所以 Primitive 数量和 Draw Call 数量没有固定的一一对应关系。要避免的是把每个格子都当成一个需要单独管理、单独提交的小任务。

把共同形状和每个格子的差异拆开

大量网格的共同点非常明显:都是盒子。

项目里准备的是一个中心位于原点、三个方向范围都是 -0.5 到 0.5 的单位立方体。它的宽、深、高都是 1,所有格子都从它变换而来。一个有 24 个带法线顶点的立方体,六个面、每面两个三角形,共 12 个三角形、36 个索引;线框另配 12 条边的索引,画的是棱,不会画出每个面的对角线。

网格尺寸不用逐格计算。同一层级下经纬度步长固定,所以同一 Tile 内所有格子的水平尺寸相同,取第一个格子的解码边界推一次就够了:

1
2
3
4
5
6
7
// gridWorkerUtils.ts — buildInstanceBuffers
const sampleBounds = grids[0].decodeRes;
const sampleSW = geodeticToEcef(sampleBounds.minLon, sampleBounds.minLat, 0);
const sampleSE = geodeticToEcef(sampleBounds.maxLon, sampleBounds.minLat, 0);
const sampleNW = geodeticToEcef(sampleBounds.minLon, sampleBounds.maxLat, 0);
const gridWidth = distance3(sampleSW, sampleSE); // 东西方向宽度
const gridLength = distance3(sampleSW, sampleNW); // 南北方向长度

注意这是拿「西边两个角点」和「西南到西北」量出来的 ECEF 直线距离,量出来的米数会随纬度变化,所以必须逐 Tile 算,不能全局存一个常数。

不过共享 Geometry 还不等于 Instancing。在 Three.js 里,10 万个 Mesh 引用同一个 BoxGeometry,几何确实只存了一份,但它们依然是 10 万个独立对象,绘制也不会因为几何相同就自动合成一次。还要再告诉 GPU:这份几何要按一组不同参数重复画 N 次。这一步才是 Instancing。

每个实例 80 字节

一个格子的差异可以整理成一条实例记录。项目里每个实例主要存一份变换矩阵和一份颜色;需要拾取时再加一份用于区分格子的拾取颜色。

实例数据 内容 每个实例占用
instanceMatrix 16 个 Float32,描述变换 64 字节
instanceColor RGBA,4 个 Float32 16 字节
pickColor 拾取用 RGBA,启用时提供 16 字节

基础实例属性合计 80 字节。按 10 万个实例算,矩阵和颜色约占 8 MB,加拾取颜色约 9.6 MB。这里用十进制 MB,只算这三样,不含编码字符串、业务对象和 CPU 副本。第一篇算过的那 5.68 GB,就是 7000 万个格子乘这 80 字节。

颜色是实例属性,所以同一批盒子可以各有各的颜色,不必因为红蓝不同就各建一份材质。当然它们仍要共享兼容的 Shader 和渲染状态 —— 一份颜色数组没法把完全不同的绘制管线塞进同一批。

矩阵里其实只有缩放和平移

实例矩阵看起来是个 4×4,实际用到的数只有四个:三个方向的尺寸,加一个位置。

项目给每个 Tile 选一个参考中心,块内格子的矩阵只存相对该中心的 ENU 局部坐标(东、北、上),不存 ECEF 平移 —— 这是第五篇要展开的 RTE。所以矩阵的对角线是格子尺寸,最后一列是局部偏移:

1
2
3
4
5
6
7
8
9
10
11
12
// gridWorkerUtils.ts — buildInstanceBuffers(逐格循环)
const worldCenter = geodeticToEcef(centerLon, centerLat, centerHeight);
const [tx, ty, tz] = worldToEnu(worldCenter, referenceEcef, basis);
const height = bounds.maxHeight - bounds.minHeight;

matrices[matrixOffset + 0] = gridWidth; // 东向尺寸
matrices[matrixOffset + 5] = gridLength; // 北向尺寸
matrices[matrixOffset + 10] = height; // 高度尺寸
matrices[matrixOffset + 12] = tx; // 相对参考中心的平移
matrices[matrixOffset + 13] = ty;
matrices[matrixOffset + 14] = tz;
matrices[matrixOffset + 15] = 1;

其余位置是 0。这是一份列主序矩阵:第 0、1、2 列构成缩放,第 3 列是平移。GLSL 里 mat4(c0, c1, c2, c3) 正好按列接收,所以着色器里拼回去时顺序原样对应。

顺带一个真实的阅读障碍:BeiDouGridPrimitive.ts 里这四个属性叫 modelMatrixRow0 到 modelMatrixRow3,名字里写的是 Row,但它们是列。按 mat4() 的语义传进去没问题,改代码时别被名字带偏。

一个 4×4 矩阵,为什么拆成四个 vec4

一个顶点属性位置通常承载最多四个分量,也就是一个 vec4;4×4 矩阵有 16 个数,所以占四个连续位置。即使在 Shader 里直接声明 mat4 输入,它也会占四个位置,并没有绕开这条规则。

项目的属性安排是:

属性位置 数据 divisor
0 顶点位置 position 0
1 顶点法线 normal 0
2~5 实例矩阵的四列 1
6 实例颜色 instanceColor 1
7 拾取颜色,启用时使用 1

每个 Float32 占 4 字节,一列四个数就是 16 字节。一份矩阵 64 字节,所以四列的起始偏移是 0、16、32、48 字节,相邻实例的同一列之间相隔 64 字节。这是真实的属性配置,抄自 BeiDouGridPrimitive._createVertexArray:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// BeiDouGridPrimitive.ts — _createVertexArray(节选第 2、3 列与颜色)
{
index: 2,
vertexBuffer: matrixBuffer,
componentsPerAttribute: 4,
componentDatatype: Cesium.ComponentDatatype.FLOAT,
normalize: false,
offsetInBytes: 0, // 第 0 列
strideInBytes: 64, // 下一个实例的同一列,往后 64 字节
instanceDivisor: 1,
},
{
index: 3,
vertexBuffer: matrixBuffer,
componentsPerAttribute: 4,
componentDatatype: Cesium.ComponentDatatype.FLOAT,
normalize: false,
offsetInBytes: 16, // 第 1 列
strideInBytes: 64,
instanceDivisor: 1,
},
// index 4 → offsetInBytes: 32,index 5 → offsetInBytes: 48,其余相同
{
index: 6,
vertexBuffer: colorBuffer,
componentsPerAttribute: 4,
componentDatatype: Cesium.ComponentDatatype.FLOAT,
normalize: false,
offsetInBytes: 0,
strideInBytes: 16, // 颜色是四列,跨度为 16
instanceDivisor: 1,
},

把 strideInBytes 理解成「读下一个实例的同一列要往后走多少字节」,就不容易和列偏移混淆。比如第 3 个实例的第 2 列,二者都从 0 编号,读取起点是 3 × 64 + 2 × 16 = 224 字节。

instanceDivisor 描述的是读取节奏

有了两类数据之后,GPU 读它们的节奏不同:顶点位置随顶点索引变化,而同一个盒子的矩阵和颜色,必须在处理这个盒子的所有顶点时保持不变。

instanceDivisor 就在描述这件事。顶点位置的 divisor 是 0,随当前顶点读;实例矩阵各列和颜色的 divisor 是 1,每换一个实例换一份数据。

画第 0 个盒子时,GPU 用第 0 份矩阵,依次处理单位立方体的各个顶点;画第 1 个盒子时,顶点仍来自那份公共几何,但矩阵和颜色切到第 1 份。如果把矩阵属性也设成 divisor 0,GPU 就会按顶点去取不同矩阵,同一个盒子的不同顶点可能被变换到完全不同的位置,结果自然是错的。

WebGL 2 里对应 vertexAttribDivisor,Cesium 的属性描述里就是 instanceDivisor;WebGL 1 需要实例化扩展提供相应能力。

Shader 里把四列拼回矩阵,再接 RTE 变换:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// BeiDouGridPrimitive.ts — 线框顶点着色器(节选)
in vec4 modelMatrixRow0; // 名字叫 Row,实际是第 0 列
in vec4 modelMatrixRow1;
in vec4 modelMatrixRow2;
in vec4 modelMatrixRow3;
in vec4 instanceColor;

void main() {
mat4 instanceMatrix = mat4(
modelMatrixRow0, modelMatrixRow1,
modelMatrixRow2, modelMatrixRow3
);

vec4 localPosition = instanceMatrix * vec4(position, 1.0);
vec3 rotatedPosition = u_referenceRotation * localPosition.xyz;
vec3 positionRelativeToEye = u_referenceRelativeToEye + rotatedPosition;
vec3 positionEC = czm_viewRotation * positionRelativeToEye;

gl_Position = czm_projection * vec4(positionEC, 1.0);
}

公共顶点先经过实例矩阵变成某个具体盒子,再乘参考旋转回到 ECEF 方向,加上 CPU 算好的「参考中心减相机」,最后只乘 czm_viewRotation。这里没有完整的 View 矩阵 —— 平移已经在 CPU 那边减掉了,再乘一次会把相机位置减两遍。这是第五篇的主题,这里先看清实例数据是怎么流进去的。

用 DrawCommand 提交给 Cesium

到这里,我们已经有了「画什么」和「每个实例怎么变」,还需要把它们组织成 Cesium 能执行的绘制描述。

可以把 DrawCommand 看成一张绘制任务单:

任务单上的内容 告诉渲染器什么
VertexArray 与索引缓冲 公共顶点、实例属性和三角形索引在哪里
ShaderProgram 顶点怎样变换,片元怎样着色
uniformMap 怎样取得这一批共享的相机和样式参数
RenderState 与 pass 深度测试、混合怎样设置,进入哪个绘制阶段
instanceCount 这份几何重复绘制多少个实例
boundingVolume 这一批数据占据哪一块空间

VertexArray 把属性配置和缓冲关联起来,ShaderProgram 把属性位置对应到 Shader 输入 —— 例如 modelMatrixRow0 对应位置 2,就是上面 attributeLocations 里写死的那份约定。两边一致,GPU 才能正确读取。

填充命令是 _createCommand 里造的,走半透明 pass:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// BeiDouGridPrimitive.ts — _createCommand(节选)
const renderState = Cesium.RenderState.fromCache({
cull: { enabled: true, face: Cesium.CullFace.BACK },
depthTest: { enabled: true },
depthMask: true,
blending: Cesium.BlendingState.ALPHA_BLEND,
});

this._drawCommand = new Cesium.DrawCommand({
vertexArray: vertexArray,
shaderProgram: shaderProgram,
uniformMap: uniformMap,
renderState: renderState,
instanceCount: instanceCount,
boundingVolume: this._boundingSphere,
pass: Cesium.Pass.TRANSLUCENT,
owner: this,
});

这里最容易混淆的是 count 和 instanceCount。填充命令没显式写 count,因为索引缓冲的长度就是它 —— 36 个索引描述一个单位立方体;instanceCount 是 8192,表示把这份索引描述的几何重复画 8192 次。公共索引缓冲只需要那 36 个索引,不必复制成 8192 份。

每一帧需要绘制时,命令进入 frameState.commandList,Cesium 随后安排执行。在 WebGL 2 的索引实例化路径里,核心调用可以理解为 gl.drawElementsInstanced(mode, 36, indexType, 0, 8192)。最后一个参数把原来由 JavaScript 逐对象安排的重复绘制交给了实例化机制。

一个 Tile 两条命令,共用一批实例缓冲

填充和线框是两份不同的命令。填充用三角形、半透明 pass;线框用 12 条边的索引、PrimitiveType.LINES、不透明 pass,还带一个线宽(会被钳到 maximumAliasedLineWidth 以内)。

关键在于 _createWireframeCommand(context, matrixBuffer, colorBuffer) 的入参 —— 它复用填充命令已经建好的那两个实例缓冲。线框自己新建的只有一份 24 个索引的边表和一个顶点数组:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// BeiDouGridPrimitive.ts — 线框索引(单位盒 12 条边)
const wireframeIndices = new Uint16Array([
0, 1, 1, 2, 2, 3, 3, 0, // 底面四条边
4, 5, 5, 6, 6, 7, 7, 4, // 顶面四条边
0, 4, 1, 5, 2, 6, 3, 7, // 四根立柱
]);

this._wireframeCommand = new Cesium.DrawCommand({
vertexArray: vertexArray,
shaderProgram: wireframeShader,
uniformMap: this._createWireframeUniformMap(),
renderState: renderState,
instanceCount: this._instanceCount,
boundingVolume: this._boundingSphere, // 同一个包围球
primitiveType: Cesium.PrimitiveType.LINES,
pass: Cesium.Pass.OPAQUE,
owner: this,
});

八行索引里藏着两件事。一是只画 12 条棱,不画面对角线 —— 如果用三角形索引去画线框,屏幕上会多出 12 条本不存在的斜线。二是线框和填充的 instanceCount、包围球完全一致,因为它们的实例数据本来就是同一批,只是拿不同的索引去解读。

线框着色器还带一个随距离淡出的 LOD:

1
2
3
4
5
6
7
8
// 远处线框变暗,避免叠加过曝
float brightnessFactor = mix(
1.2, 0.85,
smoothstep(u_visibleDistance * 0.4, u_visibleDistance, v_distanceToCamera)
);
v_color = u_wireframeColor.a > 0.0
? u_wireframeColor
: vec4(instanceColor.rgb * brightnessFactor, 1.0);

近处亮度是 1.2 倍(也就是把实例颜色提亮两成),从 u_visibleDistance 的四成距离开始,到 u_visibleDistance 处衰减到 0.85 倍。这是个纯粹的观感处理:密集边线在远景会糊成一片噪声,压暗一点比让它们叠出亮斑好看。视口侧传进来的 u_visibleDistance 是 10000。

熟悉 Three.js 的话,可以对照 InstancedMesh 理解

THREE.InstancedMesh 封装的也是同一套思想:一份 Geometry、一套 Material,配上多份实例矩阵和可选的实例颜色。setMatrixAt 在填实例矩阵,setColorAt 在填实例颜色,needsUpdate 通知 Three.js 把变化同步到 GPU。它把缓冲创建、实例属性配置和绘制调用都封装在内部,用起来更省心;Cesium 自定义 DrawCommand 更靠近渲染底层,需要自己组织这些资源,但也方便把局部坐标、RTE、拾取和 Tile 调度接到同一条数据链路上。

两者的共同点不在 API 名称,而在于:把重复的几何留一份,把差异变成按实例读取的属性,再批量提交。 同样的思路也能用在森林里的树、仓库里的货架、城市里的路灯上,前提是它们能共享几何和兼容的渲染状态。

为什么还要按 Tile 分批

知道 Instancing 之后很容易继续想:既然能批量画,干脆把整片区域的几百万个格子塞进一个集合。

真正的大规模场景还需要控制加载、剔除和回收的粒度。一个集合太大,这几个问题会一起出现:相机只看见区域一角,整个集合的包围体却仍与视锥相交;一次生成和上传太多实例,形成明显的加载峰值;远处要粗网格、近处要细网格,很难整批同步切换;某一小块不再需要时,没法只回收那部分资源。

所以项目把区域切成可以独立调度的 Tile,每个 Tile 拥有自己的实例数组和包围体,公共单位立方体仍然共享。一个 L7 切片的名义规模是 16×16×32 = 8192 个实例,这是「一块」的数量级;区域边界和裁剪会改变实际数量。

这也说明两个优化各自负责什么:Instancing 决定一块内部怎样高效绘制,Tile 决定哪些数据作为一块被加载、提交和回收。 按 Tile 分批之后,一块的边界怎样定就成了新问题 —— 第四篇讲 L7 怎么在水平和高度两个方向切,第五篇讲 L6 怎么按工作量反推。

Instancing 不减少实例数

实例化减少了重复提交,GPU 仍然要处理每一个实例。

按一个盒子 24 个带法线顶点估算,10 万个盒子仍有约 240 万个实例顶点要参与变换,填充绘制约 120 万个三角形,线框绘制约 120 万条边。这些是规模推算,不会因为命令合并就消失。当很多盒子投影到相同屏幕位置时,片元压力也会变大 —— 透明面尤其如此,多个表面会反复覆盖同一像素还要参与混合;线框省掉了面填充,却可能因为大量边线重叠而变贵。

所以 Instancing 之后仍然卡,接下来应该沿着剩余工作继续减:

后续手段 减少的工作
LOD 远处用粗网格,减少没有必要的细节
视锥剔除 跳过当前视野外的空间块
活动预算 限制同时参与渲染的实例总量
相机移动时降级 减少短暂视角触发的精细数据生成
Worker 与缓存 减轻主线程计算,减少重复生成与上传

实例数据更新也有成本。这套网格以静态空间结构为主,例子里的实例缓冲用 STATIC_DRAW 一次上传、多帧复用;如果业务变成每帧改大量颜色和矩阵,仍要承担大量数据更新,那就得另外设计局部缓冲更新,而不是只加一个 Instancing 开关。

实际排查时可以沿固定相机路线,分开观察主线程帧耗时、绘制命令、上传峰值和 GPU 压力。若提交已经很少,但降低分辨率或减少透明面后明显变快,就该继续查像素处理的成本;若卡顿集中在新块出现的时刻,则要查生成、上传和激活的节奏。


上一篇:总体架构 · 下一篇:LOD 与视锥剔除 · 系列分类

北斗网格性能优化(二):GPU Instancing 与 DrawCommand

https://wywzixin.github.io/beidou-grid-performance/02-instancing-drawcommand/

作者

wangyawei

发布于

2026-09-12

更新于

2026-09-15

许可协议