做Unity项目的人迟早会碰到一类需求:在场景里量一量某个模型的长度、角度,或者算一块地形、一个曲面的面积。如果是2D界面测距,用屏幕坐标投射一下就行,但一旦到“3D空间任意多边形”这种量级,Unity编辑器自带的MeasureTool只是最简单的点对点直线距离,根本撑不住场面。我当时在做一个数字孪生项目,需要在场景里直接点选几个空间点围成一个区域,实时显示出这块区域的真实面积,还要能随手量两点的距离、量三条边夹出的角度。翻了Asset Store好几个插件都不顺手,最后索性自己用C#在Unity编辑器里写了一套测量工具。
这套工具的核心是“在Scene视图里可视化点选”加“几何计算实时反馈”。底层用到的数学原理并不复杂,但把交互、绘制、数据刷新拼到一起,涉及的坑还真不少。这篇文章把我从需求拆解、算法选型到完整落地的过程完整写一遍,代码能直接抄走用,后面还附了我开发过程中踩过的一堆雷,应该能帮你省不少时间。
1. 需求拆解:一个3D测量工具到底要解决什么问题
1.1 这类工具最常见的应用场景
在动手前我梳理了一下,Unity项目里需要“3D空间测量”的基本是这几类人:
- 数字孪生和BIM相关项目,需要把模型放到场景里,然后在模型表面选取若干顶点形成测量区域,快速得到面积、周长,用于工程量估算。
- 关卡策划和场景美术,需要验证一个区域是否满足设计数值,比如玩家可站立面积、警戒范围半径、两个出生点的直线距离和视线夹角。
- 建模与程序化生成验证,比如我通过代码生成了不规则多边形网格,需要快速验证生成的形状是否符合预期面积和边角关系。
- 机器人、自动驾驶仿真项目,需要测量路径点之间的角度、偏离距离等几何约束。
这些场景共同的特点是:测量目标不是一个规则几何体,而是用户在场景里临时指定的任意空间多边形,且这些点往往不在同一个平面上——比如在起伏地形上点4个点,面积怎么算?这就引出了核心难点。
1.2 与2D测量方案的本质差异
2D测量多边形面积最常见的方法就是鞋带公式(Shoelace Formula)。它的思路是把多边形看成若干个梯形面积之和,最后用一个行列式累加解决,公式是S = 0.5 * |Σ(xi*y(i+1) - x(i+1)*yi)|。这个算法在XY平面内非常好用,看下面这段代码就能明白:
// 2D鞋带公式示例 public static float PolygonArea2D(Vector2[] points) { float area = 0f; int count = points.Length; for (int i = 0; i < count; i++) { Vector2 p1 = points[i]; Vector2 p2 = points[(i + 1) % count]; area += p1.x * p2.y - p2.x * p1.y; } return Mathf.Abs(area * 0.5f); }但问题来了:Unity场景中用户用鼠标拾取的点是三维的,有x、y、z三个分量。3D空间里四个点大概率不在同一个平面上,直接拿x和y当二维坐标用鞋带公式,算出来的结果会因为“投影到哪个平面”不同而完全不一样。同样一个倾斜的多边形,你投影到XZ平面算出来一个值,投影到XY平面又是另一个值。
1.3 我最终确定的方案架构
针对这些需求,我把工具拆成了三层:
- 交互层:EditorWindow悬浮窗口 + Scene视图内的鼠标点选/拖拽/右键操作,负责收集三维顶点。
- 算法层:提供三个核心计算模块——任意多边形面积、空间距离(点对点)、空间角度(三点角、线面角)。
- 可视化层:Gizmos和SceneGUIText绘制测量标注,把数值实时画在场景里,所见即所得。
算法选型上,面积计算我最终采用了Newell算法,距离计算用标准向量运算,角度计算则区分了情况分别用Vector3.Angle和点积反余弦。后续我会解释为什么这么选。
2. 核心算法解析:三维空间几何计算的意义与原理
2.1 任意多边形面积:为什么用Newell算法而不是鞋带公式
前面提到2D鞋带公式处理不了三维空间中的多边形。解决思路有两条:
- 第一条:把多边形投影到一个合适的二维平面上,再用鞋带公式算,最后除以投影余弦补偿角度。这个方法的麻烦之处在于:你得先判断哪个投影面最优,而且如果多边形是高度扭曲的,投影误差会很大。
- 第二条:直接用Newell方法,它利用向量叉积的性质,直接把三维顶点的坐标代入公式计算多边形的面法矢量,最终面积的数值就是合成法矢量的长度。
Newell算法的原理可以从几何角度理解:每个三角形的有向面积可以用两个边的叉积的一半来表示。把多边形从某一点分解成一组三角形,每个三角形对整个多边形面积的贡献都体现在叉积中,把所有叉积累加,得到的是一个总向量——这个向量的模长恰好等于多边形的总面积。
公式长这样:
Nx = Σ(yi - y_{i+1}) * (zi + z_{i+1}) Ny = Σ(zi - z_{i+1}) * (xi + x_{i+1}) Nz = Σ(xi - x_{i+1}) * (yi + y_{i+1}) 面积 = 0.5 * |N|它巧妙的地方在于:不需要事先判断多边形是否共面,也不需要对投影平面做任何假设,直接把三维坐标扔进去,出来一个三维向量,长度就是面积。我自己用随机生成的曲面多边形测试过,数值很稳。
对应Unity C#代码如下:
// 计算3D任意多边形面积(Newell算法) // 注意:顶点顺序需要是连续的多边形顶点顺序,按顺时针或逆时针均可 public static float CalculatePolygonArea3D(Vector3[] points) { int count = points.Length; if (count < 3) return 0f; Vector3 normal = Vector3.zero; for (int i = 0; i < count; i++) { Vector3 p1 = points[i]; Vector3 p2 = points[(i + 1) % count]; // Newell算法核心:交叉累加 normal.x += (p1.y - p2.y) * (p1.z + p2.z); normal.y += (p1.z - p2.z) * (p1.x + p2.x); normal.z += (p1.x - p2.x) * (p1.y + p2.y); } // 法向量模长的一半就是多边形面积 return normal.magnitude * 0.5f; }这里有个细节值得注意:顶点的行列顺序会影响Normal的方向,但面积永远是正的,因为取了模长。所以理论上来讲,用户不管从哪头开始点,得到的面积数值都是一样的。这个特性对工具来说很重要——用户如果方向点反了结果变成负值,界面上一会儿正一会儿负会非常困惑。Newell算法天然规避了这个问题。
2.2 空间距离:三种情况分别需要不同的计算方式
距离测量在3D空间里常见的有三种:两点距离、点到线的距离、点到面的距离。工具里全部实现了。
两点距离是最基础的,直接取向量模长即可。Unity里Vector3.Distance就是干这个的,底层其实是勾股定理在三维空间的推广——在两根轴上的距离平方相加,再开根号。
public static float DistancePointPoint(Vector3 a, Vector3 b) { return Vector3.Distance(a, b); }点到直线距离需要用到向量的投影。思路是把点到直线上某一点的向量分解成平行于直线方向的分量和垂直于直线的分量,垂直分量的长度就是距离。计算方式是:先算出向量在直线方向上的投影向量,然后从原向量中减去投影向量,剩下的部分长度就是垂直距离。
public static float DistancePointLine(Vector3 point, Vector3 lineStart, Vector3 lineEnd) { Vector3 lineDir = lineEnd - lineStart; Vector3 toPoint = point - lineStart; // 计算投影参数t,把point投影到直线上 float t = Vector3.Dot(toPoint, lineDir) / lineDir.sqrMagnitude; Vector3 projection = lineStart + t * lineDir; return Vector3.Distance(point, projection); }点到平面距离则依赖于平面的法向量。给定平面上任意一点P0和法向量N,任意一点P到平面的距离就是向量(P0P)在法向量N方向的投影长度,也就是点积除以法向量的模长。
public static float DistancePointPlane(Vector3 point, Vector3 planePoint, Vector3 planeNormal) { // 平面方程: (P - P0) · N = 0,距离就是带符号的投影长度 return Vector3.Dot(point - planePoint, planeNormal) / planeNormal.magnitude; }2.3 空间角度:三点夹角与线面角的关键差异
角度测量主要分两种:三点夹角和线面角。
三点夹角指的是从同一个顶点B出发,BA和BC两条边之间形成的夹角。这个用Vector3.Angle就能得到,但要注意Unity的Vector3.Angle返回的是0-180度,并不带方向。如果你需要区分顺时针和逆时针,就得用Atan2结合法向量来判断。工具里因为用户主要看数值,带不带符号意义不大,所以我直接用Vector3.Angle。
public static float AngleThreePoints(Vector3 A, Vector3 B, Vector3 C) { Vector3 BA = A - B; Vector3 BC = C - B; return Vector3.Angle(BA, BC); }线面角是直线和平面之间的夹角。很多人会直接拿向量夹角算,但这里有个大坑:Vector3.Angle返回的直线与法向量的夹角,并不是直线与平面的夹角。它们之间是互余关系——直线与平面的夹角 = 90度 - 直线与法向量的夹角。
为什么?因为平面的夹角是“直线与它在平面上的投影形成的角”,而不是“直线与法向量的角”。我用一个很直白的比喻:太阳光垂直照向地面时(法线方向),你立一根杆子,杆和地面夹角就是90度;但太阳光是从头顶正上方来的,杆与太阳光同一方向,所以杆和“竖直方向”的夹角是0。线面角和线法角永远是互余的。
public static float AngleLinePlane(Vector3 lineDir, Vector3 planeNormal) { float angleWithNormal = Vector3.Angle(lineDir, planeNormal); return 90f - angleWithNormal; }此外还有个细节:空间向量夹角的方向性。在3D场景里用户选三个点,默认只能得到0-180度的夹角。如果想做0-360度的转向角,需要把多边形顶点的顺序考虑进来,用叉积结果判断正负。这个在工具里我先做成了只读展示,后续要扩展的话也可以加。
2.4 关于数值与坐标系的几点说明
- 所有计算都基于Unity的World Space,这样测出来的数值才是场景里的真实尺寸。
- 浮点精度上,Unity默认用单精度float,大尺度场景(比如几千单位)下可能出现精度抖动。工具里我统一把结果保留两位小数展示,避免在界面上暴露浮点噪声。
- 如果场景里物体有旋转或缩放,需要在取顶点时用transform.TransformPoint把局部坐标转成世界坐标,不要直接拿localPosition算。这一点在后面网格导入部分还会再说一次。
3. 工程落地:从0搭出一个可用的Scene视图测量工具
3.1 整体结构:EditorWindow + SceneGUI + 数据层
在Unity编辑器里做工具,最常用的方式是写一个EditorWindow派生类,然后用SceneView.duringSceneGui这个事件去接管Scene视图的鼠标交互和绘制。我把整个项目结构分成三个C#文件,放在Unity项目根目录下的Assets/Editor/MeasureTool/里:
MeasureToolWindow.cs:EditorWindow主面板,负责UI布局、模式切换、数值展示。MeasureToolData.cs:数据模型,负责保存测量点列表和测量类型,以后也可以存到ScriptableObject做持久化。MeasureToolSceneView.cs:Scene视图的交互与绘制逻辑,负责点在场景里的点选、拖拽、删除、实时计算、Gizmos绘制。
我先看数据层的设计。因为需要支持多种测量模式(面积、距离、角度),我定义了一个枚举:
public enum MeasureMode { PolygonArea, // 多边形面积 Distance, // 两点距离 Angle // 三点角度 }在MeasureToolData里用一个List存放当前激活的测量点,再给每个点存一个颜色标记。因为之后要支持多组测量,我把数据结构设计成了“测量组→测量点”的层级,这样用户可以在场景里同时看几组测量结果而不会互相干扰。
3.2 Step1:搭建EditorWindow测量主窗口
主窗口的核心功能不是画场景,而是控制模式和展示计算结果。我用MiniLabel和自定义切换按钮来避免默认UI看起来太工程化。窗口左侧是功能按钮,右侧是计算结果面板,下面还会列出当前已选顶点的坐标,方便用户核对。
using UnityEditor; using UnityEngine; public class MeasureToolWindow : EditorWindow { private MeasureToolData data; [MenuItem("Tools/3D空间测量工具")] public static void Open() { var window = GetWindow<MeasureToolWindow>("3D测量工具"); window.minSize = new Vector2(320, 420); window.Show(); } private void OnEnable() { data = MeasureToolData.Instance; SceneView.duringSceneGui += OnSceneGUI; } private void OnDisable() { SceneView.duringSceneGui -= OnSceneGUI; // 清空Scene视图里的绘制 SceneView.RepaintAll(); } private void OnSceneGUI(SceneView sceneView) { if (data == null) return; MeasureToolSceneView.Render(sceneView, data); } private void OnGUI() { // 顶部的模式切换按钮 EditorGUILayout.BeginHorizontal(); data.mode = (MeasureMode)GUILayout.Toolbar((int)data.mode, new[] { "面积", "距离", "角度" }); EditorGUILayout.EndHorizontal(); // 测量点列表显示... // 计算结果展示... // 清空 / 导入Mesh / 导出数据按钮... } }这段代码有个关键点:一定要在OnEnable注册委托SceneView.duringSceneGui,在OnDisable注销。不然编辑器反复开关窗口后,事件会被重复注册,导致绘制逻辑执行多次,最后卡到爆。
3.3 Step2:Scene视图的顶点拾取与拖拽交互
Scene视图里的交互是这个工具实现起来最费劲、最容易踩坑的部分。核心需求是:
- 左键点击场景中的空处,创建一个新顶点。
- 点击已有顶点,进入拖拽模式,可以移动该顶点到新位置。
- 右键点击顶点,删除该顶点。
- 点的位置要能自动贴合到场景Collider表面(可选开关)。
第一步是用HandleUtility.GUIPointToWorldRay把鼠标屏幕坐标转为一条3D射线。射线和场景里的物体做Physics.Raycast碰撞,取命中点作为顶点位置。这样用户在场景里点到任何有Collider的物体表面,都能把测量点“贴”上去。
public static bool TryPickPointInScene(out Vector3 hitPoint) { var ray = HandleUtility.GUIPointToWorldRay(Event.current.mousePosition); if (Physics.Raycast(ray, out RaycastHit hit, 5000f)) { hitPoint = hit.point; return true; } // 如果没有命中任何物体,就放到一个固定高度平面上 Plane groundPlane = new Plane(Vector3.up, Vector3.zero); if (groundPlane.Raycast(ray, out float enter)) { hitPoint = ray.GetPoint(enter); return true; } hitPoint = Vector3.zero; return false; }这里我特意给了一个“没命中时落到XZ平面”的兜底方案。不带这个兜底的话,用户鼠标点在天空区域就会完全没反应,体验很糟糕。加了之后,至少能在水平面上画出测量点。
拖拽移动用Unity的Handles.PositionHandle实现,但这块有个非常隐蔽的坑:Event.current在SceneGUI回调中会被多次触发,包括Layout和Repaint阶段,如果你在拖拽时不加Event.current.alt和鼠标按钮判断,会在点选、拖拽、缩放场景之间互相干扰。我的处理方式是维护一个“当前交互状态”枚举:None、Hover、Dragging,并在收到鼠标按下时立刻把鼠标捕获。
static void ProcessSceneInput(MeasureToolData data, SceneView sceneView) { Event e = Event.current; int controlID = GUIUtility.GetControlID(FocusType.Passive); switch (e.type) { case EventType.MouseDown: if (e.button == 0) { // 如果命中了已有顶点,进入拖拽 int hitIndex = FindPointIndexAtScreen(data.Points, e.mousePosition); if (hitIndex >= 0) { data.draggingIndex = hitIndex; GUIUtility.hotControl = controlID; e.Use(); } else { // 否则新增顶点 if (TryPickPointInScene(out Vector3 newPos)) { data.AddPoint(newPos); e.Use(); } } } else if (e.button == 1) { // 右键删除顶点 int hitIndex = FindPointIndexAtScreen(data.Points, e.mousePosition); if (hitIndex >= 0) { data.RemovePoint(hitIndex); e.Use(); } } break; case EventType.MouseDrag: if (data.draggingIndex >= 0 && e.button == 0) { if (TryPickPointInScene(out Vector3 curPos)) { data.UpdatePoint(data.draggingIndex, curPos); e.Use(); sceneView.Repaint(); } } break; case EventType.MouseUp: if (e.button == 0) { data.draggingIndex = -1; GUIUtility.hotControl = 0; e.Use(); } break; } }有了这个基础,你就可以在Scene视图里按住鼠标拖动测量点。如果想要更好的捕捉效果,还可以加上Handles.DrawSolidDisc之类的手柄装饰。先不急着上天,后面再逐步优化。
3.4 Step3:用Gizmos把测量结果直观画出来
测量工具最忌讳的是“数字摆那儿,但用户找不到对应的是哪一段”。我的做法是:根据当前模式把测量对象在场景里用线、扇形、标注等方式画出来。
- 点:用
Handles.SphereHandleCap画一个小球,突出显示当前Measure点的位置。 - 多边形面积:把顶点按顺序连成闭合线段,并用半透明材质在内部填充一个Mesh。
- 距离:在两个顶点之间画一条高亮直线,中间显示距离数值。
- 角度:从顶点B出发,用
Handles.DrawSolidArc画一个扇形,直观展示夹角范围。
闭合成面的填充如果用Handles.DrawAAConvexPolygon直接在3D空间画会有三角形剖分限制。更稳妥的方式是动态生成一个Mesh给Gizmos.DrawMesh用。但这里有个细节,如果多边形是凹的,普通三角剖分算法会失效——我最初的版本就是直接按顶点顺序三角剖分,结果遇到凹多边形时填充区域完全不对。后面换成了Triangulator类,先把顶点投影到最佳拟合平面,用2D的ear clipping算法剖分,再转回3D坐标,才解决凹多边形的问题。
这部分代码相对长,我把核心的“把3D多边形投影到2D并三角化”的思路说一下:
// 根据顶点集合计算出最佳投影主轴 // 思路:用Newell法求出多边形法向量, 取法向量绝对值最大的分量作为投影平面法轴 int dominantAxis = GetDominantAxis(polygonNormal); // 例如dominantAxis = 1时, 说明多边形主要在XZ平面上(y方向变化最小) // 把3D顶点投影到垂直于dominantAxis的平面上, 得到2D坐标 // 用2D三角化算法 (ear clipping) 生成三角形索引 // 再把三角形索引映射回3D坐标, 组成一个Mesh交给Gizmos.DrawMesh这个投影三角化方案在绝大部分场景下表现良好。唯一的边缘情况是多边形自交或者共线点过多,这时候不管什么算法都容易出问题,需要在工具里给用户提示“当前多边形无效”,而不是让Mesh变成一个不可名状的形状。
3.5 Step4:从现有Mesh一键导入顶点
手动点选适合快速测量,但有时候用户需要测的是一片实际Mesh的表面。这时候手动逐点点击实在太慢了,而且点不准。我加了一个“导入选中Mesh顶点”的功能:选中场景里一个带MeshFilter的模型,打开工具后点“导入顶点”,工具会把Mesh的所有顶点坐标(世界空间)按顺序加载进来。
但直接导入全部顶点在复杂模型上会有大量冗余点,所以我做了个简化:按一定距离阈值抽稀。具体做法是把Mesh顶点按三角形索引遍历,遇到距离当前集合太近的点就跳过,太远的就加入。这个阈值用户可以调,默认0.1米,对于大多数场景刚好。
public void ImportMeshPoints(MeshFilter meshFilter) { Vector3[] vertices = meshFilter.sharedMesh.vertices; Transform owner = meshFilter.transform; for (int i = 0; i < vertices.Length; i++) { Vector3 worldPos = owner.TransformPoint(vertices[i]); bool isDuplicate = false; for (int j = 0; j < Points.Count; j++) { if (Vector3.Distance(worldPos, Points[j]) < threshold) { isDuplicate = true; break; } } if (!isDuplicate) Points.Add(worldPos); } }除了物理Mesh导入,我还加了从Collider.bounds八个角点导入的功能,用于快速测量包围盒的对角线距离和截面面积。
4. 实测过程:拿真实场景验证工具精度和使用体验
4.1 测试环境的搭建
为了验证工具的正确性,我建了一个专门的测试场景,包括:
- 一块10x10米的平整地面,用于测矩形面积,理论面积100平方米。
- 一个倾斜的立方体(旋转45度),用于测试三点角度和空间距离是否跟手算一致。
- 一个带凹槽的地面模型,用于测试凹多边形面积。
- 若干个随机点组成的空间五边形,用于测试Newell算法在非共面多边形上的稳定性。
4.2 多边形面积精度测试
我先在地面上点A(0,0,0)、B(10,0,0)、C(10,0,10)、D(0,0,10)四个点。这显然是一个10x10的正方形,面积理论上是100。工具算出来:
- 用Newell算法算出来:100.00
- 用2D投影鞋带公式(投影到XZ平面)算出来:100.00
完全一致。接着我测试倾斜四边形:把四个点整体绕X轴旋转45度。此时2D鞋带公式直接拿x、z坐标算会得77.15(投影缩小的结果),而Newell算法得到的是100.00——因为三维空间里的真实面积并不会因为物体旋转而改变,Newell算法正确反映了这一点。这个对比已经足以说明为什么必须用Newell算法了。
凹多边形测试用L形地面,理论上面积 = 大长方形 - 缺口。我手动算出理论面积是120,工具算出来是119.98,误差可以接受,原因是我点选的时候在坐标上不可避免有一点浮点误差。
4.3 距离与角度测试
两点距离测试:在场景里放两个相距6.5米的空物体,工具测出来6.500米。距离测量的精度几乎完全由你点击顶点的精度决定,算法本身不会引入额外误差。
角度测试:我在XY平面上设置A(0,0,0)、B(1,0,0)、C(0,1,0),实际上BA和BC夹角是90度。工具算出来90.00°,完全一致。接着把C改成(1.732,1,0),期望夹角30度,工具给出30.00°。说明三点夹角的向量算法完全够用。
4.4 特殊情况的处理
- 退化的多边形:比如三个点近乎共线(坐标几乎在一条直线上)。工具算出来面积会非常小(接近0),但不至于NaN或负数。这块需要做参数校验。
- 零距离:用户把两个点放到同一位置,距离返回0。工具不会崩,但界面上应该提示用户调整点位。
- 超远距离:我把测试点放到相距5000的位置,工具返回数值精度正常,说明World Space下的坐标范围没有明显精度问题。
- 旋转物体上的测量点:我直接在带旋转角的物体表面放点,拖动手柄时坐标是从世界坐标直接取的,所以算出来的距离和世界尺寸一直正确。
5. 踩坑记录:开发过程中遇到的高频问题与解决思路
5.1 Scene视图不刷新,改点后界面数值不更新
这是编辑器工具里最经典的坑。你在Scene视图里拖拽顶点,数据变了,但Scene视图和EditorWindow界面不刷新,看起来很糟。原因在于Unity编辑器只在特定事件下主动重绘,你自己的拖拽操作如果没触发任何UI事件,就不会自动Repaint。
解决方式是:在鼠标拖拽、添加点、删除点、修改点等所有数据变更位置,显式调用SceneView.RepaintAll()和Repaint()。
private void MarkDirty() { EditorUtility.SetDirty(this); SceneView.RepaintAll(); Repaint(); }我之前偷懒没加,每次都要手动点一下场景窗口才刷新,后来实在受不了,把所有数据变更函数统一封装了一下,全都加上这行。
5.2 顶点拾取与场景物体深度遮挡的冲突
用户点选顶点时可能会点到物体背后的点——比如一个平面挡住了后面的测量点。默认用Physics.Raycast是能正确判断深度遮挡的,但有个特殊情况:当测量点本身就在某个Collider内部(比如你要测一个房间内部的点),Raycast会因为被外墙挡住而测不到。
我的处理方式是加了一个“穿透模式”开关。开启后拾取射线用Physics.RaycastAll,取所有命中点中与当前测量点颜色相同的那一个。这个方法有点暴力,但实际用下来能解决大部分内表面测量的问题。另一个更温和的方案是给测量点加忽略遮罩的Layer,但那要求整个项目都要配合改,侵入性太大。
5.3 多边形顶点顺序影响面积符号,但对面积大小无影响
Newell算法中,法向量方向由顶点顺序决定,所以求magnitude能规避符号问题。但如果是用三角面积累加的方式,Vector3.Cross返回的向量会随顶点顺序方向反转,如果直接累加模长,重叠部分会被重复累加。所以我强烈建议只用Newell算法的最终实现,不要自己改成“累加每个三角形的面积”。
如果你要显示法向量方向,可以在工具里单独把normal.normalized用一个箭头画出来。这个对有些领域(比如风环境模拟里某个面的朝向)很有用。
5.4 角度计算出现NaN,排查后发现是零长度向量
如果两条边中有一条长度是0,比如A和B在同一个位置,那么BA或BC向量的模长为0,Vector3.Angle内部做归一化时会除以0,结果就是NaN。这在用户操作里经常出现——不小心把两个点拖到完全重合。
我在工具里加了校验:如果任一条边长度小于0.0001f,显示“角度无效”,并高亮提示用户当前两个点过于接近。
public static bool TryGetAngle(Vector3 A, Vector3 B, Vector3 C, out float angle) { Vector3 BA = A - B; Vector3 BC = C - B; if (BA.sqrMagnitude < 0.0001f || BC.sqrMagnitude < 0.0001f) { angle = 0f; return false; } angle = Vector3.Angle(BA, BC); return true; }5.5 工具数据怎么存储与复用
如果用户关掉Unity再打开,之前点的测量点都没了,这个工具体验就不完整。我最终用ScriptableObject来做数据持久化,把测量点列表和模式信息都存进Assets下的一个资源文件里。这样同一个项目里所有场景都能复用这些测量数据,还能用Asset文件做版本管理。
[CreateAssetMenu(fileName = "MeasureData", menuName = "Tools/测量数据")] public class MeasureToolData : ScriptableObject { public List<Vector3> Points = new List<Vector3>(); public MeasureMode mode = MeasureMode.PolygonArea; public bool showLabels = true; // ... }但要注意:Scene视图里的点在保存时是存放在这个ScriptableObject里的,如果换了一个场景,最好清空或分开存储,不然会出现跨场景显示旧测量点的奇怪现象。我用的方案是给每个场景创建一个独立的测量数据文件,文件名带场景名后缀。
5.6 常见问题速查表
这里把开发过程中遇到的常见问题统一整理成一张表,方便后面排查。
| 现象 | 可能原因 | 解决方法 |
|---|---|---|
| Scene视图不刷新,拖点后无反馈 | 缺少Repaint调用 | 在数据变更后调用SceneView.RepaintAll()和Repaint() |
| 拾取不到物体表面 | 物体没有Collider或射线超长 | 给物体加Collider;把Raycast距离设置成足够大 |
| 多边形面积总是偏小 | 多边形非共面且用了2D投影算法 | 改用Newell算法,不做投影近似 |
| 角度显示NaN | 两条边中有零长度向量 | 加长度校验,长度过小时提示无效 |
| 凹多边形填充区域不对 | 直接按顶点顺序三角化 | 用Ear Clipping算法先2D三角化再映射回3D |
| 导入Mesh顶点后点太多 | 没做抽稀 | 按与已有点的距离阈值跳过邻近点 |
| 窗口关掉后测量点丢失 | 数据没持久化 | 改用ScriptableObject保存数据 |
| 拖拽点的时候场景视角跟着转 | 没正确捕获Event和hotControl | 拖拽时设置GUIUtility.hotControl,结束后释放 |
5.7 关于性能优化与多实例扩展
工具运行时的计算量其实很小,几百个点也毫无压力。主要性能损耗在Gizmos的绘制上。如果一次导入了几千个顶点并开启“显示全部测量标注”,Scene视图的帧率会明显下降。我的优化策略是减少每帧不必要的绘制调用,把静态显示内容缓存到RenderTexture或直接只绘制当前模式下需要的元素。
更进一步,我还做了“测量组”机制——每个测量组独立占一个颜色,可以单独隐藏/显示。实测下来在几百个测量组同时存在的场景里,Scene视图依然流畅。
最后分享一个实用的小技巧和一点个人体会
这个工具写完之后,我最大的感受是:很多看起来“高大上”的3D测量插件,底层其实没多少玄学,关键的数学选型只要一步选对,整个工具就稳了。Newell算法、向量点积、叉积,大学线性代数第一二章的内容,撑起了一个看似复杂的编辑器扩展。
最后再分享一个小技巧:如果你把工具里SceneView.duringSceneGui改造成独立窗口和EditorWindow可同时存在,再配合Unity 2022+的UI Toolkit,界面能做得相当精致。但如果你只是内部使用,IMGUI的编辑器窗口够用且代码量更少。我个人的建议是别在UI上花太多时间,先把测量精度做对,数值和绘制反馈做好,工具的实际价值就已经出来了。
后续如果还有闲工夫,我想在这个工具基础上扩展“容积测量”和“连续路径长度测量”,原理都是一样的,只是多一步沿路径累加。到时候再写一篇新的实测记录。