十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Unity 切割模型不靠插件:平面裁剪网格切分与物理分离全解析

Unity 切割模型不靠插件:平面裁剪网格切分与物理分离全解析 简介一份面向Unity初学者的模型切割学习案例聚焦碰撞检测、鼠标交互与Mesh实时更新等核心知识点。案例预设多款基础几何体模型通过左键蓄力、右键触发切割的交互设计演示从切割路径计算、顶点三角形遍历到网格拆分重建的完整流程适合希望掌握Unity物理模拟与图形编程基础的开发者。资源包共595个文件压缩后约22MB主要包含场景与预设unity、prefab、C#脚本cs、模型与动画fbx、anim、材质与贴图mat、tif、jpg、png以及配置说明文档等结构较为完整便于直接导入工程或逐类研读。已有1310人学习浏览。通过研究该案例可以学到Mesh Collider搭配碰撞检测实现切割的工具思路以及通过输入管理蓄力并触发切割的事件逻辑对理解Unity中网格数据修改和交互反馈流程有直接帮助。1. Unity 切割模型案例为何不依赖插件一次把网格切分和物理分离讲透如果只让我挑一个最能展示“原来 Unity 里还能这么玩”的案例我会选切割模型鼠标点到西瓜上一刀切下去两半个瓜带着惯性飞到地上切口颜色干干净净。这个需求听上去像是插件才能搞定的事实际并不需要依赖任何现成切割插件核心逻辑就是一次网格数据结构拆分外加一层刚体和碰撞体的激活时机处理。这篇文章要交代的是一个可复现的最小切割方案三角面怎么分类、封口怎么补、切块怎么让物理生效以及那些让人想砸电脑的破面、发黑和卡顿问题到底出在哪。2. 切割方案选型与几何原理平面裁剪、三角形分类与网格重建2.1 三种最常见的切分方案为什么平面裁剪更适合通用案例拿到切割需求先别急着写代码。市面上能跑的方案大致分三类预切碎块、二叉空间分割、平面裁剪。我按自己评估的结果拉了一张对比表你在立项阶段可以照这个口径做决定。方案适用场景主要成本常见局限预切碎块固定破坏动画、低端移动端项目需要美术额外产出碎块模型切缝固定玩家自由刀路支持不了二叉空间分割关卡反复被切、切割机持续作业维护分割树递归写回网格对拓扑要求高问题定位困难平面裁剪近战武器切敌人、切水果、道具分解单次切割逻辑代码量很小每刀都需要生成封口面和缝合边我的选择很明确通用的 Unity 切割模型案例用平面裁剪。原因在于大部分业务场景只需要“切一刀出两个物体”偶尔需要连切几刀这种量级用二叉空间分割属于过度设计反而会把网格分裂、子网格材质对应这些无关问题全卷进来。平面裁剪的核心动作是拿一个无限大的平面去切一个三角形网格数学上简单出问题也容易定位。那一刀切下去到底发生了什么切割平面在碰撞点定位法线方向就是刀刃方向网格上每个三角形和这个平面的关系只有三种整体在正半区、整体在负半区、被平面穿过。前两种直接原样搬入各自的结果网格第三种需要把交点算出来拆成两个三角形分别归类。整个过程不修改原网格而是产出两个全新网格原物只做隐藏或销毁。2.2 顶点到切割平面的距离三角形三种命运平面裁剪的数学基础不复杂。切面由一个点planePoint和单位法线planeNormal定义任意顶点到它的有向距离是dot(planeNormal, vertex - planePoint)这个距离的正负就决定了顶点在哪一侧。private static float GetDistance(Vector3 planeNormal, Vector3 planePoint, Vector3 vertex) { return Vector3.Dot(planeNormal, vertex - planePoint); }这段代码不需要外部依赖Vector3.Dot是 UnityEngine 里现成的点积接口。严格写法是先对planeNormal做normalized再参与运算但调用方通常已经把法线归一化过所以这里不再重复处理。真正值得关注的是返回值只差一点点符号的边界情况我会在后面避坑章节细讲浮点阈值。拿到三个顶点的距离后三角形就有了明确分支d0、d1、d2全部大于等于 0整体归入正半区全部小于等于 0整体归入负半区既有正又有负进入裁剪分支。裁剪分支要找到平面与三角形两条边的交点这两个交点是新顶点的位置来源。交点坐标为lerp(A, B)或更准确的A (B - A) * t其中t dA / (dA - dB)。之所以用 dA 作为分子是因为想让 t 落在 0 到 1 之间必须保证 dA 和 dB 异号这是前面分类保证过的。如果直接写distance01这类通用插值代码容易忽略 UV 也要一起插值导致切出来的表面纹理是歪的。2.3 切割后的拓扑重建顶点复用与封口思路得到两侧三角形后网格还不能直接显示成“两半”。原始网格是一个闭合表面切穿它之后两侧都各自出现一个裸露的洞这就是切割断面。为了让视觉和物理上都成立必须给每侧补一个封口面把露出来的洞缝住。封口面本质上是切割平面上那条交线围出的多边形但三角形网格的封口不能直接把多边形当三角扇画因为原始网格的顶点顺序和 Unity 需要的逆时针顺序不一定匹配。常见做法是把所有新交点收集起来投影到切割平面上的二维坐标系按极角排序再按顺序生成三角扇。这里有一个很容易忽略的问题新顶点不能无脑Add进顶点数组并让三角形引用它因为三角形共享边的概率很高尤其在同一平面上的封口区域。如果每个三角形都生成独立新顶点缝合处会出现裂缝物理碰撞还会出现抖动。下一章我会直接给出带顶点复用逻辑的完整实现让同一个位置的顶点只新增一次。3. 手写 PlaneCutter 脚本从逐三角形裁剪到封口补洞的可运行代码3.1 自定义网格数据结构为什么不直接修改 Unity 的 Mesh写切割逻辑时我习惯先定义一个轻量的网格容器而不是直接操作Mesh类的顶点数组和三角形数组。原因是切割过程要频繁增删元素每次mesh.vertices 新数组都会触发一次底层数据副本主线程上堆积起来非常伤。自定义容器还能顺便维护一个顶点字典用来合并重复顶点这是 Mesh 自身接口不方便做的事。public class MeshSliceData { public ListVector3 vertices new ListVector3(); public ListVector3 normals new ListVector3(); public ListVector2 uv new ListVector2(); public Listint triangles new Listint(); public int AddVertex(Vector3 pos, Vector3 n, Vector2 uvPos) { vertices.Add(pos); normals.Add(n); uv.Add(uvPos); return vertices.Count - 1; } }这个容器承担了两侧结果网格的装载任务。AddVertex返回的新顶点索引要立刻被三角形引用所以顺序很重要顶点数组的索引就是三角形数组真正使用的值。正常项目里还会扩展出切线和顶点颜色两个列表这里从简只保留位置、法线、UV 三项方便读者把注意力放在切分本身。为什么不直接往Mesh.vertices里写因为切割过程中原始三角形是遍历处理的每处理一个三角形可能新增 1 到 3 个新顶点直接操作 UnityEngine 的数组会产生频繁的重新分配。而且Mesh的三角形索引是扁平化的int[]追加拼接还需要自己维护长度容易算错。用 List 加索引返回的方式逻辑顺很多。3.2 切割主循环逐三角形分类与交点插值核心函数接收原始网格、切割平面参数输出两侧的MeshSliceData。这里的关键设计是循环里只负责“分类和生成顶点”不直接写Mesh这样单测也好写出问题也好定位。private void ClipTriangle( Vector3[] verts, Vector3[] normals, Vector2[] uv, int i0, int i1, int i2, Vector3 planeNormal, Vector3 planePoint, MeshSliceData frontData, MeshSliceData backData) { float d0 GetDistance(planeNormal, planePoint, verts[i0]); float d1 GetDistance(planeNormal, planePoint, verts[i1]); float d2 GetDistance(planeNormal, planePoint, verts[i2]); if (d0 0f d1 0f d2 0f) { frontData.AddVertex(verts[i0], normals[i0], uv[i0]); // 这里只展示了一个顶点的写法实际需要连续三角 } else if (d0 0f d1 0f d2 0f) { // 整体在负半区写入 backData } else { ClipCrossingTriangle(/* 把交点算出来分片写入两侧 */); } }距离计算使用的GetDistance就是上一节那个函数。整体同侧时直接把原顶点挂到对应数据里这里没有做深拷贝因为顶点是值类型写入新数组即完成了复制。真正的裁剪发生在ClipCrossingTriangle中。假设三角形三个顶点分别是 A、B、C其中 A 单独在一侧边 AB 和边 AC 分别与平面相交产生两个交点 P 和 Q。此时原始三角形被劈成两部分一个三角形和一个四边形四边形需要拆成两个三角形。我这套代码为了通用把所有交点都插值出来再按顶点组合重新装配三角形。private Vector3 InterpolateVertex( Vector3 a, Vector3 b, float da, float db, Vector3 na, Vector3 nb, Vector2 uva, Vector2 uvb, MeshSliceData data) { float t da / (da - db); data.AddVertex( Vector3.Lerp(a, b, t), Vector3.Lerp(na, nb, t).normalized, Vector2.Lerp(uva, uvb, t)); return data.vertices[data.vertices.Count - 1]; }这里的t是关键参数da / (da - db)在 da 与 db 异号时永远落在 0 到 1 之间代表交点从 A 到 B 移动的比例。用这个比例同时插值位置、法线、UV 三个属性保证切出来的边缘和原表面无缝衔接。法线插值后再 normaliize 一次是因为线性插值可能导致长度不等于 1放进光照计算会偏暗或偏亮。如果某条边刚好和平面平行也就是Mathf.Abs(da - db)小于一个极小值可以跳过该边不让它参与交点生成。这个判断要放在插值之前因为除零会产出 NaN一个小数点错误会让整片三角形变成黑色。3.3 封口面的生成交点排序与三角扇缝合处理好所有三角形后两侧缺口处都只是一堆散点必须按顺序把它们连成面。我的做法是把本次切割产生的所有交点单独存一个列表排序后生成三角扇。排序方法是用切割平面上的投影直角坐标计算Atan2极角。private ListVector3 BuildCapVertices(Vector3 planeNormal, Vector3 planePoint, ListVector3 loopPoints) { Vector3 center Vector3.zero; for (int i 0; i loopPoints.Count; i) center loopPoints[i]; center / loopPoints.Count; Vector3 e1 Vector3.Cross(planeNormal, Vector3.up).normalized; if (e1.sqrMagnitude 0.01f) e1 Vector3.Cross(planeNormal, Vector3.right).normalized; Vector3 e2 Vector3.Cross(planeNormal, e1).normalized; loopPoints.Sort((p, q) { float angleP Mathf.Atan2(Vector3.Dot(p - center, e2), Vector3.Dot(p - center, e1)); float angleQ Mathf.Atan2(Vector3.Dot(q - center, e2), Vector3.Dot(q - center, e1)); return angleP.CompareTo(angleQ); }); return loopPoints; }这段代码含金量在e1和e2的选择先从平面法线和世界坐标Vector3.up做叉积得到一个落在切割平面内的基向量再用它和平面法线叉积得到第二个基向量。如果法线恰好平行于 up 轴叉积结果接近零向量就换用Vector3.right重新计算避免所有点投影后挤在同一条线上。排序后三角扇的生成顺序是以loopPoints[0]为中心点依次连接loopPoints[i]和loopPoints[i1]把整个多边形拆成一圈三角形。封口面的法线方向我建议先默认指向切割平面的负方向因为切割面自身朝向是原物体的外侧法线封口要朝内才算闭合视觉上前期可以把Cull Off开着观察方向不对再翻转。4. 切割碎片落地刚体、碰撞体与双面材质处理的组装细节4.1 从网格数据到可见切块重新生成 Mesh 与 Editor 调试顺序前后两侧的MeshSliceData完成后要各自转成真正的Mesh并挂到新 GameObject 上。这里有个容易翻车的细节切割和生成 Mesh 尽量不要在同一个函数里闷头写完分两步走中间用 Debug 画线把切面可视化。private GameObject SpawnPiece(MeshSliceData sliceData, Material mat, Transform original) { Mesh mesh new Mesh(); mesh.name original.name _Piece; mesh.vertices sliceData.vertices.ToArray(); mesh.normals sliceData.normals.ToArray(); mesh.uv sliceData.uv.ToArray(); mesh.triangles sliceData.triangles.ToArray(); mesh.RecalculateBounds(); GameObject piece new GameObject(original.name _Cut); piece.transform.SetPositionAndRotation(original.position, original.rotation); piece.transform.localScale original.localScale; piece.AddComponentMeshFilter().sharedMesh mesh; piece.AddComponentMeshRenderer().sharedMaterial mat; return piece; }代码里的sharedMesh和sharedMaterial要刻意使用共享版本避免误用mesh和material造成实例化开销。切割瞬间如果有十多个碎片同时生成实例化材质会让 Draw Call 翻倍这是明显的性能损耗。生成完网格后必须调用一次RecalculateBounds。不调用的后果是 MeshRenderer 的包围盒还是默认的最小值相机裁剪会莫名其妙把切块裁掉。至于RecalculateNormals我建议先不调用尽量沿用从原始网格插值出来的法线这样原表面的光照不跳变如果之后发现切块边缘发黑再改用RecalculateNormals兜底。4.2 切块的物理化Rigidbody 与碰撞体激活顺序切块生成后需要接上物理才能掉落。常见做法是给两半物体各加MeshCollider和Rigidbody。这里我踩过一个很深的坑如果网格还没生成完就先挂MeshColliderUnity 会报空网格错误而如果 Rigidbody 已经激活碰撞体却还没赋值切块会像幽灵一样直接穿过地面下坠。var rb piece.AddComponentRigidbody(); var col piece.AddComponentMeshCollider(); col.sharedMesh mesh; col.convex true; rb.collisionDetectionMode CollisionDetectionMode.ContinuousDynamic;物理参数里最值得说明的是col.convex true。切割生成的碎块轮廓不规则MeshCollider 默认只支持非凸碰撞体但非凸碰撞体不能和刚体一起参与旋转碰撞计算。对大多数切块来说把convex设为 true 就能获得合理的物理响应。如果碎块形状太复杂导致碰撞抖动更可靠的方案是把碰撞体替换成 BoxCollider用mesh.bounds的大小去近似性能还能进一步下降。CollisionDetectionMode.ContinuousDynamic适合速度较快的切片飞溅场景。如果是切水果这类小体积物体默认的Discrete也能跑但物体飞出去撞墙时偶尔会穿透这一点每个人项目的尺度不一样需要自己实测。4.3 双面材质与切面观感切出来为什么是半透明或者黑色平面裁剪完成后切口面和原表面是同一个 Mesh 的正反两个方向而 Unity 默认材质是单面渲染的从切面内侧看过去会直接看到背面被剔除呈现黑色空洞或看不见封口。解决方式有两个给切面单独一个双面渲染的材质或者关闭 Shader 的背面剔除。Shader Custom/DoubleSide { SubShader { Tags { RenderType Opaque } LOD 100 Cull Off Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag // 这里省略完整的 CG 代码核心就是去掉 Cull Back ENDCG } } }如果只要快速验证直接用一个Unlit/Transparent材质再把 MeshRenderer 的材质数组里第二块设成半透明也能糊弄过去但这会让切口显得很假。正规项目的做法是把原始材质复制一份把 Cull 改成 Off然后作为第二块材质赋给 MeshRenderer。注意 MeshRenderer 的材质数组数量和 mesh 的 subMesh 数量必须相等切割生成的 mesh 默认只有一个 subMesh所以要么把材质数组长度扩到 2要么不切 subMesh只在 Shader 层面解决。如果原始模型带贴图切面区域是没有任何 UV 的因为那些顶点只在封口时产生没有映射到原始贴图上。这时候双面材质只是一个兜底后面我会讲怎么给切口单独画一条贴花让断面有水果肉或者木茬纹理。5. 切割模型常见问题与避坑排查四个最容易翻车的现场5.1 现象切出来的碎块发黑像个黑匣子切完以后碎块表面大面积发黑甚至完全黑掉只有边缘有颜色。这个现象在带法线贴图的模型上特别明显因为我把原始法线插值到了新顶点但新顶点的切线向量没有同步插值导致法线贴图采样出错切线空间把法线挤到了错误方向。原因分析切线数据是网格皮肤的第四套属性只复制位置和法线切线的遗留值会发生错位。解决方式是连同tangents数组一起插值切割函数里把Vector4.Lerp同样写一份。之前偷懒跳过切线处理的切水果案例十个有九个黑脸问题基本都出在这里。如果插值切线后仍然发黑另一个排查点是Mesh.RecalculateNormals后的顶点法线方向与三角形绕序不一致。修复方法是把切块的所有三角形索引做一次反转测试在编辑器里写一个临时按钮挨个翻转直到光照正常为止。这不是玄学是绕序和法线配合问题。5.2 现象切口可以看到内部空腔封口根本没生成从一个球形物体中间切一刀正面看没问题转到底部却发现内部是空的能直接看到另一边。这个现象说明封口生成逻辑没被执行或者执行的时机晚于网格赋值。原因大多数方案在遍历三角形时统计交点但如果原始网格本身没有封闭比如只有单面的平面片它压根没有“内部”概念切出来当然没有封口。解决方法是先检查原始Mesh是否具备闭合条件最直观的就是看MeshRenderer从反面看是否正常如果本身透明就不该走平面裁剪封口逻辑。另一个原因是封口三角扇的绕序错了导致从切口内侧看时三角形被剔除。这类问题排查效率最高的办法是临时把 Shader 的Cull Off打开如果内部空腔消失说明方向问题直接翻转封口三角形的顶点绕序即可。我一般会在编辑器里写一个快捷键跑三角形反转比手动改代码快很多。5.3 现象切割瞬间掉帧明显大模型直接卡出尖峰见过很多项目在切割 5 万顶点级别的模型时主线程卡顿超过 200 毫秒玩家体感就是一刀砍下去画面凝滞。原因是遍历全部三角形、生成两个新网格、加上 RecalculateNormals 重算法线都在同一帧内执行。原因分析切割本身是 CPU 密集任务且一次性创建大量 GameObject 会产生 GC 压力几百个网格同时分配内存主线程当然扛不住。解决思路是给切割操作加边界条件控制可切割模型的面数上限比如超过 3 万面就不允许切或者把切割过程拆分到两到三个帧里第一帧算交点第二帧生成封口第三帧挂物理中间用协程 yield 分隔。真实项目里我会优先限制单次可切割物体数量而不是花大力气做分帧因为玩家几乎不可能同时切十几个复杂物体。给切割入口写个排队锁后一次切割等前一次完成再执行收益比分帧高得多代码也简单。5.4 现象切出来的两半看起来分开了但对撞后相互穿透视觉上两个碎块已经分离但掉在地上时重叠或者互相穿插后挤在一起弹不出来。这个现象的排查重点不在网格而在碰撞体的生命周期。原因切块生成的同一帧Rigidbody 还未完成唤醒MeshCollider 的凸包数据没更新物理系统用的还是旧包围盒。解决方法是延迟一帧再触发刚体里的Sleep唤醒或者生成切块时先不要加刚体在下一帧AddComponent保证 Unity 物理管线完成热更新。还有一个隐蔽问题切割平面穿过了模型中心两半各自的 MeshCollider 在切面位置共享了一条边碰撞体之间存在轻微重叠连续碰撞检测也处理不了这种拓扑接缝。解决方法是把两个切块的位置沿切割法线方向各推开一个极小的偏移量比如0.01f乘以法线让碰撞体不再无缝贴合。数值要小到不影响视觉又要大到物理系统能区分。5.5 现象移动端上切块掉落后频繁穿地这个问题最容易在低端安卓机和 Pico4 这类设备上出现。切块被击飞后速度很快MeshCollider 的凸包在移动端上计算精度下降导致穿地。原因移动端物理引擎的碰撞精度本身低于桌面端加上碎块顶点数量大碰撞体更新占用了过多帧率。解决方法有两个一是把切块碰撞体换成盒体胶囊这类基础几何体用一个近似 BoxCollider 去模拟不规则碎块二是给 Rigidbody 设置interpolation为Interpolate并且把碰撞检测模式从Discrete提升到Continuous代价是 CPU 消耗增加。移动端项目我通常建议做碰撞体简化分支用mesh.bounds.size直接生成 BoxCollider虽然撞击精度没那么准但胜在稳定。与其追求完美的凸包碰撞不如保证玩家看到的是“切开后自然落地”这个优先级更高。6. 让切口更像真刀切出来的切面贴花与切割轨迹的进阶验证封口面有了双面渲染也开了但切口一片纯色和带纹理的原始表面放在一起格格不入。以切西瓜为例模型表面是绿条纹切开后应该是红色的瓜瓤这就轮到切面贴花出场了。常见做法是把切割产生的封口顶点统一筛出来给它们单独算一套平面投影 UV然后叠加一张带透明通道的贴花材质。Vector2 uv new Vector2( Vector3.Dot(localPos, tangent1) * tiling.x, Vector3.Dot(localPos, tangent2) * tiling.y );关键参数是tiling它控制贴花在切口上的重复密度。切西瓜时我一般设成 1让一张完整瓜瓤贴图覆盖整个切面切树干或者木板则会放大到 2 到 4让纹理细节更清晰。注意这里计算 UV 用的是物体本地空间坐标不能直接用世界坐标否则物体旋转后贴花方向会跟着转乱。进阶一点的验证方式不是肉眼看而是检查切口顶点数量是否和预期一致。每次切割产生的封口顶点数量理论上等于切割平面与三角形相交的边数如果筛选出来的交点数量和Debug.DrawLine画出来的线数不一致说明有三角形被漏判。我会在编辑器里写一个断言函数把每个新三角形都检查一遍面积是否大于一个极小值乘机过滤掉零面积三角形防止物理碰撞体里出现退化三角形导致抖动。这套流程做下来最小的可运行案例就完整了点击发起切割到生成两个带物理的切块再到切口贴花全程不依赖第三方插件。运行到这一步之后你可以顺着任意方向扩展比如把切割平面换成刀刃的扫掠面就可以做出非平面的曲线切割效果核心逻辑依然是逐三角形裁剪和封口生成。最后一点个人习惯所有切割相关的参数包括切割偏移量、封口 UV 密度、浮点比较阈值我都会集中放到一个可序列化配置类里而不是散落在各个脚本中。切割方案是最容易因为微小数值差异翻车的玩法逻辑把这些参数暴露到 Inspector 里调起手感来才像调物理参数而不是改代码希望帮到你。本文还有配套的精品资源点击获取
返回列表