Cesium 5000 目标运动渲染优化:从 7 FPS 到 GPU 实例化
环境:Cesium 1.146 + Vue3 + Vite。场景:
/motion页,5000 个飞机模型以 1Hz WS 推送做运动插值,要求稳态 ≥ 40 FPS。
一、问题
同一份数据、同样的相机视域下:
- 逐帧赋值(assign):每个 WS 包直接给实体设新位置 —— 约 7 FPS
- 插值管线(interp):
SampledPositionProperty+ 时钟插值 +requestRenderMode—— 约 7 FPS
两种写法都掉到 7 FPS(≈143ms/帧,折合 28μs/实体/帧)。这个量级基本可以断定:瓶颈不在 GPU、不在模型面数(plane.glb 才 9KB),而在主线程的 Entity 属性链。

二、瓶颈分析
Cesium 的 Entity 是"声明式"的:每帧要为每个实体
- 评估
position(SampledPositionProperty:二分查找样本 + 线性插值) - 评估
orientation(VelocityOrientationProperty:两次采样插值 + ENU 参考系 4×4 矩阵 + 四元数) ModelVisualizer据脏属性更新每个模型实例矩阵、重算包围体- 视锥裁剪 + 命令排序 + 提交
关键点:assign 模式并不用 VOP,只是每帧 lerp/slerp 后 setValue,却和 interp 一样 7 FPS。说明共享成本(5000 个脏属性 → ModelVisualizer 逐实体更新 + 命令提交)才是主因,属性求值本身只是次要部分。
于是优化分两条路:小步调优 Entity 管线(P1),以及釜底抽薪绕开 Entity(P2)。
三、P1:Entity 管线调优(预期 7 → 15~25)
针对 interpolated.ts:
- 朝向与位置解耦:朝向是变化缓慢的量,没必要逐帧求值。把
CallbackProperty(VOP×轴修正)改成ConstantProperty,在clock.onTick里以 5Hz 节流求值写回;位置仍走时钟逐帧插值。 - 采样裁剪 O(1) 化:原来的"每 16 包对全部实体
removeSamples"是周期性尖峰,改为按包时间戳滚动队列,每秒只移除一个最旧样本。 - 删掉
silhouetteColor:alpha=0 也可能保留轮廓材质分支。
这些改动把每帧固定开销砍了一大块,但要 5000 档稳定 40 FPS 仍不保险。
四、P2:GPU 实例化管线(目标 40+)
核心思想一句话:把"5000 个对象的 CPU 管理成本"压成"5000 个实例的数据填充成本",GPU 端单条 draw call。
4.1 硬件实例化:VertexArray + DrawCommand
1.146 没有公开的"多实例 Model" API(ModelInstances 那套早被移除,Model 只有单个 modelMatrix)。但引擎内部的 splat/tileset 管线用的底层机制是可用的运行时 API:
VertexArray的属性支持instanceDivisor(逐实例属性)DrawCommand支持instanceCount(一次画 N 份)
于是:一份网格(plane 168 顶点)+ 每实例属性(偏移高低位、朝向四元数、颜色),instanceCount = 5000,一条命令画完。网格几何从 plane.mesh.json 加载(由 gen-models.mjs 顺带导出)。
vertexArray = new Cesium.VertexArray({
context,
attributes: [
{ vertexBuffer: posBuffer, componentsPerAttribute: 3 }, // 逐顶点
{ vertexBuffer: nrmBuffer, componentsPerAttribute: 3 },
{ vertexBuffer: bufHigh, componentsPerAttribute: 3, instanceDivisor: 1 }, // 逐实例
{ vertexBuffer: bufLow, componentsPerAttribute: 3, instanceDivisor: 1 },
{ vertexBuffer: bufOri, componentsPerAttribute: 4, instanceDivisor: 1 },
{ vertexBuffer: bufColor, componentsPerAttribute: 3, instanceDivisor: 1 }
],
indexBuffer
})
4.2 数据用扁平 TypedArray,逐帧零分配
位置/朝向不再走属性系统,而是维护在 Float32Array/Float64Array 里。每帧一个循环直写数组,copyFromArrayView 上传(约 200KB/帧)。中间量全部复用 scratch 对象,主循环不 new,无 GC 抖动。
位置解算自实现:每目标一个 30 槽环形缓冲存 ECEF double 样本,按当前时钟线性插值、末段速度外推 —— 等价 SampledPositionProperty 但绕开了属性派发。
朝向逐帧直接构造:由前后采样位置得 forward,椭球法向(球近似)得 up,正交化后组右手基 → Quaternion.fromRotationMatrix。纯向量运算,5000 次 < 1ms。
4.3 远距离精度:GPT 高低位拆分
ECEF 坐标量级 6.4e6,单 float32 分辨率约 0.5m,会抖。做法:CPU 端把位置拆成 high/low 两个 float32,顶点级模型偏移进 low,着色器用 czm_translateRelativeToEye 合并 —— 与 Cesium 内部一致,无抖动。
4.4 minimumPixelSize 搬到 GPU
远小目标需放大到最小像素尺寸。CPU 端逐模型算太贵,直接在顶点着色器按投影像素自适应放大:
float dist = max(1.0, length(rel.xyz));
float pxPerMeter = 0.5 * czm_viewport.w * czm_projection[1][1] / dist;
float minScale = u_minPixelSize / max(1e-3, 2.0 * u_modelRadius * u_scale * pxPerMeter);
float k = max(1.0, minScale);
vec4 p = czm_translateRelativeToEye(aOffsetHigh, aOffsetLow + rotated * k);
gl_Position = czm_modelViewProjectionRelativeToEye * p;
4.5 挂进渲染循环
不注册 Entity,而是往 scene.primitives 塞一个自定义 primitive,update(frameState) 里把命令 push 进 commandList:
const primitive = {
show: true, async: false,
isDestroyed: () => cleared,
destroy: () => true,
update(frameState) { if (ready && primitive.show) frameState.commandList.push(command) }
}
scene.primitives.add(primitive)

