1. 为什么垂直图片资源在三维场景里是个麻烦事
做过三维地理场景开发的人多半遇到过这种需求:在三维球面上贴一张竖直方向的图片,比如广告牌、建筑立面装饰、路牌、竖幅标语,或者某种垂直于地面的标识。听起来简单,但真上手做的时候,很多人第一反应是用billboard或者entity的plane去挂图片,结果发现要么图片永远朝着相机(billboard 的特性),要么贴上去之后方向不对、比例失调、被地形遮挡,甚至在某些视角下直接消失。
Cesium这个三维地球引擎里,添加图片资源的方式有好几种,最常用的是EntityAPI 和PrimitiveAPI。Entity上手快、封装好,适合快速搭建原型;但一旦涉及大量图片、需要精细控制渲染状态、或者对性能有要求的时候,Entity的抽象层反而成了负担。Primitive更底层,直接操作几何体和外观(Appearance),性能更好,控制粒度更细,但代价是——你得自己算顶点、自己构造几何体、自己处理朝向。
垂直图片资源的本质是什么?它是一张有纹理的矩形面片,这个面片垂直于地面(或者说垂直于椭球体表面),并且有确定的朝向(比如朝东、朝南,或者沿着某条道路方向)。在Cesium里,能承载纹理的几何体类型不少,Rectangle、Polygon、Wall、Box、Plane都可以,但真正适合"垂直贴图"这个场景的,主要是Wall和自定义的Plane几何体。选哪个、怎么选,取决于你的图片是要贴在一个固定位置的单面,还是要沿着一条线延伸,以及是否需要考虑地形起伏。
这篇文章会从实际项目出发,把"用Primitive添加垂直图片资源"这件事拆开讲透。包括几何体选型的逻辑、坐标计算的细节、纹理朝向的坑、性能优化的取舍,以及我在真实项目里踩过的那些坑。不管你是刚接触Cesium的新手,还是已经用过Entity想往底层走的老手,应该都能从中找到能直接抄作业的东西。
2. 几何体选型:Wall、Plane 还是自定义 Geometry
2.1 Wall 几何体的适用边界
Cesium的WallGeometry是专门用来做"墙"的,它接受一组底部点和一组顶部点(或者通过高度数组自动生成顶部点),然后生成一个竖直的带状面。这个特性天然适合垂直图片——因为墙本身就是垂直的,纹理贴上去之后自然就是竖直方向。
用WallGeometry贴图的基本思路是:给定一条线段(两个端点),指定底部高度和顶部高度,生成一个矩形墙面,然后把图片作为材质贴上去。代码大概长这样:
const wall = new Cesium.WallGeometry({ positions: Cesium.Cartesian3.fromDegreesArray([ 116.39, 39.91, 116.40, 39.91 ]), minimumHeights: [0, 0], maximumHeights: [50, 50], vertexFormat: Cesium.VertexFormat.POSITION_AND_STUV }); const geometry = Cesium.WallGeometry.createGeometry(wall); const instance = new Cesium.GeometryInstance({ geometry: geometry, attributes: { color: Cesium.ColorGeometryInstanceAttribute.fromColor(Cesium.Color.WHITE) } }); const primitive = new Cesium.Primitive({ geometryInstances: instance, appearance: new Cesium.MaterialAppearance({ material: new Cesium.Material({ fabric: { type: 'Image', uniforms: { image: 'path/to/vertical-image.png' } } }) }) }); viewer.scene.primitives.add(primitive);这段代码能跑,但有几个关键点需要注意。第一,vertexFormat必须包含ST(纹理坐标),否则材质拿不到 UV,图片贴不上去。POSITION_AND_STUV里的STUV是给纹理坐标和法线用的,如果不需要法线可以只用POSITION_AND_ST。第二,WallGeometry的纹理坐标默认是沿着墙的长度方向铺 0 到 1,高度方向也是 0 到 1,所以如果图片宽高比和墙的实际宽高比不一致,图片会被拉伸。第三,墙的正面和背面朝向取决于顶点的顺序,逆时针看过去是正面,顺时针是背面,如果图片贴反了,把两个端点的顺序调换一下就行。
WallGeometry的局限也很明显:它只能做直墙,不能做弧形,而且如果墙很长,纹理会被拉伸得很厉害,需要手动设置repeat或者用自定义着色器。另外,墙的底部高度如果是 0,而地面有地形起伏,墙可能会被地形"吞掉"一部分,需要把底部高度设得比地形略低,或者开启深度测试的偏移。
2.2 Plane 几何体的灵活性与代价
如果你要贴的不是一条线,而是一个独立的竖直面片(比如一个广告牌),PlaneGeometry更合适。PlaneGeometry生成的是一个在局部坐标系里的矩形平面,默认在 XY 平面上,法线朝 Z 轴。要把它竖起来,需要做两步:先把平面绕 X 轴旋转 90 度,让它从水平变成竖直;然后再根据目标位置的经纬度和朝向,构造一个变换矩阵,把平面放到正确的位置和方向上。
这个过程的数学细节比较多,但逻辑是清晰的。假设你要在某个经纬度位置放一个朝南的竖直图片,高度 20 米,宽度 10 米,底部贴地。步骤是:
- 用
Cesium.Cartesian3.fromDegrees(lon, lat, height)算出目标点的空间直角坐标。 - 用
Cesium.Transforms.eastNorthUpToFixedFrame构造一个以该点为原点的局部坐标系,东为 X,北为 Y,上为 Z。 - 在这个局部坐标系里,竖直面片应该位于 XZ 平面(因为要朝南,法线朝南,也就是 -Y 方向),所以平面需要绕 X 轴旋转 90 度,让原来的 XY 平面变成 XZ 平面。
- 把平面平移到目标位置,注意平面的中心默认在几何中心,如果底部贴地,需要向上平移高度的一半。
代码实现:
const center = Cesium.Cartesian3.fromDegrees(116.39, 39.91, 10); const transform = Cesium.Transforms.eastNorthUpToFixedFrame(center); const plane = new Cesium.PlaneGeometry({ vertexFormat: Cesium.VertexFormat.POSITION_AND_ST, width: 10, height: 20 }); const geometry = Cesium.PlaneGeometry.createGeometry(plane); const modelMatrix = Cesium.Matrix4.multiply( transform, Cesium.Matrix4.fromRotationTranslation( Cesium.Matrix3.fromRotationX(Cesium.Math.toRadians(90)), new Cesium.Cartesian3(0, 0, 0) ), new Cesium.Matrix4() ); const instance = new Cesium.GeometryInstance({ geometry: geometry, modelMatrix: modelMatrix, attributes: { color: Cesium.ColorGeometryInstanceAttribute.fromColor(Cesium.Color.WHITE) } });这里有个容易搞混的地方:PlaneGeometry的width和height是在局部坐标系里的尺寸,旋转之后,原来的height方向变成了 Z 轴方向(竖直方向),原来的width方向还是 X 轴方向(水平方向)。所以如果你要一个宽 10 米、高 20 米的竖直面片,width设 10,height设 20,旋转之后就是对的。但如果你先旋转再设尺寸,就会乱掉。
PlaneGeometry的好处是灵活,可以任意旋转、任意位置,不限于直线。缺点是每个面片都是一个独立的GeometryInstance,如果数量多了,draw call 会很多,性能下降明显。而且PlaneGeometry默认是双面的,如果不需要背面渲染,可以设置MaterialAppearance的faceForward为true,减少一半的渲染量。
2.3 自定义 Geometry 的极端场景
有些场景下,Wall和Plane都不够用。比如你要贴的图片不是矩形,而是带透明通道的不规则形状;或者你要沿着一条曲线贴一排图片,每张图片的朝向都要根据曲线切线动态调整;又或者你需要图片随着相机距离动态切换 LOD。这些情况下,就得自己构造Geometry,手动计算顶点、法线、纹理坐标。
自定义Geometry的核心是构造Cesium.Geometry对象,它需要几个关键属性:attributes(顶点属性,包括位置、法线、纹理坐标等)、indices(索引数组,决定三角形如何连接)、primitiveType(图元类型,通常是TRIANGLES)、boundingSphere(包围球,用于视锥剔除)。手动构造这些数据需要对三维图形学有一定了解,但一旦掌握,就能实现任何你想要的形状和贴图效果。
我在一个项目里做过沿曲线排列的竖直图片墙,用的是自定义Geometry。思路是:先把曲线离散成一系列点,对每个点计算切线方向和法线方向,然后以每个点为中心生成一个竖直的矩形面片,把所有面片的顶点合并到一个Geometry里,最后用一个Primitive渲染。这样做的好处是只有一个 draw call,性能很好;坏处是代码复杂,调试困难,而且如果曲线很长、点很多,顶点数会爆炸,需要做裁剪和 LOD。
3. 纹理坐标与朝向:图片为什么总是贴反或拉伸
3.1 UV 坐标的默认行为与手动修正
Cesium的几何体在生成纹理坐标时,有一套默认规则。WallGeometry的 U 方向沿着墙的长度,从第一个点到最后一个点,V 方向从底部到顶部。PlaneGeometry的 U 方向沿着宽度,V 方向沿着高度。这些默认规则在大多数情况下是合理的,但一旦涉及到旋转、镜像、或者非标准朝向,就会出问题。
最常见的问题是图片贴反。比如你希望图片的顶部朝上,但贴上去之后发现图片倒过来了。这是因为PlaneGeometry在旋转 90 度之后,原来的 V 方向(高度方向)可能变成了 -Z 方向,导致纹理上下颠倒。解决办法有两种:一是旋转的时候用 -90 度而不是 90 度;二是在材质里设置image的repeat为new Cesium.Cartesian2(1, -1),把 V 方向翻转。第二种方法更通用,因为它不依赖于几何体的旋转方式。
另一个常见问题是图片拉伸。如果图片的宽高比是 16:9,而墙面的实际宽高比是 4:3,图片就会被压扁或拉长。解决办法是调整几何体的尺寸,让宽高比匹配图片,或者在材质里设置repeat,让图片平铺。平铺的时候要注意,如果图片边缘不是无缝的,平铺之后会有明显的接缝,这时候需要用mirroredRepeat或者手动处理边缘像素。
提示:
Cesium的Material支持repeat和offset两个 uniform,分别控制纹理的重复次数和偏移量。这两个参数在fabric里设置,类型是Cartesian2。
3.2 朝向计算:让图片面朝正确的方向
垂直图片的朝向由几何体的法线决定。WallGeometry的法线垂直于墙面,方向取决于顶点的顺序。PlaneGeometry的法线默认朝 Z 轴正方向,旋转之后跟着变。如果你希望图片始终面向相机(类似 billboard 的效果),那就不能用固定的几何体,而需要在每一帧更新几何体的旋转矩阵,或者用自定义着色器实现 billboard 效果。
固定朝向的场景更常见。比如一个朝南的广告牌,法线应该朝南(-Y 方向,在 ENU 坐标系里)。用PlaneGeometry的时候,默认法线朝 Z(上),要让它朝南,需要绕 X 轴旋转 -90 度(或者 90 度,取决于坐标系的手性)。这里有个简单的判断方法:旋转之后,如果图片的正面朝向你想要的方向,那就对了;如果朝反了,把旋转角度取反,或者把MaterialAppearance的faceForward设为false,让背面也渲染。
对于WallGeometry,朝向由端点的顺序决定。假设你有一条从西到东的线段,端点顺序是 [西, 东],那么墙的法线朝北;如果顺序是 [东, 西],法线朝南。这个规则在Cesium的文档里没有明确写,但实测下来是这样的。如果你不确定,可以先把墙渲染出来,从不同角度看一眼,如果图片在背面看不到,就调换端点顺序。
3.3 透明图片的处理
垂直图片资源里,带透明通道的 PNG 很常见,比如镂空的 logo、带阴影的标识牌。Cesium的Material默认支持透明,但需要设置transparent: true,并且注意渲染顺序。如果透明图片和不透明几何体混在一起,可能会出现深度测试的问题——透明部分挡住了后面的东西,或者后面的东西穿透了透明部分。
解决办法是调整Primitive的renderOrder,让透明图片在不透明几何体之后渲染。另外,MaterialAppearance的translucent属性要设为true,这样Cesium会把它归到透明渲染队列里。如果透明图片很多,还要注意排序问题,Cesium默认按距离排序,但有时候距离相近的透明面片会出现闪烁,这时候需要手动设置renderOrder或者用depthTestAgainstTerrain来控制。
4. 性能优化:从几十张到几千张图片的跨越
4.1 合并 GeometryInstance 减少 Draw Call
用Primitive添加图片资源,最大的性能瓶颈是 draw call。每多一个GeometryInstance,就多一次 draw call,几十张图片还好,几千张就会卡顿。解决办法是把多个GeometryInstance合并到一个Primitive里,Cesium会自动做批处理(batching),把相同材质的几何体合并成一个 draw call。
合并的方式很简单:创建一个Primitive,把多个GeometryInstance放到一个数组里传进去:
const instances = []; for (let i = 0; i < positions.length; i++) { const instance = new Cesium.GeometryInstance({ geometry: createPlaneGeometry(positions[i]), modelMatrix: createModelMatrix(positions[i]), attributes: { color: Cesium.ColorGeometryInstanceAttribute.fromColor(Cesium.Color.WHITE) } }); instances.push(instance); } const primitive = new Cesium.Primitive({ geometryInstances: instances, appearance: new Cesium.MaterialAppearance({ material: new Cesium.Material({ fabric: { type: 'Image', uniforms: { image: 'path/to/image.png' } } }) }) });这样做的条件是所有实例共用同一个材质。如果每张图片的纹理不同,就不能合并到一个Primitive里,只能分开渲染,或者用纹理图集(texture atlas)把多张图片合并到一张大图上,然后通过 UV 偏移来区分。纹理图集是性能优化的常用手段,但制作图集需要额外的工作,而且图集的大小受限于 GPU 的最大纹理尺寸,通常是 4096x4096 或 8192x8192。
4.2 视锥剔除与 LOD
Cesium默认会对Primitive做视锥剔除,也就是不在相机视野内的几何体不渲染。这个机制对性能帮助很大,但如果几何体的包围球(boundingSphere)计算不准确,剔除就会失效,导致不必要的渲染。自定义Geometry的时候,一定要手动计算包围球,或者调用Cesium.BoundingSphere.fromVertices自动计算。
LOD(Level of Detail)是另一个优化手段。当相机距离很远时,用低分辨率的图片或者简化的几何体;距离近时,用高分辨率。Cesium的Primitive支持minimumPixelSize和maximumScale等属性,可以控制几何体在不同距离下的显示大小。对于图片资源,还可以根据距离动态切换纹理,但这需要自定义着色器或者多个Primitive切换。
我在一个项目里做过测试:1000 张垂直图片,如果不做任何优化,帧率会掉到 20 以下;合并成 10 个Primitive(每个 100 张)之后,帧率回到 50 左右;再加上视锥剔除和 LOD,帧率稳定在 60。这个数据因硬件而异,但趋势是明显的——批处理和剔除是性能优化的关键。
4.3 纹理压缩与内存管理
图片纹理占用的显存不容忽视。一张 1024x1024 的 RGBA 图片,未压缩时占用 4MB 显存,100 张就是 400MB,很容易把显存撑爆。解决办法是使用压缩纹理格式,比如 KTX2 或 DDS,这些格式在 GPU 里可以直接解码,占用显存更少,加载更快。Cesium从 1.70 版本开始支持 KTX2 纹理,但需要额外的配置和工具链。
另一个办法是控制图片的分辨率。如果图片在屏幕上显示的大小不超过 256x256 像素,就没必要用 1024x1024 的纹理。根据实际显示尺寸选择合适的纹理分辨率,可以大幅减少显存占用。我在项目里通常会把图片预处理成 2 的幂次方尺寸(256、512、1024),这样 GPU 的采样效率最高,而且方便做 mipmap。
注意:
Cesium的Material默认会生成 mipmap,但如果图片尺寸不是 2 的幂次方,mipmap 的效果会打折扣,甚至在某些 GPU 上不支持。所以尽量用 2 的幂次方尺寸的图片。
5. 踩坑实录:那些文档里不会写的细节
5.1 地形遮挡与深度测试
垂直图片如果贴在地面上,很容易被地形遮挡。比如你把图片的底部高度设为 0,但实际地形在那个位置的高度是 10 米,图片的下半部分就会被地形挡住。解决办法是把底部高度设得比地形略低,比如 -5 米,或者开启depthTestAgainstTerrain,让Cesium自动处理深度关系。
但depthTestAgainstTerrain有个副作用:它会和地形做深度测试,如果图片在地形后面,就会被正确遮挡;但如果图片在地形前面,而地形有起伏,图片可能会被地形"切割",出现不规则的边缘。这个现象在山区或者地形复杂的区域特别明显。我的经验是,如果图片是贴地放置的,最好把底部高度设得比地形最低点还低,然后让图片自然延伸到地面以下,这样就不会被切割。
另一个坑是Primitive的modelMatrix和地形的交互。如果你用PlaneGeometry加modelMatrix的方式放置图片,modelMatrix是相对于地心坐标系的,而地形的高度是动态加载的。如果地形还没加载完,图片的位置可能会偏高或偏低。解决办法是监听地形的tileLoadProgress事件,等地形加载完成后再添加图片,或者用Cesium.sampleTerrainMostDetailed异步获取地形高度,再调整图片位置。
5.2 图片跨域与加载失败
用Primitive加载图片纹理时,如果图片来自不同的域名,可能会遇到跨域问题。Cesium底层用的是Image对象或者fetch来加载图片,如果服务器没有设置正确的 CORS 头,图片会加载失败,控制台报错。解决办法是把图片放在同域下,或者让服务器设置Access-Control-Allow-Origin。如果图片是 base64 编码的,就不存在跨域问题,但 base64 会增加文件体积,不适合大图。
另一个常见问题是图片加载是异步的,如果Primitive在图片加载完成之前就渲染了,会显示空白或者默认颜色。Cesium的Material在图片加载完成后会自动更新纹理,但有时候需要手动触发重绘。我的做法是在图片的onload回调里调用viewer.scene.requestRender(),强制刷新一帧。
5.3 坐标系转换的精度问题
Cesium内部用的是 WGS84 椭球体,经纬度转空间直角坐标的精度很高,但在做局部坐标变换时,如果矩阵乘法的顺序搞错了,或者用了单精度浮点数,可能会出现位置偏移。特别是在大范围场景里,偏移可能达到几米甚至几十米。解决办法是尽量用Cesium提供的Transforms和Matrix4工具,避免手动构造矩阵;如果必须手动构造,用双精度浮点数,并且注意矩阵乘法的顺序(A.multiply(B)表示 A 乘以 B,不是 B 乘以 A)。
还有一个容易被忽略的点:Cesium.Cartesian3.fromDegrees的第三个参数是高度,单位是米,但它是相对于椭球面的高度,不是相对于地形的高度。如果你要贴地的图片,需要先获取地形高度,再加上图片的高度偏移。这个细节在文档里写得很清楚,但实际写代码的时候很容易忘。
6. 一个完整的可复现示例
把上面的内容串起来,给一个完整的示例:在指定的经纬度位置,用Primitive添加一张朝南的垂直图片,底部贴地,宽度 10 米,高度 20 米,带透明通道。
// 1. 初始化 viewer const viewer = new Cesium.Viewer('cesiumContainer', { terrainProvider: await Cesium.createWorldTerrainAsync() }); // 2. 目标位置 const lon = 116.39; const lat = 39.91; // 3. 获取地形高度 const terrainSample = await Cesium.sampleTerrainMostDetailed( viewer.terrainProvider, [Cesium.Cartographic.fromDegrees(lon, lat)] ); const groundHeight = terrainSample[0].height; // 4. 计算中心点(底部贴地,中心在高度一半处) const centerHeight = groundHeight + 10; // 高度 20 米,中心在 10 米处 const center = Cesium.Cartesian3.fromDegrees(lon, lat, centerHeight); // 5. 构造局部坐标系 const transform = Cesium.Transforms.eastNorthUpToFixedFrame(center); // 6. 构造平面几何体 const planeGeometry = new Cesium.PlaneGeometry({ vertexFormat: Cesium.VertexFormat.POSITION_AND_ST, width: 10, height: 20 }); const geometry = Cesium.PlaneGeometry.createGeometry(planeGeometry); // 7. 构造旋转矩阵:绕 X 轴旋转 90 度,让平面竖直,法线朝南 const rotation = Cesium.Matrix3.fromRotationX(Cesium.Math.toRadians(90)); const rotationMatrix = Cesium.Matrix4.fromRotationTranslation(rotation); // 8. 组合变换矩阵 const modelMatrix = Cesium.Matrix4.multiply( transform, rotationMatrix, new Cesium.Matrix4() ); // 9. 创建 GeometryInstance const instance = new Cesium.GeometryInstance({ geometry: geometry, modelMatrix: modelMatrix, attributes: { color: Cesium.ColorGeometryInstanceAttribute.fromColor(Cesium.Color.WHITE) } }); // 10. 创建 Primitive const primitive = new Cesium.Primitive({ geometryInstances: instance, appearance: new Cesium.MaterialAppearance({ material: new Cesium.Material({ fabric: { type: 'Image', uniforms: { image: 'path/to/vertical-image.png', repeat: new Cesium.Cartesian2(1, 1) } } }), translucent: true, faceForward: true }), asynchronous: false }); // 11. 添加到场景 viewer.scene.primitives.add(primitive); // 12. 图片加载完成后刷新 const img = new Image(); img.src = 'path/to/vertical-image.png'; img.onload = () => viewer.scene.requestRender();这段代码可以直接跑,只需要把图片路径换成你自己的。几个关键点再强调一下:vertexFormat必须包含ST;旋转角度决定朝向,90 度和 -90 度效果相反;translucent和faceForward根据图片是否透明、是否需要背面渲染来设置;asynchronous: false让Primitive同步创建,避免异步加载导致的闪烁。
如果图片贴反了,把Cesium.Math.toRadians(90)改成-90;如果图片上下颠倒,把repeat的 Y 分量改成 -1;如果图片被地形遮挡,把centerHeight调低或者开启depthTestAgainstTerrain。这些调整都是我在实际项目里反复试出来的,文档里不会写得这么细,但真正做的时候一定会遇到。
最后说一个我个人的习惯:每次调整完参数,都会用viewer.scene.debugShowFramesPerSecond = true打开帧率显示,观察性能变化。垂直图片资源如果数量多,性能问题往往不是一下子暴露的,而是随着场景复杂度增加逐渐显现。早发现、早优化,比后期重构要省事得多。