做Unity开发的朋友,尤其是碰过数字孪生、工业仿真、三维现场测量这类项目的,应该都有过相似的经历:场景搭建好了,模型摆好了,镜头也调顺了,需求方忽然提一句——“帮我在这个3D场景里加个测量功能呗,能点两个点量一下距离,点三个点看下角度,选一圈点算个面积出来。”
听起来是个“简单的小功能”,真正动手就知道坑不少。3D空间里的多边形面积不能拿二维鞋带公式硬套;鼠标点击拾取到的点需要经过坐标空间换算,不同模型的缩放比例不一致,坐标数据容易乱;连点十几个点还要实时刷新标注和数值,性能和交互都要照顾到。我是在做某个数字孪生项目时,被这个需求反复折腾,最终沉淀出了这套基于Unity和C#的3D空间测量工具,覆盖任意多边形面积、距离、角度三类核心测量。
这篇文章把整个实现过程复盘一遍,包括面积算法推导、交互链路设计、可视化标注,以及我在实际项目里踩过的坑和排查经验。无论你是准备在数字孪生、建筑可视化、工业仿真里加测量功能,还是单纯想做一个小工具练手,这套方案都可以直接参考。项目并不复杂,但要把“能算”做到“好用”,中间有不少细节值得认真琢磨。
1. 项目定位:3D测量工具的“表面需求”与真实价值
1.1 三种测量功能各自解决什么问题
先拆解需求。距离测量是最基础的,点击两个点,拿到三维空间中的直线距离。很多场景下需要的是累计距离,比如沿着一条管道路径测总长,点五六个点加和,这比单纯的两点距离更实用。
角度测量在工程场景里更常见。点击三个点,求夹角;或者选择两个平面,求二面角。前者对应管线折弯、钢结构斜撑的安装角度,后者对应墙面与地面的垂直度校验、坡道的倾斜角。三种角度计算方式各有适用场景,但交互上都离不开“点选顶点”这个动作。
面积测量最容易出问题。二维鞋带公式大家都会写,但三维空间里的多边形顶点不保证共面,直接投影到一个固定平面再算面积,误差会相当大。比如一个稍微翘曲的空间四边形,投影到XY平面和投影到XZ平面得到的“面积”完全不同,哪个才是真实面积?这就需要一个更严谨的算法,我在第二章详细讲。
把这三个功能放到一起看,本质上是一个“三维空间量化工具”:手动或半自动地在场景里拾取关键点,通过算法把几何量算出来,再把结果用可视化的方式标注回场景。表面上是给用户一把“三维尺子”,底层考验的是矢量计算、空间变换、交互状态管理和场景渲染的协作能力。
1.2 为什么选择在Unity项目内自研而不是用现成插件
有人会问,Asset Store里不是有现成的测量插件吗,为什么不直接买一个?我在项目启动前认真对比过三条路线。
用现成插件确实能快速出效果,很多商业插件做得相当完善,遮挡处理、单位切换、标注样式都有成熟方案。但问题同样明显:插件是黑盒,数字化项目的需求经常是“测量出来的数据要实时回传给业务系统”,甚至要和历史数据对比、写入报表,插件不太可能给你留好这些接口。
另一种思路是把测量逻辑拆出来做成独立算法库,比如在WinForm上位机里处理。这种方案适合离线数据处理,但用户脱离了3D场景,没法通过鼠标在模型上直观地拾取点,效率低很多。
最终我选择在Unity项目内自研一个测量模块。理由有三个:第一,场景本身就在Unity里运行,射线检测、坐标变换、UI叠加都是现成的;第二,测量结果可以和业务数据直接互通,比如串口采集到的传感器值与测量值做对比校验;第三,模块化之后,后续加“弧长测量”“体积估算”等功能,代码结构清晰,维护成本低。
2. 面积计算核心算法:3D空间任意多边形怎么算才准
2.1 鞋带公式在3D场景中的局限
二维鞋带公式(Shoelace Formula)是计算平面多边形面积的经典方法,把顶点坐标按顺序排列,交叉相乘累加取一半。公式本身没有问题,但它有一个硬性前提:所有顶点必须在同一个二维平面上。
3D场景中鼠标拾取的点虽然看起来都在模型表面,但模型表面本身是曲面,一串点未必严格共面。即便是在一个平面上,如果直接忽略一个坐标轴做投影,也会因为多边形与投影面不平行而产生面积失真。比如一个与地面成45度角的坡面,投影到XY平面后面积只有真实面积的70%左右。
所以三维面积计算最核心的问题不是“怎么算面积”,而是“怎么确定多边形所在平面,并把面积还原为真实面积”。
2.2 Newell法向量与投影还原法推导
这里我选用的是“Newell法向量 + 投影还原”方案。Newell公式是计算机图形学里计算多边形法向量的经典方法,它不需要多边形严格共面,而是通过顶点坐标的循环累加得到一个“最佳拟合”法向量,公式如下:
Nx = Σ (y_i - y_{i+1}) * (z_i + z_{i+1}) Ny = Σ (z_i - z_{i+1}) * (x_i + x_{i+1}) Nz = Σ (x_i - x_{i+1}) * (y_i + y_{i+1})其中 i+1 需要循环取模,最后一个点的下一点是第一个点。这个法向量的方向与顶点顺序有关(顺逆时针会影响正负),但模长具有明确的几何意义——它等于多边形在自身平面上的“面积加权法向量”。
拿到法向量之后,确定投影平面。做法是找出法向量中绝对值最大的分量,剔除这个分量,把三维顶点投影到对应的二维平面。举个例子,如果法向量是 (0, 0, 2.5),Z分量绝对值最大,就把所有点的Z坐标去掉,在XY平面上用鞋带公式算投影面积。
为什么要选最大分量而不是固定选择某个轴?原因是数值稳定性。如果法向量某个分量等于0或接近0,也就是说多边形恰好和投影轴平行,投影面积会退化为0,修正比例会变成无穷大。选择最大分量可以保证分母足够大,有效减少精度损失。
最后一步是面积修正。设法向量为 N,最大分量绝对值为 |N_max|,那么真实面积与投影面积的关系为:
真实面积 = 投影面积 * (N.magnitude / |N_max|)这个比例因子的几何意义是法向量与投影平面法线夹角的余弦倒数。多边形倾斜得越厉害,投影面积越小,修正幅度越大,公式完美还原了倾斜带来的面积损失。
2.3 面积计算的C#完整实现
理论讲完直接上代码。这一步我封装了一个静态方法,输入是三维顶点数组,输出是多边形的真实面积:
public static float CalculatePolygonArea(Vector3[] vertices) { int n = vertices.Length; if (n < 3) return 0f; Vector3 normal = Vector3.zero; for (int i = 0; i < n; i++) { Vector3 current = vertices[i]; Vector3 next = vertices[(i + 1) % n]; normal.x += (current.y - next.y) * (current.z + next.z); normal.y += (current.z - next.z) * (current.x + next.x); normal.z += (current.x - next.x) * (current.y + next.y); } float absX = Mathf.Abs(normal.x); float absY = Mathf.Abs(normal.y); float absZ = Mathf.Abs(normal.z); Vector2[] projected; if (absX >= absY && absX >= absZ) { projected = ProjectTo2D(vertices, 0); } else if (absY >= absX && absY >= absZ) { projected = ProjectTo2D(vertices, 1); } else { projected = ProjectTo2D(vertices, 2); } float projectedArea = CalculateShoelace(projected); float scale = normal.magnitude / Mathf.Max(absX, Mathf.Max(absY, absZ)); return projectedArea * scale; } private static Vector2[] ProjectTo2D(Vector3[] vertices, int removeAxis) { Vector2[] result = new Vector2[vertices.Length]; for (int i = 0; i < vertices.Length; i++) { if (removeAxis == 0) result[i] = new Vector2(vertices[i].y, vertices[i].z); else if (removeAxis == 1) result[i] = new Vector2(vertices[i].x, vertices[i].z); else result[i] = new Vector2(vertices[i].x, vertices[i].y); } return result; } private static float CalculateShoelace(Vector2[] points) { int n = points.Length; float area = 0f; for (int i = 0; i < n; i++) { Vector2 p1 = points[i]; Vector2 p2 = points[(i + 1) % n]; area += p1.x * p2.y - p2.x * p1.y; } return Mathf.Abs(area) * 0.5f; }这段代码里有两个细节值得注意。第一,投影时保留的坐标分量组合需要做到“方向一致性”。如果移除的是X轴,投影平面就是YZ平面,坐标对应该取 (y, z),而不是 (z, y)。方向反了会得到一个负面积,绝对值能救回来,但后续如果要做方向判断就麻烦了。第二,面积修正用的是normal.magnitude而不是归一化后的单位向量,这一步容易漏,漏掉之后面积会整体偏小,并且随着多边形倾斜角度增大而越来越离谱。
提示:这个算法对“近似共面”的多边形结果足够准确,但前提是多边形不能严重扭曲。如果连续点的跨度很大且空间翘曲严重,比如一个空间四边形的四个点不在任何一个平面附近,那严格意义上“面积”是多值的,工程上通常拆成三角形再合计更合理。
我这里还踩过一个性能相关的坑:面积计算虽然只涉及几十个顶点,但在Update里反复调用时,每次new Vector2[]都会产生GC。如果测量点数经常超过几十个且需要实时刷新,建议用静态数组复用,或者改用Span、NativeArray。正常使用测量工具的时候频率不高,影响不大,但养成好习惯总没错。
3. 交互链路实现:从鼠标点击到实时测量数值
3.1 射线拾取:准确拿到3D场景里的点
算法再漂亮,也必须有可靠的输入。测量工具的第一步是把鼠标点击转换为3D世界坐标中的点,这里我使用Physics.Raycast:
Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition); if (Physics.Raycast(ray, out RaycastHit hit, 1000f, measureMask)) { currentPoint = hit.point; }看似三行代码,实际项目里有几个地方要处理仔细。
第一是LayerMask。场景中可能存在角色、UI模型、天空盒等不该参与测量的物体,必须给可测量物体单独设置“Measure”层,通过LayerMask过滤。否则点击到无关物体时误生成测量点,用户会非常困惑。
第二是UI遮挡。鼠标点击UI面板时,射线也会穿过UI打中后面的模型,导致一边点按钮一边生成测量点。判断方法是每次点击前用EventSystem.current.IsPointerOverGameObject()检查当前指针是否悬停在UI元素上,是则跳过拾取逻辑。
第三是坐标空间。hit.point是世界坐标,但如果测量对象是某个有缩放、旋转的子物体,就需要考虑是否要换算到物体的本地坐标做进一步计算。多数场景下直接用世界坐标就够,除非你要把测量点“吸附”到某个物体的表面上,那时候可以结合Transform.InverseTransformPoint做精确映射。
一条比较稳的交互策略是:鼠标悬停时高亮当前可拾取的点(用一个小Sphere临时显示),点击后固定这个标记点。这样用户能看到“即将测量哪里”,误操作率大幅降低。
3.2 距离与角度测量:不止是Vector3.Distance
距离计算最简单,两点直线距离直接Vector3.Distance(a, b)。但工程场景里很多时候需要的是“累计距离”,例如测一段通道的长度、一条管线的总长,需要在两点之间连续取点,然后把每一段的长度累加:
float ComputePolylineLength(List<Vector3> points) { float total = 0f; for (int i = 0; i < points.Count - 1; i++) { total += Vector3.Distance(points[i], points[i + 1]); } return total; }还有一种更实用的扩展是“点到直线距离”,用于测量某个设备点离基准线有多远。实现思路是用向量的叉积,距离等于向量AP和向量AB叉积的模除以AB的模:
float DistancePointToLine(Vector3 p, Vector3 a, Vector3 b) { Vector3 ab = b - a; Vector3 ap = p - a; return Vector3.Cross(ab, ap).magnitude / ab.magnitude; }角度测量有三种形式。三点定角是用得最多的:点击A、B、C三个点,测量角ABC的度数,实现方式是向量BA和向量BC的夹角:
float AngleABC(Vector3 a, Vector3 b, Vector3 c) { Vector3 ba = a - b; Vector3 bc = c - b; return Vector3.Angle(ba, bc); }需要区分锐角还是钝角时,用Vector3.SignedAngle或者通过叉积方向判断。二面角测量稍微复杂一点,两个平面各取三个点,先求出各自的法向量,再用法向量夹角计算二面角。实际工程里这个功能比预想中用得多,比如测量一个爬梯与地面的倾角,选三个点比反复调整视线要快得多。
3.3 测量状态机:不同模式下的采点、撤销、清空
三种测量模式不能共用一套无脑逻辑,我建议用一个简单状态机控制交互流程。
我把状态分为Idle、SamplingDistance、SamplingAngle、SamplingArea四种。进入对应模式后,点击左键添加一个点;添加点数满足模式要求后自动计算并显示结果。具体规则如下:
| 模式 | 最少点数 | 结果条件 | 撤销规则 |
|---|---|---|---|
| 距离测量 | 2 | 点数达到2,自动计算直线距离;继续加点则切换为累计距离 | 右键回退最近一个点 |
| 角度测量 | 3 | 点数达到3,以第二个点为顶点计算夹角 | 右键回退最近一个点 |
| 面积测量 | 3 | 点数达到3开始显示临时面积;右键闭合多边形并出最终值 | 右键回退,双键闭合 |
这里有一个交互上的细分:面积测量的“完成”动作建议明确化,不能只依赖点数。实际使用时用户可能点了六个点,不一定是最终轮廓,还需要一个“闭合多边形”的确认动作。我的做法是双击右键或者点击“完成”按钮时,把已有的点序列按顺序闭合,计算面积,并在场景中绘制闭合多边形。
状态机管理点位的增删有一个好处:撤销逻辑统一。无论处于什么模式,右键回退最近一个点,点列表同步更新,所有标记物、LineRenderer、UI数值一次刷新,不会出现“点已经撤销了但标注还留在场景里”的尴尬情况。
4. 可视化与业务扩展:把工具真正用进项目里
4.1 场景标注与实时刷新
测量功能如果没有可视化反馈,基本没法用。用户需要看到三样东西:点在哪里、线怎么走、数值是什么。
点标记我用的是预制体,一个极小的灰色球体或立方体,实例化后放在点位上。如果需要批量测量几十个点,建议做一个对象池,避免频繁Instantiate和Destroy造成GC抖动。
线框用LineRenderer解决。LineRenderer的效率在点数几百以内都扛得住,需要注意两点:一是要控制材质透明度,不然线会挡住模型;二是线的渲染顺序和深度偏移,startWidth/endWidth设成固定值即可。
数值标注我在场景里用TextMesh显示。每个测量结果实例化一个TextMesh,放在最后一个测量点的旁边,并让它始终面向摄像机:
labelTransform.LookAt(2 * labelTransform.position - Camera.main.transform.position);这个技巧能让文字在场景中始终朝向摄像机,用户从任何角度都能读数。如果要更精细,可以给TextMesh加一个半透明背景板,不然模型纹理复杂时文字可读性很差。
实时刷新的核心是“集中刷新”原则。不要每个点在点击后单独刷新,而是用统一的RefreshAll()方法,把当前模式的所有点位、线段、数值一次刷新。这样代码路径清晰,也不容易因为多次刷新产生状态不同步的问题。
注意:在VR项目(比如Pico4)里,鼠标拾取要替换为射线拾取,手柄射线的hit测试逻辑和鼠标Raycast几乎一样,只要把
Camera.main.ScreenPointToRay换成手柄的Ray origin即可。World Space UI的TextMesh在VR里读取有一定距离限制,实测下来用Canvas + World Space模式,配合适当的scale,在3到5米距离上看清楚数值是没问题的。
4.2 在数字孪生与串口联动场景中的扩展
做完基础测量功能后,我在这套工具上叠加了和业务系统的联动,场景价值一下子变大。
最直接的是“测量数据回传”。通过C#调用HTTP接口或直接与串口通信,把测量结果发给上位机或后台数据库。比如一个数字孪生厂房项目里,用户在三维场景中测量了一段管道的长度,点击“提交”按钮,数据就写入SQL Server或通过MQTT推送到后端,用于施工记录存档。这里要注意异步通信不能阻塞主线程,可以用协程或者async/await包裹网络请求,防止UI卡死。
另一个扩展方向是“虚实结合校验”。场景中可能存在一个已安装设备的理想位置,也有通过串口或Modbus(比如nmodbus4)读到的真实传感器数据,将测量值、理论值、传感器值在UI面板里并列显示,比对偏差是否在允许范围内。这个功能在产线装配模拟里非常实用。
我实际项目中还遇到过WebGL发布场景,测量工具跑在浏览器里,模型和标注都要满足WebGL的渲染限制。核心测量逻辑本身没有问题,但要注意System.IO.File在WebGL下不可用,如果要把测量结果导出为CSV或JSON,改用PlayerPrefs或IndexedDB方案。网上不少团队反馈过Unity发布WebGL后写入失败的问题,本质是IO接口不对,换成Unity自带的持久化数据接口就行。
5. 实际项目中的典型问题与排查经验
5.1 顶点顺逆时针与自交多边形的判定
面积算法对顶点顺序有方向敏感性,Newell法向量会随着顶点走顺时针还是逆时针翻转方向。不过面积计算使用了绝对值,所以方向不影响面积大小,但如果后续要用法向量做方向判断,比如判断多边形法向量指向哪边,就必须约定好顶点顺序,并在代码里统一。
自交多边形是另一个经常踩的坑。用户按直觉逆时针点了一串点,形成的轮廓却不一定是简单的凸多边形,可能出现“8字型”交叉,这时候鞋带公式算出的面积在数学上是一个带符号的差值,实际意义已经丢失。我在工具里推荐加一层简单的自交检测:遍历所有不相邻的线段对,检查它们是否相交。一旦检测到自交,提示用户调整点位顺序,而不是默默输出一个错误数值。
5.2 浮点精度、模型缩放与坐标空间
Unity的默认单位是米,但很多CAD模型导入时缩放比例不同,建模软件里1个单位可能代表1毫米或者1厘米。如果模型不是一个整体导入,而是多个子物体拼合,缩放不一致会让测量结果非常诡异。我在工具初始化时加了一个“前缀因子”配置项,让用户针对不同的模型设定单位倍率,面积结果再乘以倍率的平方。
计算精度上,Vector3的float在几十米的场景里够用,但如果你做的项目有超大场景(半径超过几公里),float的精度损失会导致测量点抖动,这时候坐标计算要改用double。一个常见的处理方式是把世界原点搬到场景中心附近,用局部坐标计算,再换算回世界坐标展示。
5.3 UI遮挡、World Space UI与VR拾取的特殊处理
UI遮挡问题在桌面端和VR里属于两类坑。
桌面端,鼠标拾取会被UGUI的GraphicRaycaster拦截,处理方法是EventSystem.current.IsPointerOverGameObject()判断。但这个方法在主摄像机上没有任何UI时可能出现意想不到的行为,建议再用屏幕坐标做一次反向检测,双重保险。
VR端,手柄射线发出后不会“遮挡”问题,取而代之的是“优先拾取”问题。场景里可能存在多个可交互物体,手柄射线一次性射中好几个Collider,需要按照距离排序取最近的。还有一点,Pico4或Quest里World Space UI默认会受到OVRRaycaster的影响,如果UI层级写得不规范,UI自身也会挡住测量射线,这一点和桌面端的遮挡本质相同,但排查起来更隐蔽。
5.4 性能优化:当测量点数量上升到上百个
普通用途十个八个点完全不用考虑性能,但如果你做一个“大面积采集”功能,比如用户在模型上一口气点了几百个点,所有标记物和线同时在线刷新,帧率会明显下降。
我的经验是:标记物一定要对象池化,点数多时考虑用MeshRenderer合批,减少DrawCall。LineRenderer可以拆成多条独立线段,但每段都会产生DrawCall,更好的做法是合并为一条折线,所有标记点用同一个Grid/Instanced部件渲染。实测在100个点时,普通Prefab方案掉到30帧左右,改用合并网格方案后轻松保持60帧。
UI面板的数值刷新也要限制频率,不需要每帧刷新,改为“点位变更时刷新一次”即可,避免高频更新Text导致的CPU浪费。
这套测量工具从最初“被需求逼出来”的小功能,到后来逐渐演变成项目里一个独立模块,最大的感触是:工具类功能的难点不在算法本身,而在交互的完整性。你要在“算法正确”和“用户操作随意”之间架一座桥,让用户随便点,程序都能给出合理反馈,哪怕输入不合法,也要用提示代替报错。
如果你后续要做类似功能,建议先从面积算法入手,把Newell法向量的验证做扎实,可以用一个已知面积的平面多边形做单元测试,确认算法无误后再写交互。交互部分宁可多花时间做状态管理和撤销,也不要把所有逻辑堆在Update里。最后再考虑和业务系统联动,那一步是加分项,前提是前面所有基础都稳了。