五、踩坑实录
底层 API + 手写 GLSL 的组合,坑一个接一个,且大多无法靠类型检查发现,只能逐个用运行时核验 + 浏览器观察定位。
坑1:new Cesium.ShaderProgram({context, vertexShaderSource}) 直接抛错
1.146 里公开的 ShaderProgram 构造函数签名已改为内部形态(读 gl + vertexShaderText),传 context + ShaderSource 会在构造期 undefined.match 抛错。
正确姿势:用 ShaderProgram.fromCache({context, vertexShaderSource, fragmentShaderSource, attributeLocations})。
排查技巧:把初始化拆成分阶段 stage 标记 + 打印 e.stack,一眼看出挂在哪步。
坑2:飞机"巨大无比"铺满屏幕
czm_viewport 是 vec4(x, y, width, height),.y 是视口原点(0)、.w 才是高度。我误用了 .y:
pxPerMeter = 0.5 * czm_viewport.y * ... ≈ 0 → minScale = 26/1e-3 ≈ 26000 → 模型被放大 2.6 万倍。
改成 czm_viewport.w 即恢复。
坑3:飞机"一闪一闪"
Cesium 3D 的 globe/地形用对数深度写 gl_FragDepth,而裸片元着色器只写线性深度。两套深度空间比较错乱 → 飞机相对地面忽前忽后。
正解是在 shader 里集成 czm_vertexLogDepth/czm_writeLogDepth,但手写对数深度 GLSL 依赖多个 czm_ 内置且难在无浏览器环境验证。考虑到这是航空目标跟踪场景(本就要求目标恒可见、不被地形遮挡),最终选择关闭深度测试与写入(depthTest.enabled=false, depthMask=false),按图标叠加渲染 —— 既确定消除闪烁,又符合业务预期。
坑4:模型发暗
半球光照系数 0.35 + 0.65*diff,法向背向光源时接近 0.35 → 发暗。改基色纯白 + 0.82 + 0.18*diff,保留轻微立体明暗。
坑5:切回 assign/interp 画面空白
buildTargets() 里 targets?.clear() 在创建新模式实体之前执行。GPU 的 clear() 一旦抛异常,整个函数中断,新模式实体根本没建 → 空白。
给 clear() 包 try/catch 并让 GPU 清理逐步容错:清理失败也只记日志、不阻断重建。
坑6(最隐蔽):GPU 轨迹与其它模式不一致
我把时钟 JulianDate 转 Unix 秒时用了"教科书"常数 2440587.5:
(totalDays(currentTime) - 2440587.5) * 86400
但 Cesium 的 JulianDate.fromDate(new Date(0)) 实际对应 totalDays = 2440587.5001157406,差 0.0001157406 天 = 整整 10 秒。于是 GPU 的插值时刻比样本时间戳超前 10s,按 15–25 km/s 的目标速度,飞机沿轨迹超前约 150–250km。
修复:改用 toJulian 的精确逆运算 JulianDate.toDate(clock.currentTime).getTime()/1000,保证与样本同一时间基。
六、验证方法
无需 WebGL 的纯数学验证:SampledPositionProperty 是纯计算,可在 Node 里 headless 跑。把同一批样本喂给 Cesium 属性和自实现的环插值,逐时刻比对:
dt=0s err=0.0000m
dt=1.5s err=0.0000m
dt=3s err=0.0000m // 含外推段
时间基修正前,同样的比对暴露出上万米的偏差 —— 这也是定位坑6的关键手段。
浏览器实测:/motion 页自带 PerfPanel + capturePerf(预热+稳态分位数)。GPU 实例化模式在 5000 档 FPS 明显提升;assign/interp 受 5000 实体逐帧属性开销限制仍偏低。
另外要说明:assign(跳变/补间)与 GPU/interp 的轨迹本就不同——assign 不做时钟外推,只追最新点,这是三种模式的既定语义差异,不是 bug。
七、小结
| 手段 | 作用 |
|---|---|
| 朝向 5Hz 节流 + 位置逐帧插值解耦 | 砍掉每帧 VOP/CallbackProperty 求值 |
| 采样滚动裁剪 | 消除周期性 removeSamples 尖峰 |
| VertexArray.instanceDivisor + DrawCommand.instanceCount | 5000 目标单条 draw call |
| 扁平 TypedArray + 复用 scratch | 主循环零分配、无 GC 抖动 |
| 自实现环插值/外推 | 绕开属性系统 |
| GPT 高低位拆分 | 远距离无抖动 |
| minimumPixelSize 搬到顶点着色器 | 免除逐模型 CPU 缩放 |
一句话:Entity 适合"少量、动态、需交互"的对象;当规模上到数千且逻辑同质时,下沉到实例化绘制 + 数据驱动,把每帧成本从 O(N × 属性链) 降到 O(N × 几十次浮点) + 几次小缓冲上传。
而底层 API + 手写 GLSL 的坑,类型检查基本帮不上忙,靠的是运行时核验(Node 里探 API 存在性/行为)+ 分阶段日志 + headless 数学比对 + 浏览器观察这套组合拳。
487



被折叠的 条评论
为什么被折叠?



