Cesium 贴地渲染的三个坑:地面图元、像素线宽与三维瓦片
想让道路贴住地形,结果连续踩了三个坑:地面图元只接受一种外观、折线宽度单位是屏幕像素而不是米、三维瓦片的高度参考对烘焙模型根本无效。这篇把每个坑的现象、原因和绕法都写清楚。
背景
在三维地图上画一条「贴着地面」的路,听起来是把折线的高度设成地形高程就行。实际上地形是自己的几何、道路是另一个图元,两者要贴在一起,得靠 Cesium 的地面图元(ground primitive)——它会在渲染时与地形做深度混合,从而视觉上「画在地面上」。
这个机制本身不难用。难的是它的几个限制不太符合直觉,而且报错信息往往指向错误的方向。
下面三个坑按踩到的顺序排列。
坑一:GroundPrimitive 只接受一种外观
第一个反应是:道路要有虚线中央标线,那就用材质做一个自定义外观(appearance)。
// ❌ 这样写跑不起来
new Cesium.GroundPrimitive({
geometryInstances: new Cesium.GeometryInstance({
geometry: new Cesium.CorridorGeometry({ /* ... */ }),
}),
appearance: new Cesium.MaterialAppearance({
material: Cesium.Material.fromType('Stripe'), // 想要斜纹/虚线材质
}),
});
结果是面没有按预期渲染。原因不复杂:GroundPrimitive 只支持 PerInstanceColorAppearance。它需要在片元着色器里与地形做特殊混合,能支持的外观类型被限制了。
绕法是把「面」和「线」拆开,各自用对的图元:
// ✅ 面:用 PerInstanceColorAppearance
const roadSurface = new Cesium.GroundPrimitive({
geometryInstances: new Cesium.GeometryInstance({
geometry: new Cesium.CorridorGeometry({
positions: centerline,
width: 11, // 单位是米
}),
attributes: {
color: Cesium.ColorGeometryInstanceAttribute.fromColor(
Cesium.Color.fromCssColorString('#2f3438').withAlpha(0.92),
),
},
}),
appearance: new Cesium.PerInstanceColorAppearance({ flat: true }),
classificationType: Cesium.ClassificationType.TERRAIN,
});
// ✅ 线:虚线必须走 GroundPolylinePrimitive,它支持 PolylineMaterial
const centerDash = new Cesium.GroundPolylinePrimitive({
geometryInstances: new Cesium.GeometryInstance({
geometry: new Cesium.GroundPolylineGeometry({
positions: centerline,
width: 2, // ⚠ 注意单位,见坑二
}),
attributes: {
color: Cesium.ColorGeometryInstanceAttribute.fromColor(Cesium.Color.WHITE),
},
}),
appearance: new Cesium.PolylineColorAppearance(),
classificationType: Cesium.ClassificationType.TERRAIN,
});
一句话记法:地面上的面 → GroundPrimitive(颜色外观);地面上的线 → GroundPolylinePrimitive(可用虚线与发光材质)。
坑二:GroundPolylineGeometry.width 是屏幕像素
这个坑最反直觉。
普通折线几何 PolylineGeometry 的 width 是屏幕像素;地面折线 GroundPolylineGeometry 的 width 也是屏幕像素,而不是米。
后果是:相机拉近拉远,路的边线粗细毫无变化。做俯视大屏时看着还行,一旦切到第一人称低空视角就露馅 —— 十几米宽的路面配一条永远 2 像素宽的边线,比例完全不对。
想让「线宽」随距离透视变化,只有两条路:
- 用面代替线:把标线做成窄的
CorridorGeometry(width单位是米),走GroundPrimitive。代价是每条标线都是一个独立几何,数量多了开销上升。 - 接受屏幕像素语义,只在视觉上要求「恒定清晰」的场景使用(比如边线、网格线)。
我在设计里做了混合:路面与中央虚线的「物理宽度」用面表达(都走 CorridorGeometry),只有必须恒定可见的细边线用 GroundPolyline。
坑三:三维瓦片的高度参考对烘焙楼块无效
第三个坑更隐蔽。把三维建筑物瓦片(3D Tiles)叠到场景里,楼块整体悬浮在地表之上几米到几十米。
第一反应是设置高度参考:
// ❌ 对「烘焙过的」楼块瓦片无效
tileset.heightReference = Cesium.HeightReference.CLAMP_TO_GROUND;
没变化。原因是 Cesium3DTileset.heightReference 只对矢量瓦片(containing 高程属性、可以按需计算贴合)生效。如果瓦片是烘焙好的几何(顶点里已经写死了绝对高程),引擎没有可用的语义信息,这个设置自然不起作用。
而这里的楼块又是按真实地形高程烘焙的 —— 场景里却只有一个光滑椭球(没开地形),于是整层楼块以椭球面为基准整体抬升。
绕法是「自己把高度差补上」:
// 1) 在锚点处采样一次地形高程(此时并不需要把 terrain 挂到 globe 上)
const anchor = Cesium.Cartographic.fromCartesian(anchorCartesian);
const sampled = await Cesium.sampleTerrainMostDetailed(terrainProvider, [anchor]);
const height = sampled[0].height ?? 0;
// 2) 让模型矩阵沿锚点法线方向整体平移 -height
function buildOffsetMatrix(anchorCartesian: Cesium.Cartesian3, offsetMeters: number) {
const up = Cesium.Cartesian3.normalize(anchorCartesian, new Cesium.Cartesian3());
const translation = Cesium.Cartesian3.multiplyByScalar(up, offsetMeters, new Cesium.Cartesian3());
return Cesium.Matrix4.fromTranslation(translation);
}
这个做法还有个额外好处:它是个纯平移,不用碰旋转与缩放,因此不会引入模型朝向的副作用。
顺带一个时序竞态
tileset.modelMatrix 如果在瓦片加载之后再赋值,会偶发不生效 —— 内部根节点的变换已经被算过一次,后续赋值不一定被重新应用。
可靠的做法是在构造时就传入:
const tileset = await Cesium.Cesium3DTileset.fromIonAssetId(96188, {
modelMatrix: buildOffsetMatrix(anchor, -height), // 构造选项,不是事后赋值
});
另外顺手修了一个显存泄漏:三维瓦片的卸载只调用 remove() 是不够的 —— 它只解除引用,不回收 GPU 资源。反复开关会持续累积:
// ❌ ts.remove() 只解引用
// ✅
ts.destroy();
小结
| 现象 | 原因 | 绕法 |
|---|---|---|
| 地面面片用了自定义材质不渲染 | GroundPrimitive 只支持 PerInstanceColorAppearance | 面走 GroundPrimitive,虚线走 GroundPolylinePrimitive |
| 路宽不随距离变化 | GroundPolylineGeometry.width 单位是屏幕像素 | 需要物理宽度的用 CorridorGeometry 面表达 |
| 楼块整体悬浮 | heightReference 只对矢量瓦片有效,烘焙瓦片无高程语义 | 采样地形高程 → modelMatrix 沿锚点法线纯平移 |
| 偏移偶发生效/不生效 | modelMatrix 事后赋值与内部根节点更新存在竞态 | 改为 fromIonAssetId 的构造选项传入 |
| 反复开关后显存上涨 | remove() 不回收 GPU 资源 | 显式 destroy() |
真正的教训不是这三条限制本身,而是:这类问题的报错往往不指向根因。 楼块悬浮看起来像「地形没开」,实际是「瓦片的高度语义与场景基准不一致」;面片不渲染看起来像「材质写错了」,实际是「外观类型不在支持列表里」。定位这类问题,比读 API 文档更有效的方法是:先构造一个最小可复现场景,逐个变量排除 —— 上面每条结论都是这么来的。