1. 项目概述:为什么要在Cesium中自定义坐标轴?
在三维地理信息可视化领域,CesiumJS无疑是一座绕不开的高峰。它为我们提供了开箱即用的地球、三维模型、地形和影像服务,让我们能快速构建出令人惊叹的数字孪生场景。然而,当项目需求从“展示”深入到“分析”和“交互”时,我们常常会遇到一个瓶颈:Cesium的原生实体(Entity)和图形(Primitive)虽然强大,但在表达特定、专业的空间关系时,往往显得力不从心。
就拿坐标轴来说。Cesium自带的坐标系是地心固定坐标系(ECEF)和地理坐标系(WGS84),这对于标注一个点的经纬度高程是完美的。但如果你需要在地球上清晰地展示一个物体的姿态——比如一架无人机的机头朝向、一个传感器的扫描范围、或者一个三维模型的局部坐标系——原生的坐标标注方式就几乎无法胜任。你可能会尝试用三个不同颜色的线段(Polyline)来模拟X、Y、Z轴,但很快就会发现,没有箭头头的线段在三维空间中方向感极弱,难以一眼分辨正负方向;而一个简单的圆形轨迹(比如表示水平扫描范围),用原生的Polyline绘制也会显得单调且信息量不足。
这就是“自定义箭头坐标轴”和“圆坐标轴”需求诞生的背景。它不是一个简单的UI美化,而是三维空间分析工具的基础组件。通过自定义,我们可以实现:
- 增强可读性:为坐标轴添加醒目的箭头头,明确指示正方向;为圆形轨迹添加刻度、标签或径向线,使其能表达角度、半径等信息。
- 表达专业语义:将坐标轴与特定的实体(如模型、传感器)绑定,动态反映其位置和姿态变化,用于态势监控、仿真推演等场景。
- 构建交互基础:自定义的、美观的坐标轴本身可以作为交互手柄(Gizmo),用于在三维场景中直接拖拽、旋转实体,这是许多三维编辑工具的核心。
因此,这个项目标题“Cesium 自定义箭头坐标轴 圆坐标轴(一)”直指了一个非常实际且高频的开发痛点。本系列文章,我将从最基础的箭头坐标轴绘制讲起,结合我在地理信息、三维可视化项目中的实战经验,不仅给出“怎么做”的代码,更深入剖析“为什么这么做”的原理,并分享那些在官方文档里找不到的“踩坑”心得。无论你是刚接触Cesium的新手,还是正在为项目添加专业分析功能的老手,相信都能从中获得直接的帮助。
2. 核心思路与方案选型:从需求到技术实现
在动手写代码之前,我们必须先理清思路:在Cesium的三维世界里,绘制一个自定义的图形,有哪些技术路线可选?每种路线的优劣是什么?我们的“箭头坐标轴”和“圆坐标轴”最适合哪一条?
Cesium提供了多层次、可灵活组合的图形绘制API,主要分为三大阵营:
- Entity API:高级抽象,声明式编程。你告诉Cesium“我想要一个在某个位置、某种颜色的线”,它帮你处理细节。优点是简单易用,能与Cesium的时间动态数据、CZML等完美集成。缺点是定制化程度低,性能开销相对较大,且难以实现复杂的自定义几何形状(如一个带锥形箭头的轴)。
- Primitive API:中级抽象,面向图形。你需要自己定义几何(Geometry)和外观(Appearance)。这提供了巨大的灵活性,你可以创建任何由三角形构成的形状。性能通常优于Entity。但API较为复杂,需要理解计算机图形学的一些基础概念(如顶点、索引、着色器)。
- 低级图形API(如CustomShader):直接操作WebGL。通过为Primitive或Model编写自定义着色器(Custom Shader),你可以完全控制顶点变换、光照和颜色计算。这是功能最强大、性能潜力最高的方式,但门槛也最高,需要对GLSL着色语言和图形管线有深入理解。
对于“自定义箭头坐标轴”,我们的核心需求是:在三维空间的指定位置和方向,绘制三条互相垂直、带有明确箭头指向的线段(X-红,Y-绿,Z-蓝)。
- Entity方案:可以组合使用
PolylineGraphics(线段)和ModelGraphics(一个锥形模型作为箭头)。但将多个Entity组合成一个逻辑整体(坐标轴)比较麻烦,动态更新姿态时的同步和性能是问题。 - Primitive方案:我们可以为每条轴(包括箭头头)创建一个自定义的
Primitive。箭头头可以用一个圆锥体(ConeGeometry)或一个由多个三角形构成的锥形CustomGeometry来表示。这是平衡了灵活性、性能和复杂度的最佳选择。我们可以将三条轴的Primitive编组管理,统一更新位置和姿态。 - CustomShader方案:杀鸡用牛刀。对于静态或简单动态的箭头,不需要这么复杂。
因此,我选择Primitive API作为实现箭头坐标轴的核心方案。它让我们能精细控制几何体的每一个顶点,从而构建出任意形状的箭头,并且能高效地批量更新其变换矩阵(位置、旋转、缩放)。
对于“圆坐标轴”,需求是:在三维空间中指定一个中心点、法向量(圆所在平面的朝向)和半径,绘制一个圆形,并可附加刻度、标签等元素。
- 一个简单的圆环可以用
EllipseGeometry(将半长轴和半短轴设为相等)或CircleGeometry来轻松创建,这属于Primitive API的范畴。 - 如果需要带刻度的圆(如罗盘、角度盘),则需要在圆环的基础上,额外绘制一系列从圆心向外辐射的短线(Tick),这同样可以通过创建自定义的Geometry(比如用一系列细长的矩形来代表刻度)来实现。
所以,整个项目的技术栈就明确了:以Cesium的Primitive API为主,辅以必要的数学计算(坐标系变换、向量运算),来构建我们自定义的、可复用的坐标轴可视化组件。
3. 基础构建:理解Cesium的坐标系与变换矩阵
在开始绘制之前,我们必须攻克第一个,也是最重要的难点:空间变换。一个坐标轴组件,必须能够被放置到场景中的任意位置,并指向任意方向。这涉及到Cesium中多种坐标系的相互转换。
核心心法:在Cesium中绘图,你的顶点数据最终都需要转换到世界坐标(World Coordinates),也就是那个以地心为原点的ECEF坐标系。我们的任务就是找到从“本地模型坐标”到“世界坐标”的转换矩阵。
3.1 关键坐标系解析
- 模型局部坐标系(Local Frame):这是我们定义几何形状的地方。例如,我们定义一条从(0,0,0)到(1,0,0)的线段,表示X轴正方向。这个坐标系通常是一个右手坐标系,X右,Y前(或上),Z上(或前),具体取决于你的习惯,但必须保持一致。
- 东北天坐标系(East-North-Up, ENU):这是一个以地球表面某个特定点(通常是我们的坐标轴要放置的位置)为原点的局部坐标系。它的三个轴分别指向东、北、天顶方向。这个坐标系对于描述附着在地球表面的物体姿态非常直观。
- 地心地固坐标系(Earth-Centered, Earth-Fixed, ECEF):也就是世界坐标系。原点在地球质心,Z轴指向北极,X轴指向本初子午线与赤道的交点,Y轴与X、Z轴构成右手坐标系。Cesium场景中的所有位置最终都用这个坐标系表示。
3.2 构建变换矩阵
我们的目标是:给定一个地理坐标center(Cartesian3类型,ECEF坐标)和一个姿态orientation(通常用四元数Quaternion或欧拉角HeadingPitchRoll表示),计算出一个4x4的变换矩阵modelMatrix。这个矩阵能将我们定义在局部坐标系中的顶点,变换到正确的世界坐标位置。
Cesium提供了强大的工具函数来完成这个工作:
// 假设我们有一个中心点(WGS84经纬高) var center = Cesium.Cartesian3.fromDegrees(116.39, 39.9, 100.0); // 北京附近,高度100米 // 定义姿态:例如,航向45度(正北为0,顺时针增加),俯仰和横滚为0。 var hpr = new Cesium.HeadingPitchRoll(Cesium.Math.toRadians(45), 0, 0); // 将HeadingPitchRoll转换为四元数。注意:Cesium的默认方向是北-东-下,我们需要调整。 // 更常用的做法是,我们根据目标方向直接构建一个旋转矩阵或四元数。 // 方法一:使用Transforms.headingPitchRollToFixedFrame。这个函数直接生成一个以center为原点,具有给定hpr姿态的局部坐标系到ECEF的变换矩阵。 // 它默认生成的是“东北天”坐标系下的变换。我们可以通过指定一个“固定轴”参数来调整。 var modelMatrix = Cesium.Transforms.headingPitchRollToFixedFrame( center, hpr, Cesium.Ellipsoid.WGS84, Cesium.Transforms.eastNorthUpToFixedFrame // 这是默认值,表示局部坐标系是ENU ); // 方法二:如果我们想要的不是ENU,而是自定义的朝向(例如,让坐标轴Z轴指向某个方向向量),则需要手动构建。 // 假设我们有一个“上”向量(up)和一个“前”向量(forward/direction)。 var up = Cesium.Cartesian3.normalize(center, new Cesium.Cartesian3()); // 地心到点的方向,近似“天” var forward = Cesium.Cartesian3.normalize( Cesium.Cartesian3.cross(up, new Cesium.Cartesian3(1, 0, 0), new Cesium.Cartesian3()), new Cesium.Cartesian3() ); // 构造一个与“天”和X轴都垂直的方向作为“前” var right = Cesium.Cartesian3.cross(forward, up, new Cesium.Cartesian3()); // 右手定则 // 现在我们有三个正交的单位向量:right, forward, up。它们构成了局部坐标系的三个轴在世界坐标系(ECEF)下的表示。 // 我们可以用它们来构建一个旋转矩阵,然后与平移矩阵组合成modelMatrix。 var rotationMatrix = Cesium.Matrix3.fromColumnMajorArray([ right.x, right.y, right.z, forward.x, forward.y, forward.z, up.x, up.y, up.z ]); var translationMatrix = Cesium.Matrix4.fromTranslation(center); var modelMatrix = Cesium.Matrix4.multiply( translationMatrix, Cesium.Matrix4.fromRotationTranslation(rotationMatrix), new Cesium.Matrix4() );实操心得一:理解“默认朝向”。
Cesium.Transforms.eastNorthUpToFixedFrame生成的矩阵,其局部坐标系的X轴指向东,Y轴指向北,Z轴指向天。如果你定义的模型局部坐标系是X右、Y前、Z上,那么直接用这个矩阵,你的模型Y轴就会指向地理北。这常常是符合直觉的。如果你的模型朝向定义不同,就需要额外乘一个旋转矩阵来校正。
得到了modelMatrix,我们就拥有了将局部坐标顶点“放置”到三维场景中的魔力钥匙。在创建Primitive时,将这个矩阵赋给Primitive.modelMatrix属性,Cesium就会在渲染时自动应用这个变换。
4. 实战:绘制自定义箭头坐标轴
理论铺垫完成,现在进入实战环节。我们将分步骤创建X、Y、Z三条带箭头的坐标轴。
4.1 设计几何数据:轴体与箭头头
一条完整的箭头轴由两部分组成:轴身(一条细长的圆柱体)和箭头头(一个圆锥体)。我们将为每个部分创建独立的Geometry。
轴身几何:我们可以用CylinderGeometry来创建。但CylinderGeometry默认是竖直的(沿Y轴)。我们需要在局部坐标系中,创建一条沿X轴方向的圆柱体,然后通过modelMatrix将其旋转到目标方向。更灵活的方式是使用CylinderGeometry的offset属性,或者直接使用PolylineVolumeGeometry(但更重)。为了简单和性能,我选择使用CylinderGeometry,并通过调整其orientation(一个旋转矩阵)来改变方向。
不过,对于坐标轴这种简单形状,更直接高效的方法是使用自定义几何(Custom Geometry)。我们可以直接定义构成圆柱体的顶点和三角形索引。
function createArrowAxisGeometry(axisLength = 10.0, axisRadius = 0.05, headLength = 2.0, headRadius = 0.15) { // 局部坐标系:原点在(0,0,0),X轴正方向为箭头方向。 // 轴身:从(0,0,0)到(axisLength, 0, 0)的圆柱体。 // 箭头头:一个底面在(axisLength, 0, 0),尖端在(axisLength + headLength, 0, 0)的圆锥体。 // 注意:这里为了简化,我们先只创建轴身的圆柱体几何。 // 圆柱体可以用一个多边形(比如8边形)沿着X轴拉伸来近似。 const segments = 8; // 圆柱横截面的边数 const positions = []; const indices = []; const normals = []; // 法线,用于光照 // 1. 生成圆柱体两个端面的顶点 for (let i = 0; i < segments; i++) { const angle = (i / segments) * Cesium.Math.TWO_PI; const y = Math.cos(angle) * axisRadius; const z = Math.sin(angle) * axisRadius; // 起始端面顶点 (x=0) positions.push(0, y, z); // 结束端面顶点 (x=axisLength) positions.push(axisLength, y, z); // 法线方向是顶点位置减去中心点,然后归一化(对于圆柱侧面) // 对于端面,法线是(1,0,0)或(-1,0,0),这里我们先处理侧面。 const normal = Cesium.Cartesian3.normalize(new Cesium.Cartesian3(0, y, z), new Cesium.Cartesian3()); normals.push(normal.x, normal.y, normal.z); normals.push(normal.x, normal.y, normal.z); } // 2. 生成圆柱体侧面的三角形索引 for (let i = 0; i < segments; i++) { const nextI = (i + 1) % segments; const a0 = i * 2; // 当前段起始端面下顶点索引 const a1 = i * 2 + 1; // 当前段结束端面上顶点索引 const b0 = nextI * 2; // 下一段起始端面下顶点索引 const b1 = nextI * 2 + 1;// 下一段结束端面上顶点索引 // 两个三角形构成一个侧面矩形 indices.push(a0, a1, b0); indices.push(b0, a1, b1); } // 3. 创建并返回Geometry实例 // 实际项目中,还需要为两个端面添加顶点和索引,并计算正确的法线。 // 此处为演示简化流程。 const geometry = new Cesium.Geometry({ attributes: { position: new Cesium.GeometryAttribute({ componentDatatype: Cesium.ComponentDatatype.DOUBLE, componentsPerAttribute: 3, values: new Float64Array(positions) }), normal: new Cesium.GeometryAttribute({ componentDatatype: Cesium.ComponentDatatype.FLOAT, componentsPerAttribute: 3, values: new Float32Array(normals) }) }, indices: indices, primitiveType: Cesium.PrimitiveType.TRIANGLES, boundingSphere: Cesium.BoundingSphere.fromVertices(positions) }); return geometry; }箭头头几何:圆锥体也可以用类似的自定义几何创建,或者使用Cesium内置的ConeGeometry,并通过旋转和平移将其放置到轴身末端。
function createArrowHeadGeometry(headLength = 2.0, headRadius = 0.15) { // 使用内置的ConeGeometry,它默认尖端在+Y方向,底面在-Y方向。 var coneGeometry = Cesium.ConeGeometry.createGeometry( new Cesium.ConeGeometry({ length: headLength, topRadius: 0.0, // 尖端半径为0 bottomRadius: headRadius, slices: 16, // 锥体细分面数 vertexFormat: Cesium.VertexFormat.POSITION_AND_NORMAL // 需要法线 }) ); // ConeGeometry的顶点是在局部坐标系中的。我们需要将其旋转,使其轴向与X轴对齐。 // 创建一个旋转矩阵:绕Z轴旋转-90度,使Y轴转向X轴。 var rotation = Cesium.Matrix3.fromRotationZ(-Cesium.Math.PI_OVER_TWO); var rotationMatrix = Cesium.Matrix4.fromRotationTranslation(rotation); // 再平移,使圆锥体的底面中心位于局部坐标系的原点(这样箭头头的基部才能和轴身末端对接)。 // ConeGeometry的底面中心在(0, -length/2, 0)。我们要平移到(0,0,0)。 var translation = Cesium.Matrix4.fromTranslation(new Cesium.Cartesian3(0, headLength/2, 0)); var transform = Cesium.Matrix4.multiply(translation, rotationMatrix, new Cesium.Matrix4()); // 应用变换到几何体的所有顶点 Cesium.GeometryPipeline.transformToWorldCoordinates(coneGeometry, transform); return coneGeometry; }实操心得二:几何变换的时机。有两种方式处理几何的朝向:1)在创建几何顶点时,就直接计算在世界坐标系或目标朝向坐标系下的位置(像上面
createArrowAxisGeometry的注释里提到的,需要复杂计算)。2)创建“标准”几何(如沿Y轴的圆柱、圆锥),然后通过Primitive的modelMatrix进行整体变换。我强烈推荐第二种。因为它更清晰、性能更好(Cesium内部会合并矩阵运算),且易于管理。我们只需为X轴创建一个沿Y轴的标准圆柱几何,然后通过一个旋转矩阵(绕Z轴转-90度)在modelMatrix中将其转到X轴方向。这样,我们只需要一套基础的圆柱和圆锥几何体,通过不同的modelMatrix就能生成所有方向的轴。
4.2 组装Primitive并设置外观
有了几何,我们需要用Primitive来包装它,并赋予材质(颜色)。
function createAxisPrimitive(geometry, color, modelMatrix) { // 创建几何实例,将几何与模型矩阵、属性(如颜色)关联 var instance = new Cesium.GeometryInstance({ geometry: geometry, modelMatrix: modelMatrix, // 关键!这里应用变换 attributes: { color: Cesium.ColorGeometryInstanceAttribute.fromColor(color) } }); // 创建Primitive var primitive = new Cesium.Primitive({ geometryInstances: instance, appearance: new Cesium.PerInstanceColorAppearance({ flat: true, // 平面着色,使颜色均匀,不随光照变化。对于坐标轴通常需要flat。 translucent: false // 不透明 }), asynchronous: false // 同步加载,适合简单几何 }); return primitive; }现在,我们可以创建三条轴了:
// 假设我们已经有了一个总的 modelMatrix,它定义了坐标轴组件整体的位置和朝向(ENU坐标系)。 var enuToFixedFrame = Cesium.Transforms.eastNorthUpToFixedFrame(center); var axisGroupMatrix = enuToFixedFrame; // 整体变换矩阵 // 定义各轴的颜色和局部变换(相对于组中心) var axisConfig = [ {color: Cesium.Color.RED, localRotation: Cesium.Matrix3.IDENTITY}, // X轴:红色,在ENU中对应东方向 {color: Cesium.Color.GREEN, localRotation: Cesium.Matrix3.fromRotationZ(Cesium.Math.PI_OVER_TWO)}, // Y轴:绿色,绕Z转90度得北方向 {color: Cesium.Color.BLUE, localRotation: Cesium.Matrix3.fromRotationX(-Cesium.Math.PI_OVER_TWO)} // Z轴:蓝色,绕X转-90度得天方向 ]; var axisPrimitives = []; // 创建轴身几何(一个沿局部Y轴的标准圆柱) var shaftGeometry = createCylinderGeometry(10.0, 0.05); // 假设这个函数返回一个沿Y轴,高10,半径0.05的圆柱 // 创建箭头头几何(一个沿局部Y轴的标准圆锥) var headGeometry = createConeGeometry(2.0, 0.15); // 假设这个函数返回一个沿Y轴,高2,底半径0.15的圆锥 axisConfig.forEach((config, index) => { // 计算该条轴自身的模型矩阵 = 整体矩阵 * 局部旋转矩阵 var localRotationMatrix = Cesium.Matrix4.fromRotationTranslation(config.localRotation); var axisModelMatrix = Cesium.Matrix4.multiply(axisGroupMatrix, localRotationMatrix, new Cesium.Matrix4()); // 创建轴身Primitive var shaftPrimitive = createAxisPrimitive(shaftGeometry, config.color, axisModelMatrix); axisPrimitives.push(shaftPrimitive); // 创建箭头头Primitive。箭头头需要附加在轴身末端。 // 先计算箭头头自身的变换:在轴身的基础上,再沿Y轴平移轴身的长度。 var headTranslation = Cesium.Matrix4.fromTranslation(new Cesium.Cartesian3(0, 10.0, 0)); // 沿Y轴平移10(轴身长度) var headModelMatrix = Cesium.Matrix4.multiply(axisModelMatrix, headTranslation, new Cesium.Matrix4()); var headPrimitive = createAxisPrimitive(headGeometry, config.color, headModelMatrix); axisPrimitives.push(headPrimitive); }); // 将所有Primitive添加到场景的PrimitiveCollection中 var viewer = new Cesium.Viewer('cesiumContainer'); axisPrimitives.forEach(p => viewer.scene.primitives.add(p));4.3 封装与复用:创建坐标轴组件
将上述逻辑封装成一个类,是工程化的必然选择。这个类应该提供简洁的API,如setPosition(lng, lat, height)和setOrientation(heading, pitch, roll),内部负责更新所有相关Primitive的modelMatrix。
class ArrowAxisGizmo { constructor(viewer, options) { this.viewer = viewer; this.options = Cesium.defaultValue(options, {}); this._axisLength = Cesium.defaultValue(this.options.axisLength, 10.0); this._shaftRadius = Cesium.defaultValue(this.options.shaftRadius, 0.05); this._headLength = Cesium.defaultValue(this.options.headLength, 2.0); this._headRadius = Cesium.defaultValue(this.options.headRadius, 0.15); this._position = new Cesium.Cartesian3(); this._orientation = new Cesium.Quaternion(); this._modelMatrix = new Cesium.Matrix4(); this._primitives = []; // 存储所有轴和箭头头的Primitive this._initGeometries(); this._createAxes(); } _initGeometries() { // 创建一次,重复使用的基础几何 this._shaftGeometry = this._createShaftGeometry(); this._headGeometry = this._createHeadGeometry(); } _createShaftGeometry() { /* 返回沿Y轴的圆柱几何 */ } _createHeadGeometry() { /* 返回沿Y轴的圆锥几何 */ } _createAxes() { // 类似于上面的循环,创建6个Primitive(3轴*2部分) // 并将它们添加到viewer.scene.primitives和this._primitives中 } setPosition(cartesian) { Cesium.Cartesian3.clone(cartesian, this._position); this._updateModelMatrix(); } setOrientation(quaternion) { Cesium.Quaternion.clone(quaternion, this._orientation); this._updateModelMatrix(); } _updateModelMatrix() { // 根据位置和四元数,计算新的modelMatrix var translation = Cesium.Matrix4.fromTranslation(this._position, new Cesium.Matrix4()); var rotation = Cesium.Matrix4.fromRotationTranslation(Cesium.Matrix3.fromQuaternion(this._orientation)); Cesium.Matrix4.multiply(translation, rotation, this._modelMatrix); // 更新所有Primitive的modelMatrix // 注意:每条轴有自己的局部旋转,需要分别计算 this._primitives.forEach((primitive, index) => { // 计算该primitive最终的modelMatrix var finalMatrix = ...; // this._modelMatrix * 该轴的局部变换 primitive.modelMatrix = finalMatrix; }); } destroy() { this._primitives.forEach(p => this.viewer.scene.primitives.remove(p)); this._primitives.length = 0; } }这样,在业务代码中,你就可以轻松地创建和操控坐标轴了:
var gizmo = new ArrowAxisGizmo(viewer, {axisLength: 50.0}); gizmo.setPosition(Cesium.Cartesian3.fromDegrees(116.39, 39.9, 100.0)); // 动态更新位置,坐标轴会跟随移动 viewer.scene.preRender.addEventListener(function() { var newPosition = ...; // 计算新的位置 gizmo.setPosition(newPosition); });5. 进阶:绘制圆坐标轴
圆坐标轴(或称“圆环刻度盘”)的绘制思路与箭头坐标轴类似,但几何形状更复杂一些。核心是创建一个位于特定平面上的圆环,并可能在其上添加刻度标记。
5.1 创建圆环几何
我们可以使用Cesium.EllipseGeometry来创建一个圆(将半长轴和半短轴设为相同)。
function createCircleGeometry(radius, normal) { // normal是圆平面法向量(单位向量) var options = { center: Cesium.Cartesian3.ZERO, // 在局部坐标系中心 semiMajorAxis: radius, semiMinorAxis: radius, rotation: 0.0, stRotation: 0.0, height: 0.0, extrudedHeight: undefined, vertexFormat: Cesium.VertexFormat.POSITION_ONLY, ellipsoid: Cesium.Ellipsoid.UNIT_SPHERE // 在局部小范围使用单位球近似平面 }; var ellipseGeometry = new Cesium.EllipseGeometry(options); // EllipseGeometry默认在XY平面(法向量为Z轴)。我们需要将其旋转到目标法向量方向。 // 计算从Z轴(0,0,1)旋转到目标法向量normal的旋转矩阵。 var rotationMatrix = Cesium.Matrix3.fromRotation(Cesium.Matrix3.IDENTITY, normal); var geometry = Cesium.EllipseGeometry.createGeometry(ellipseGeometry); // 应用旋转 Cesium.GeometryPipeline.transformToWorldCoordinates(geometry, Cesium.Matrix4.fromRotationTranslation(rotationMatrix)); return geometry; }但是,EllipseGeometry生成的是填充多边形。如果我们只需要圆环线,使用PolylineGeometry配合Polyline来绘制性能更好,但无法附加复杂的材质(如发光效果)。另一种折中方案是使用WallGeometry来构造一个非常窄的“墙”作为圆环。对于需要着色、发光的圆环,使用EllipseGeometry并配合Material(如ColorMaterialProperty或PolylineGlowMaterialProperty的近似效果)是常见做法。
5.2 添加刻度标记
刻度本质上是圆环上的一系列短线。我们可以在圆环几何创建的同时,生成这些刻度线的几何数据,并合并到同一个GeometryInstance中,以提高绘制效率。
思路是:在生成圆环边缘顶点时,每隔一定角度(如每30度),不仅生成圆环上的点,还额外生成两个点,构成一条从圆环向内(或向外)延伸的短线段。将这些线段的位置信息也加入到positions数组中,并扩展indices来定义这些线段(使用LINES或LINE_STRIP图元类型)。
function createCircleWithTicksGeometry(radius, normal, tickCount = 12, tickLength = radius * 0.1) { const positions = []; const indices = []; const segmentCount = 64; // 圆环细分段数 // 1. 生成圆环顶点 (LINE_LOOP) for (let i = 0; i <= segmentCount; i++) { const angle = (i / segmentCount) * Cesium.Math.TWO_PI; const x = Math.cos(angle) * radius; const y = Math.sin(angle) * radius; const z = 0; positions.push(x, y, z); if (i > 0) { indices.push(i-1, i); } } // 闭合圆环 indices.push(segmentCount, 0); let currentIndex = positions.length / 3; // 当前顶点索引起始位置 // 2. 生成刻度线顶点 (LINES) for (let i = 0; i < tickCount; i++) { const angle = (i / tickCount) * Cesium.Math.TWO_PI; const cosA = Math.cos(angle); const sinA = Math.sin(angle); // 刻度线起点(在圆环上) const startX = cosA * radius; const startY = sinA * radius; const startZ = 0; // 刻度线终点(向内延伸) const endX = cosA * (radius - tickLength); const endY = sinA * (radius - tickLength); const endZ = 0; positions.push(startX, startY, startZ, endX, endY, endZ); indices.push(currentIndex, currentIndex + 1); currentIndex += 2; } // 3. 将顶点数组从局部XY平面旋转到目标法向量平面 const rotationMatrix = Cesium.Matrix3.fromRotation(Cesium.Matrix3.IDENTITY, normal); const transformMatrix = Cesium.Matrix4.fromRotationTranslation(rotationMatrix); const typedPositions = new Float64Array(positions); // ... 应用旋转矩阵到typedPositions中的所有顶点 ... // 4. 创建Geometry return new Cesium.Geometry({ attributes: { position: new Cesium.GeometryAttribute({ componentDatatype: Cesium.ComponentDatatype.DOUBLE, componentsPerAttribute: 3, values: typedPositions }) }, indices: indices, primitiveType: Cesium.PrimitiveType.LINES // 使用线段图元 }); }5.3 圆坐标轴的放置与更新
圆坐标轴的放置逻辑与箭头坐标轴完全一致。我们计算出圆平面法向量对应的旋转矩阵,结合中心点位置,生成最终的modelMatrix,并将其赋给圆环Primitive。
一个常见的应用是将圆坐标轴作为俯仰(Pitch)或偏航(Yaw)的角度指示器。此时,圆坐标轴的法向量需要与载体的某个轴对齐(例如,水平圆的法向量与载体的Z轴对齐),并随着载体姿态的变化而旋转。
6. 性能优化与常见问题排查
当场景中需要同时显示数十甚至上百个自定义坐标轴时,性能就成为必须考虑的问题。
6.1 性能优化技巧
- 几何实例化(Geometry Instancing):这是我们一直在使用的方法。将相同的几何(如轴身圆柱)通过多个
GeometryInstance共享,每个实例拥有自己的modelMatrix和attributes(如颜色)。这能极大减少向GPU传输的数据量和绘制调用(Draw Call)。我们的ArrowAxisGizmo类为每条轴创建独立的Primitive,实际上每个Primitive包含一个GeometryInstance。对于大量重复的坐标轴,可以考虑将所有轴的几何实例合并到一个Primitive中管理。 - 细节层次(LOD):当坐标轴距离相机很远时,不需要渲染复杂的箭头头甚至完整的刻度。可以定义不同细节层次的几何(例如,远处只画一条线,中等距离画不带箭头的轴,近处画完整箭头),根据距离动态切换。
- 视锥体剔除(Frustum Culling):Cesium会自动为
Primitive进行视锥体剔除。确保你为自定义几何设置了正确的boundingSphere,否则剔除失效,会导致不可见的部分也被渲染。 - 避免每帧更新矩阵:如果坐标轴的位置和姿态不变,就不要在
preRender事件中更新其modelMatrix。只在变化时更新。 - 简化几何:在满足视觉效果的前提下,减少圆柱和圆锥的切片数(
slices)。8-16边通常足以满足大部分情况。
6.2 常见问题与解决方案实录
问题1:坐标轴在特定角度下“闪烁”或“消失”。
- 排查:这通常是深度缓冲(Z-Fighting)的典型症状。当两个或多个表面(如坐标轴的轴身和地球表面,或两个重叠的坐标轴)距离非常近时,由于深度值的精度限制,GPU无法确定谁在前谁在后,导致渲染顺序错乱。
- 解决:
- 启用多边形偏移(Polygon Offset):在创建
Primitive的Appearance时,设置renderState。对于线条,可以设置polygonOffset: { enabled: true, factor: -1.0, units: -1.0 }。这会轻微调整图元的深度值,使其“浮”在其他几何体之上或之下。
appearance: new Cesium.PerInstanceColorAppearance({ renderState: { depthTest: { enabled: true }, polygonOffset: { enabled: true, factor: -1.0, units: -1.0 } // 负值使其更靠近相机 } })- 调整绘制顺序:通过设置
Primitive的depthFailAppearance或确保坐标轴在其它实体之后添加(后添加的默认后渲染),但这种方法不绝对可靠。 - 轻微偏移:在构建几何时,让坐标轴稍微“抬离”它所依附的物体表面零点几个单位。
- 启用多边形偏移(Polygon Offset):在创建
问题2:坐标轴颜色不鲜艳,或者受场景光照影响。
- 排查:
PerInstanceColorAppearance默认使用flat: false(平滑着色),它会根据顶点法线和场景光源计算颜色。如果法线计算不正确,或者你希望颜色恒定,就会出现问题。 - 解决:
- 设置
flat: true。这会禁用光照计算,直接使用你提供的颜色,使坐标轴颜色鲜艳且恒定。 - 确保你为几何提供了正确的法线属性(
normal)。如果你使用flat: true,则法线不是必须的。
- 设置
问题3:坐标轴在球面远处看起来扭曲或尺寸不一致。
- 排查:这是因为我们在局部坐标系中定义的几何(例如,10米长的轴),在应用了
modelMatrix后,被直接放置到了ECEF世界坐标系中。在Cesium的球面场景中,远离地心的位置,一个“米”代表的实际视觉角度会变化。 - 解决:这通常不是问题,因为坐标轴作为物体的局部参考系,其视觉尺寸随物体一起变化是符合透视的。如果你希望坐标轴在世界空间中保持恒定的像素大小(即屏幕空间尺寸不变),则需要使用** Billboard或Label **技术来绘制箭头,或者使用更复杂的着色器程序来实现屏幕空间固定大小(Screen-Space Fixed Size),这超出了本文Primitive方案的范畴。对于大多数附着在实体上的坐标轴,接受其透视变化是更合理的。
问题4:自定义坐标轴无法被拾取(Pick)。
- 排查:默认情况下,
Primitive不支持拾取。你需要显式启用它。 - 解决:在创建
GeometryInstance时,添加一个id属性,并确保Primitive的allowPicking设置为true。
然后,在var instance = new Cesium.GeometryInstance({ geometry: geometry, modelMatrix: matrix, attributes: { color: ... }, id: 'myAxisX' // 设置一个可拾取的id }); var primitive = new Cesium.Primitive({ geometryInstances: instance, appearance: ..., asynchronous: false, allowPicking: true // 启用拾取 });viewer.scene.pick事件中,就可以通过pickedObject.id来判断是否拾取到了坐标轴。
自定义坐标轴的开发是深入理解Cesium图形系统的一个绝佳实践。它串联起了坐标系变换、几何构建、Primitive渲染和性能优化等多个核心知识点。从一条简单的带箭头的线开始,你可以逐步扩展出刻度标签、交互手柄、动画效果等,最终构建出满足复杂业务需求的专业三维交互工具。希望这篇长文能为你打下坚实的基础,在后续的系列文章中,我们将探讨更高级的主题,如与Cesium Entity的集成、动态数据驱动更新以及更复杂的复合坐标轴设计。