跳到正文

Cesium 贴地渲染的三个坑:地面图元、像素线宽与三维瓦片

· 约 4 分钟

想让道路贴住地形,结果连续踩了三个坑:地面图元只接受一种外观、折线宽度单位是屏幕像素而不是米、三维瓦片的高度参考对烘焙模型根本无效。这篇把每个坑的现象、原因和绕法都写清楚。

背景

在三维地图上画一条「贴着地面」的路,听起来是把折线的高度设成地形高程就行。实际上地形是自己的几何、道路是另一个图元,两者要贴在一起,得靠 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 像素宽的边线,比例完全不对。

想让「线宽」随距离透视变化,只有两条路:

  1. 用面代替线:把标线做成窄的 CorridorGeometry(width 单位是米),走 GroundPrimitive。代价是每条标线都是一个独立几何,数量多了开销上升。
  2. 接受屏幕像素语义,只在视觉上要求「恒定清晰」的场景使用(比如边线、网格线)。

我在设计里做了混合:路面与中央虚线的「物理宽度」用面表达(都走 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 文档更有效的方法是:先构造一个最小可复现场景,逐个变量排除 —— 上面每条结论都是这么来的。

这篇文章对应的作品