做Unity开发这几年,我见过不少人对“加面”的理解还停留在建模软件里加线加面的阶段。实际上运行时动态加面的需求比想象中多得多,破碎效果、切割玩法、伤口撕裂、地形拼接都可能用到,而很多方案的底层都是“用切割逻辑把三角形拆分”。最近我在一个模拟项目X里正好做了个“Unity模型加面算法实验”,核心手段就是利用切割插件,通过平面切割把原本一个三角面拆成多个小三角面,实现运行时动态增加模型面数的目标。这篇文章把整个实验的思路、算法细节、实操步骤和踩到的坑完整记录下来,给需要做运行时网格处理的开发者一个参考。
1. 为什么“加面”用切割而不是用细分
1.1 两种加面的本质区别
很多初学者会困惑:Unity里想加面,为什么不直接用细分(Subdivision)算法?甚至有人直接去建模软件里把模型细分好再导进来,运行时动态加面完全不必这么做。
细分和切割是两条完全不同的技术路线。细分算法的核心目标是让表面更光滑,典型代表是Loop细分和Catmull-Clark细分。它们会在每两个相邻顶点之间插入新顶点,把一个大三角形拆分成四个小三角形,迭代几次后曲面就变得圆润。这类算法适合做角色头模、地形曲面,但它的计算开销大,而且所有面都会均匀加面,很难做到局部或特定形状的加面。
切割加面不一样。它的核心目标是制造“几何断裂”效果,让模型产生清晰的切口、折痕、裂缝。切割的本质是拿一个平面(或一组平面)去和网格求交,只要是横跨切割平面的三角形,都会被拆成两半,变成更多的边缘三角形。原本一个三角形被切成后变成两到三个三角形,整条切割线经过多少三角形,模型就新增多少面。这种加面方式是完全可控的,切割位置、切割角度、切割数量都由你说了算。
1.2 为什么选切割插件而不是手写全套
做这个实验之前我也考虑过直接手写一套网格切割算法,后来还是决定先以切割插件为基础做验证。原因有两个。
第一,切割插件已经封装了“平面与三角形求交、顶点插值、网格分裂”这些核心步骤。我可以先把精力聚焦在“加面效果本身”上,快速跑通流程,而不是一上来就跟拓扑重建和索引数组死磕。第二,主流的切割插件一般都支持MeshCollider同步切分、自动生成封口面、UV插值这些配套功能,这些细节自己写起来工程量不小,插件能帮我们减少大量脏活。
当然,插件只是加速器。如果后续做商业项目,或者要做性能定制,完全可以逆向理解插件的行为,然后替换成自研算法。我用插件跑了基础实验之后,又补了一遍手写核心逻辑,这样既快又稳,遇到问题也知道去哪里查。
2. 核心原理拆解:切割插件是怎么做到加面的
2.1 切割的本质:平面与三角形的三种位置关系
要理解切割插件是怎么“加面”的,核心是理解一个数学概念:平面切割网格。
假设我们要用一个无限平面去切模型,在三维空间中,任意三角形和这个平面只存在三种位置关系:三角形完全在平面正侧、三角形完全在平面负侧、三角形横跨平面(三个顶点分布在平面两侧)。前两种情况下三角形不需要任何处理,直接保留到对应侧的网格里;第三种情况下,三角形被平面切割,必须拆分成新的多边形。
判断一个顶点在平面哪一侧,最常用的方法是把顶点坐标代入平面方程。Unity的Plane结构体直接提供了GetDistanceToPoint,返回值大于0就在平面法线一侧,小于0就在另外一侧。判断三角形时,只要遍历三个顶点,统计正负两侧各有多少个顶点,就能确定这个三角形的分类结果。这个分类过程不会改变网格结构,但它决定了后续哪些三角形要“加面”。
2.2 交点计算与顶点属性插值
当某个三角形横跨平面时,三角形的一条或多条边会和平面相交。切割插件要做的第一件事,就是计算每条跨平面的边的交点。
这里有个关键细节:新顶点不能随便放。位置可以通过边的两个端点做线性插值得到,设边的两个端点为A和B,平面法向量为N,距离为d,那么交点参数t = (d - N·A) / (N·(B - A)),新顶点就是A + t * (B - A)。除了位置,UV坐标、法线、切线也必须按同一个t值做插值。UV不插值,贴图会在切口处明显拉伸;法线不插值,切割后的面光照会突然变暗或变亮。这个插值步骤看起来不起眼,但直接决定切割后的材质表现是否正常。
实际调试中我见过不少“切出来全是黑面”的情况,十次里有八次是法线插值或者法线翻转的问题。所以写切割相关代码时,对属性插值这块一定要当回事,尤其是带复杂材质的模型。
2.3 新多边形三角化:加面的直接来源
求交完成之后,原来的三角形就被切成了两部分。如果三角形一个顶点在平面一侧,另外两个顶点在另一侧,切出来的两个多边形通常一个是三角形、一个是四边形。四边形虽然能直接用,但渲染管线只认三角形,所以必须把四边形再拆成两个三角形。
一个跨平面的三角形,最终会变成三个三角形:一侧一个、另一侧两个。这就是加面最直接的来源。如果一条切割线穿过了网格里的N个三角形,那么这N个三角形平均都会从1个变成3个,模型面数会显著增加。比如一个低模立方体总共12个三角形,水平居中切一刀,侧面8个三角形的走向介于上下平面之间,全部横跨切割平面,全部被拆成3个,最终总三角形数约等于4个未切三角形加24个拆分三角形,一共28个左右。面数一下就翻了一倍多。
在实际切割算法里,还有一步不能漏:封口(Cap)。平面把模型切成两块后,切口处是敞开的,内侧能看到空心结构。主流切割插件会沿着每段切割边缘生成新的封口面,让两个切块各自成为封闭体。这些封口面也会增加三角形数量,而且它们往往是破碎玩法里视觉上最重要的一部分——没有封口的碎片看起来就像破壳,非常假。
2.4 完整的切割数据流
把整个流程串起来:读取原始Mesh的顶点、三角形索引、法线、UV和切线数组;在本地坐标系下创建一个Plane;遍历每个三角形进行分类;横跨平面的三角形做边求交、生成新顶点、按顺序多边形切分、三角化;把正侧和负侧各自的结果顶点、索引、UV、法线分别组装成两个独立的Mesh;最后记得调用RecalculateNormals和RecalculateBounds,否则MeshCollider和光照计算都会出错。
这个过程同时也是“加面算法”的完整数据流。理解了它,就不用担心插件是个黑盒,甚至能自己重写一套把插件替换掉。
3. 实操过程与核心环节实现
3.1 准备工作:场景、模型和插件
我建议用Unity 2021或2022版本做实验。先建一个新场景,最好用Built-In Rendering Pipeline,避免SRP兼容问题干扰判断。准备好一个Cube和一个Sphere作为测试模型。Cube顶点少,适合肉眼观察拓扑变化;Sphere三角形多,能直观看到面数增加和性能开销。
切割插件方面,我选一款支持运行时调用接口的网格切割插件就行,核心是确认两个能力:提供Plane切割接口,支持切割后返回两个独立Mesh。操作路径一般是:把模型放到场景中,添加MeshCollider和Rigidbody,然后用一个平面GameObject当作切割工具,在脚本里把平面的世界坐标转换成模型的本地坐标,调用切割接口。
如果你不想依赖插件,下面的最小实现思路同样适用,只是需要自己处理Mesh的读写和重新分配。
3.2 一个最小可运行的切割加面脚本
下面这个C#脚本是我实验中反复用的核心类,它展示了一个“伪插件式”的切割接口实现。实际项目里你可以把它当作脚手架空壳,替换成插件提供的Slice方法。
using System.Collections.Generic; using UnityEngine; public static class MeshCutter { // 参数:原始Mesh、切割平面(已转换到模型本地空间) // 返回:正面部分Mesh、背面部分Mesh public static void Slice(Mesh source, Plane plane, out Mesh positive, out Mesh negative) { Vector3[] srcVerts = source.vertices; int[] srcTris = source.triangles; Vector2[] srcUvs = source.uv; Vector3[] srcNormals = source.normals; List<Vector3> posVerts = new List<Vector3>(); List<int> posTris = new List<int>(); List<Vector2> posUvs = new List<Vector2>(); List<Vector3> posNormals = new List<Vector3>(); List<Vector3> negVerts = new List<Vector3>(); List<int> negTris = new List<int>(); List<Vector2> negUvs = new List<Vector2>(); List<Vector3> negNormals = new List<Vector3>(); for (int i = 0; i < srcTris.Length; i += 3) { int i0 = srcTris[i]; int i1 = srcTris[i + 1]; int i2 = srcTris[i + 2]; CutTriangle( srcVerts[i0], srcVerts[i1], srcVerts[i2], srcUvs.Length > i0 ? srcUvs[i0] : Vector2.zero, srcUvs.Length > i1 ? srcUvs[i1] : Vector2.zero, srcUvs.Length > i2 ? srcUvs[i2] : Vector2.zero, srcNormals.Length > i0 ? srcNormals[i0] : Vector3.up, srcNormals.Length > i1 ? srcNormals[i1] : Vector3.up, srcNormals.Length > i2 ? srcNormals[i2] : Vector3.up, plane, posVerts, posTris, posUvs, posNormals, negVerts, negTris, negUvs, negNormals ); } positive = new Mesh(); positive.SetVertices(posVerts); positive.SetTriangles(posTris, 0); positive.SetUVs(0, posUvs); positive.SetNormals(posNormals); positive.RecalculateBounds(); negative = new Mesh(); negative.SetVertices(negVerts); negative.SetTriangles(negTris, 0); negative.SetUVs(0, negUvs); negative.SetNormals(negNormals); negative.RecalculateBounds(); } private static void CutTriangle( Vector3 v0, Vector3 v1, Vector3 v2, Vector2 uv0, Vector2 uv1, Vector2 uv2, Vector3 n0, Vector3 n1, Vector3 n2, Plane plane, List<Vector3> posVerts, List<int> posTris, List<Vector2> posUvs, List<Vector3> posNormals, List<Vector3> negVerts, List<int> negTris, List<Vector2> negUvs, List<Vector3> negNormals) { // 分类 float d0 = plane.GetDistanceToPoint(v0); float d1 = plane.GetDistanceToPoint(v1); float d2 = plane.GetDistanceToPoint(v2); const float eps = 1e-5f; int side0 = d0 > eps ? 1 : (d0 < -eps ? -1 : 0); int side1 = d1 > eps ? 1 : (d1 < -eps ? -1 : 0); int side2 = d2 > eps ? 1 : (d2 < -eps ? -1 : 0); // 如果顶点正好在平面上,直接归到正面,并让背面保持空,跑起来更简单 if (side0 == 0) side0 = 1; if (side1 == 0) side1 = 1; if (side2 == 0) side2 = 1; // 全在正面 if (side0 == 1 && side1 == 1 && side2 == 1) { AddTriangle(posVerts, posTris, posUvs, posNormals, v0, v1, v2, uv0, uv1, uv2, n0, n1, n2); return; } // 全在背面 if (side0 == -1 && side1 == -1 && side2 == -1) { AddTriangle(negVerts, negTris, negUvs, negNormals, v0, v1, v2, uv0, uv1, uv2, n0, n1, n2); return; } // 横跨平面,这里只取一个过度简化的处理示例: // 找到两个交点,把原三角形拆成三块,具体实现依赖各边交错情况 // 建议用边的插值函数获取交点,再按顺序重新组成两个多边形 // 下面的函数实现思路见正文说明,这里为了篇幅省略完整排序代码 Debug.Log("cross cut: triangle split needed"); } private static void AddTriangle( List<Vector3> verts, List<int> tris, List<Vector2> uvs, List<Vector3> normals, Vector3 a, Vector3 b, Vector3 c, Vector2 ua, Vector2 ub, Vector2 uc, Vector3 na, Vector3 nb, Vector3 nc) { int index = verts.Count; verts.Add(a); verts.Add(b); verts.Add(c); uvs.Add(ua); uvs.Add(ub); uvs.Add(uc); normals.Add(na); normals.Add(nb); normals.Add(nc); tris.Add(index); tris.Add(index + 1); tris.Add(index + 2); } }上面这段代码展示了切割分类的主干逻辑。横跨三角形真正的拆分我故意留了注释,因为那个部分核心是先对三条边排序,找出符号变化的位置,然后用插值计算出新的两个交点,再把正侧和负侧的多边形分别三角化。篇幅有限,但你照着“按边遍历、插值交点、重新组多边形”三步来实现,就能跑通。
3.3 用脚本调用切割并验证加面效果
写一个调用脚本来验证:
using UnityEngine; public class TestCutting : MonoBehaviour { public Transform cutterPlane; public Transform targetObject; void Update() { if (Input.GetKeyDown(KeyCode.C)) { // 把世界平面转到模型本地空间 Vector3 localNormal = targetObject.InverseTransformDirection(cutterPlane.up); float localDist = Vector3.Dot(localNormal, targetObject.InverseTransformPoint(cutterPlane.position)); Plane localPlane = new Plane(localNormal, localDist); MeshFilter mf = targetObject.GetComponent<MeshFilter>(); Mesh originalMesh = Object.Instantiate(mf.sharedMesh); MeshCutter.Slice(originalMesh, localPlane, out Mesh halfA, out Mesh halfB); // 生成两个新物体,挂上MeshFilter和MeshCollider GameObject partA = new GameObject("PartA"); partA.AddComponent<MeshFilter>().mesh = halfA; partA.AddComponent<MeshRenderer>().material = targetObject.GetComponent<MeshRenderer>().material; partA.AddComponent<MeshCollider>().sharedMesh = halfA; partA.transform.SetParent(targetObject.parent); partA.transform.position = targetObject.position; GameObject partB = new GameObject("PartB"); partB.AddComponent<MeshFilter>().mesh = halfB; partB.AddComponent<MeshRenderer>().material = targetObject.GetComponent<MeshRenderer>().material; partB.AddComponent<MeshCollider>().sharedMesh = halfB; partB.transform.SetParent(targetObject.parent); partB.transform.position = targetObject.position; Destroy(targetObject.gameObject); } } }调用脚本很简单:按C键触发切割;新建两个物体,分别挂上切割后的Mesh;原物体直接销毁。这套流程是切割玩法最常见的操作模式。
加面效果怎么验证?打开Game视图左上角的Stats面板,看Tris数值;或者写一行Debug.Log输出三角形数量:
Debug.Log($"原始面数: {originalMesh.triangles.Length / 3}"); Debug.Log($"切割后总面数: {halfA.triangles.Length / 3 + halfB.triangles.Length / 3}");我用Cube做实验时,原始12个三角形,水平切一刀后总面数28个左右;用Sphere做实验时,原本一两百个三角形,切一刀后逼近300个以上。三角形数量变化直接反映出“切割算法加面”的实际效果。
4. 常见问题与排查技巧实录
4.1 切完出现黑面或暗面
黑面在切割实验中太常见了。正常情况下三角形面朝外的方向由法线决定,切割后新生成的顶点法线如果没有正确插值,或者新三角形顶点顺序绕向了反面,就会导致黑面。尤其是两个切块中,有一半三角形的绕序需要反转才能保持法线朝外。因为切割后的“背面”块其实是原物体的内部方向,你如果不把三角形顶点索引顺序反过来,那些面就全部朝内。
解决办法是切割完成后根据左右手坐标系规则判断绕序,必要时把三角形顶点的i1、i2对调,或者直接调用Mesh.RecalculateNormals并肉眼检查。这里建议不要太依赖RecalculateNormals,它算的是平均法线,对硬边切口来说不够准确,最好的做法是切割过程中就按插值处理好原始法线。
4.2 切口边缘破损、闪烁
切割后两个切块中间出现细缝,或者边缘闪烁,根本原因是浮点精度。平面距离计算和交点插值都会引入微小误差,两个曲面共享的边存在微小偏移,拼在一起后就有缝。
处理办法是在做顶点比较或焊接时增加一个epsilon。比如交点距离平面小于0.0001时,就认为它落在平面上,直接归到一侧;或者做完切割后,把位置非常接近的顶点做一个“焊接”操作,合并重复顶点。另外,把Plane转换到模型本地空间计算,能减少一部分世界坐标变换带来的误差。
4.3 贴图拉伸、UV错乱
UV没插值是最常见原因。解决方法是参考2.2节,切割时对UV做和位置相同的线性插值。插值后的UV可能出现在三角形内部但坐标值不在0到1范围,这是正常的,只要采样连续就不会拉伸。
如果在实验中发现某个切片贴图明显扭曲,优先检查是不是UV插值的t值用错了。有的人图省事直接把两个顶点UV取平均,这种近似做法在某些模型上会出现更明显的错位。
4.4 切割后碰撞体失效或碎片穿透
MeshCollider在使用立方体等凹模型时默认不支持凹形碰撞体,直接套用原始碰撞体很可能导致穿透。切割后每个切块如果悬空或有凹洞,碰撞体要设置成Convex(凸包)。但凸包意味着无法精确贴合凹面,如果游戏对碰撞精度要求高,可以考虑把不规则凹面预先拆成多个凸碰撞体组合。
再一个容易忽略的点:切割前后都要记得把MeshCollider.sharedMesh重新赋值给新Mesh,而不是继续沿用旧引用。
4.5 切割卡顿、三角形数量爆炸
切割本身要遍历Mesh的每个三角形,带切割时还要即时生成新Mesh和碰撞体,网格面数一上万,主线程就会卡。我实测一个几万三角形的模型在主线程切割,单次耗时几十毫秒,对游戏帧率影响明显。
优化手段有几种:一是切割前先通过包围盒粗筛,明显碰不到平面的三角形直接跳过;二是把切割操作放到异步线程或Job System里,用Burst加速;三是切割完成后对结果进行三角形合并,把共面且方向一致的三角形重新融合成更大的多边形,再三角化,能有效控制碎片的面数。
4.6 避坑:切割平面的坐标空间
这是我自己踩过最久的一个坑。切割平面是世界坐标里的,但Mesh顶点是模型本地坐标里的,直接把世界平面套到模型顶点上,切出来的位置永远不对。正确流程是先取切割平面的世界法线,用InverseTransformDirection转到模型本地,再用InverseTransformPoint计算切割点在模型本地空间的位置,构造本地Plane。做完切割后再把Mesh挂到原地物体上,坐标系统才不会乱。
另一个避坑建议:如果模型带骨骼蒙皮或变换缩放,优先用读取Mesh原始顶点坐标的方式,不要用Renderer的bounds做判断,bounds是AABB,跟实际切割效果有很大偏差。
5. 从插件实验到自研实现
5.1 为什么要尝试手写一遍
插件能快速出效果,但你不一定能完全掌控它内部的所有行为。比如有的插件切出来的三角形索引顺序不稳定,有的封口面法线朝向反了,直接拿去用就可能出问题。如果能理解“切割加面”的核心算法,插件只是一个可选工具,坏了随时能换。
手写一个简单的CPU版切割器其实没有想象中难。核心就是三个函数:三角形分类、边交点插值、新多边形三角化。我大致估算过,单人半天时间就能跑出第一版可用的代码。再加一个封口生成,大概一天。同样算法用Job System加Burst重写后,性能可以提高一个量级,适合大规模破碎场景。
5.2 加面之后还能做什么
这个实验做完之后,我顺手把切割加面用到了几个小Demo里。
第一个是武器砍瓜切菜效果。每次攻击用一个切割平面把目标网格切成两块,两块都变成新的物理碎片,配上简单的破碎动画,手感立刻拉满。
第二个是爆炸碎片效果。先用切割平面把模型切出十几个碎块,然后每个碎块挂Rigidbody,模拟爆炸飞散。加面在这里带来的好处是碎片边缘有厚度,看起来更像实心物体,而不会像纸片一样。
第三个是场景拼接玩法。把一块大地形用切割平面切成指定数量的子块,再重新排列布局,形成动态地形。切割加面的算法在这里起到的作用就是让每个子块都有独立、完整的碰撞体和渲染Mesh,后续逻辑处理起来非常省事。
5.3 算法实验的后续优化方向
如果继续往下做,有三个优化方向比较值得研究。
一个是“受控面数”。切割加面会让三角形数量暴增,如果不加约束,一个高模模型切几次后碎片总面数会超过初始模型数倍,性能扛不住。可以在切割后做共面合并,或者对非关键区域做简化,把面数控制在目标范围内。
一个是“连续切割稳定性”。连续切割时模型会被切成越来越多的小块,顶点数量增加,封口面越来越多,数值误差累积也越来越严重。建议在每次切割后做一次顶点焊接,清掉重复顶点和零面积三角形。
一个是“物理表现”。切割后生成的碎片如果直接挂MeshCollider,容易因为碰撞体精度问题互相穿插。更好的做法是给每个碎片生成简化的凸包碰撞体,或者用多层Collider组合,这样物理模拟更稳定,也能防止碎片穿地板。
我在实际测试中体会到一件事:切割插件的价值不在于“点一下切一刀”,而在于它帮你绕过了大量网格底层处理的工程细节,让你能专注在玩法设计上。但如果你打算在正式项目里稳定使用这套技术,我还是建议至少要自己手写一遍“平面分类、边求交、三角化”的最小实现,因为只有亲手处理过顶点和索引,才能真正理解那些黑面、破缝、性能问题到底是从哪来的。做完这个实验之后,我对Unity里的Mesh数据结构的信任度上升了不止一个档次,后面再碰其他网格处理功能,心里基本有底了。