Unity不规则网格建造系统:从顶点编辑到布尔运算的实时生成方案
2026/5/26 5:57:10 网站建设 项目流程

1. 这不是“画个立方体”那么简单:为什么传统建模流程在Unity里卡在了最后一公里

你有没有试过,在Unity里拖进一个从Blender导出的不规则地形模型,想让它支持实时挖洞、动态拼接、玩家可编辑——结果发现MeshFilter里的顶点数据像一堵密不透风的墙?改一个顶点要重导整个FBX,加个新洞得切回建模软件再导出,等你切回去,美术同事已经下班了。这不是个别现象,而是绝大多数Unity中型项目在进入“可交互环境构建”阶段时集体撞上的那堵墙:静态模型与动态逻辑之间,缺一套真正属于运行时的网格建造系统

关键词“Unity”“不规则模型”“网格建造系统”不是堆砌术语,它直指三个现实痛点:第一,“Unity”意味着必须跑在C#环境里,所有操作要兼顾性能与GC压力;第二,“不规则模型”排除了规则体素(如Minecraft式方块)的简化路径,要求能处理任意拓扑、非流形边、悬空面片;第三,“网格建造系统”不是单次生成Mesh,而是要支持增删改查、布尔运算、UV重映射、法线重计算、LOD适配——整套闭环能力。我去年带的一个开放世界沙盒项目,就卡在这个环节整整三个月:美术用ZBrush雕出山体,程序写脚本把山体切成几十块可破坏模块,结果每次玩家炸掉一块,剩下的模型就出现穿插、光照错误、碰撞体错位。最后发现,问题不在爆炸逻辑,而在我们压根没给Mesh本身赋予“可生长、可修复、可验证”的底层能力。

这个系统不是炫技,它解决的是真实生产流中的断点:美术输出的是“结果”,而游戏需要的是“过程”。当玩家用铲子挖出一条蜿蜒的沟壑,系统不能只播放一个预设动画,它必须实时生成新的顶点、三角面、UV坐标和法线向量,并让物理引擎立刻识别这个新形状。这背后是几何计算、内存管理、GPU同步、多线程安全等一系列硬核问题。本文不讲“怎么导入模型”,也不讲“怎么写一个Cube生成器”,而是带你从零搭建一套能扛住真实项目压力的网格建造系统——它能处理ZBrush雕刻的有机山体,也能拼接 procedurally generated 的破碎建筑残骸,还能在手机端保持60帧稳定运行。源码已开源,但更重要的是,我会把每一步背后的取舍、踩过的坑、实测的临界值,全部摊开来讲。

2. 网格的本质不是“画图”,而是“解方程”:理解Unity Mesh底层的数据契约

很多人以为Mesh就是一堆顶点连成的三角形,改改vertices数组就完事。这是最大的误解。Unity的Mesh对象是一套严格的数据契约,它不接受“差不多就行”的输入。你传进去的vertices、triangles、normals、uv、colors、boneWeights,每一组都必须满足特定的数学约束,否则轻则渲染异常(黑面、闪烁、UV拉伸),重则触发Unity内部断言直接崩溃。我见过最典型的错误,是开发者把一个带孔洞的平面模型(比如带窗户的墙)的三角面片列表直接拼接到新Mesh上,结果运行时MeshRenderer报错“Invalid triangle index”,排查三天才发现:原模型的triangles数组里有索引值指向了不存在的顶点——因为那个孔洞区域的顶点被美术手动删掉了,但三角面片引用没清理干净。

2.1 顶点数据的三重校验:位置、法线、UV必须自洽

先看最基础的vertices。它是一维Vector3数组,每个元素代表一个空间坐标。但关键在于:同一个空间位置,可能对应多个顶点。为什么?因为法线(normals)和UV(uv)不同。举个例子:一个立方体的角点,有三个面在此交汇,每个面需要不同的法线方向(用于光照计算)和不同的UV展开(用于贴图采样)。所以Unity里一个立方体实际有24个顶点(6个面 × 每个面4个顶点),而非8个。如果你强行把8个位置塞进vertices,再用24个法线去匹配,Unity会静默截断或填充默认值,结果就是光照发灰、贴图错乱。

提示:永远不要假设“顶点数量 = 位置唯一数”。用mesh.GetVertices()拿到的数组长度,才是Mesh真正拥有的顶点总数。这个数字决定了triangles数组里每个索引的有效范围(0到length-1)。

再看法线(normals)。它必须是单位向量(长度为1)。Unity不会帮你归一化。如果你在代码里计算了新法线但忘了调用Vector3.Normalize(),渲染器会用未归一化的向量做光照计算,导致明暗失常。更隐蔽的坑是:法线方向必须与三角面片的绕序(winding order)一致。Unity默认使用顺时针绕序(Clockwise),即从摄像机方向看,三角形三个顶点按顺时针排列。如果法线指向与绕序不匹配(比如绕序是顺时针,法线却指向摄像机),该面片会被当作“背面”而剔除(Backface Culling)。我在做洞穴挖掘时就遇到过:挖出的洞内壁一片漆黑,调试半天才发现,新生成的三角面片我用了逆时针绕序,但法线却按顺时针习惯计算,结果全被剔除了。

UV坐标同理。它不是简单的二维坐标,而是贴图坐标的采样指令。UV值超出[0,1]范围本身没问题(可以实现平铺效果),但必须保证UV映射的连续性。比如一个圆柱体侧面展开成矩形,上下边缘的UV必须严格相等(U=0和U=1对应同一竖线),否则贴图会出现撕裂。我在拼接两段不规则管道时,就因两端UV的U值有微小浮点误差(0.000001),导致接缝处出现一条细线闪烁。解决方案不是调高精度,而是主动对齐:取两端UV的平均值,强制赋给共享边上的顶点。

2.2 三角面片(Triangles)的拓扑陷阱:索引不是ID,而是地址偏移

triangles数组是整数数组,每个元素是vertices数组的索引。这里埋着两个致命陷阱。第一,索引必须在有效范围内。假设vertices有100个元素,triangles里出现105,Unity会静默忽略这个三角形,或者(在某些版本)触发崩溃。第二,三角形必须是非退化(Non-degenerate)。即三个顶点不能共线,也不能重合。共线三点构成的“三角形”面积为零,GPU光栅化器无法处理,结果就是该面片完全不渲染。我在用Marching Cubes算法生成地形时,就因浮点计算误差导致某些体素的三个顶点几乎共线,生成了大量“隐形三角形”,最终模型看起来缺了一大块。

更深层的问题是拓扑一致性。一个健康的Mesh,其三角面片应该构成一个封闭的、无自交的流形(Manifold)表面。这意味着:每个边(Edge)最多被两个三角形共享;每个顶点周围应该形成一个环状邻接关系。不规则模型(尤其是ZBrush导出的)常常违反这点:有悬空边(只被一个三角形使用)、有非流形顶点(被超过两个三角形以非环状方式共享)、有自交面片。Unity的Mesh API不会拒绝这种数据,但它会让后续的网格操作(如布尔运算、细分)变得不可预测。我的经验是:在将外部模型导入建造系统前,必须加一道“拓扑净化”步骤——用OpenMesh或CGAL库的简化版算法检测并修复这些缺陷。虽然Unity没有内置工具,但用C#重写一个轻量级的边表(Edge Table)检查器,200行代码就能搞定90%的常见问题。

2.3 为什么Mesh.RecalculateNormals()经常失效?法线重建的数学本质

很多教程告诉你“改完顶点后调用RecalculateNormals()就行”。但在不规则模型上,这招大概率失败。原因在于:RecalculateNormals()的算法极其简单——对每个顶点,收集所有共享该顶点的三角面片,计算每个面片的法线(叉积),然后对这些面片法线做加权平均(权重为面片面积)。这个算法假设:所有共享顶点的面片,其法线方向是“合理”的。但不规则模型里,一个顶点可能同时连接着朝外的山体表面和朝内的洞穴内壁。RecalculateNormals()会把这两个反向的法线平均,结果得到一个指向山体内部的法线,整个面片就变黑了。

真正的解法是基于几何特征的法线分区(Normal Smoothing Groups)。你需要先识别出模型的“逻辑表面”:哪些三角面片属于同一个连续曲面(比如整个山坡),哪些属于另一个(比如挖出的洞)。这通常通过计算面片法线之间的夹角来实现——如果两个相邻面片的法线夹角小于某个阈值(如60度),就认为它们属于同一光滑组。然后,对每个光滑组单独计算顶点法线,组与组之间保持法线不连续(即硬边)。我在项目里用的阈值是45度,实测下来对有机地形和人工建筑都效果稳定。这个过程无法靠Unity内置API完成,必须自己写循环遍历所有三角面片,构建邻接关系图,再用DFS或BFS进行分组。虽然多花200ms初始化时间,但换来的是100%可控的法线质量。

3. 从“拼积木”到“捏陶土”:不规则网格建造的四大核心操作模式

把不规则模型当成静态资产,你就永远走不出“导出-替换-测试”的死循环。真正的建造系统,必须提供四种原子级操作能力,它们共同构成了“可编程环境”的基础。这四种模式不是并列关系,而是有严格的依赖层级:顶点编辑是基石,面片编辑是骨架,布尔运算是血肉,而变形编辑是神经。少任何一个,系统都不完整。

3.1 顶点级编辑:不是移动点,而是重构局部坐标系

最基础的操作是移动单个顶点。但对不规则模型而言,“移动”意味着风险。直接改vertices[i],会导致该顶点关联的所有三角面片瞬间扭曲,UV拉伸,法线错乱。正确做法是:以目标顶点为中心,构建一个局部影响域(Influence Radius),并对域内所有顶点施加平滑过渡的位移。这本质上是一个径向基函数(RBF)插值问题。

具体实现:选定目标顶点V0,计算其K近邻(K=8~12,取决于模型密度)。对每个邻近顶点Vi,定义其位移权重Wi = 1 / (1 + d²),其中d是Vi到V0的欧氏距离。然后,V0的新位置 = V0旧位置 + ΔP(用户指定的位移向量),而每个Vi的新位置 = Vi旧位置 + Wi × ΔP。这样,V0移动最大,邻居移动渐弱,边界顶点几乎不动,整个局部区域像一块被拉伸的橡胶,保持拓扑和UV的连续性。我在做“地形塑形”工具时,就用这个方法实现了“推拉山脊”功能:玩家用鼠标拖拽,系统自动计算影响域,避免了传统“顶点选择+拖拽”导致的模型撕裂。

注意:K近邻的搜索不能暴力遍历所有顶点(O(n²)太慢)。必须预构建空间索引。我用的是八叉树(Octree),在模型加载时一次性构建,查询复杂度降至O(log n)。对于10万顶点的山体模型,单次K近邻查询耗时稳定在0.3ms以内。

3.2 面片级编辑:删除、插入、分割——三角网格的外科手术

面片编辑是建造系统的“骨骼操作”。删除一个三角面片(triangle)看似简单,但会引发连锁反应:该面片的三条边可能变成悬空边,其三个顶点可能变成孤立点。直接从triangles数组里删掉三个索引,只会让Mesh数据结构损坏。正确流程是三步:第一,标记该三角形为“待删除”;第二,遍历所有其他三角形,检查是否有边与之共享;第三,仅当某条边只被这一个三角形使用时,才将该边对应的两个顶点从vertices中移除(并更新所有相关索引)。这个过程叫“边收缩(Edge Collapse)”,是网格简化(Mesh Simplification)的核心算法。

插入新面片则相反。最常见的需求是“在两个相邻面片之间插入一个过渡面片”,用于平滑连接。例如,拼接一段破损的墙壁和一段完好的墙壁,中间需要一个斜坡过渡。这时不能随便画个三角形,而要计算两个面片的交线(Line of Intersection),然后在交线上取点,向两侧面片法线方向偏移,生成新的顶点。这个计算涉及平面方程求解:每个面片可表示为Ax+By+Cz+D=0,交线就是两个平面的公共解集。我封装了一个Plane.Intersect(Plane other)方法,返回一条Line结构(包含起点和方向向量),所有后续操作都基于这条线展开。

最复杂的操作是面片分割(Split)。比如,你想把一个巨大的岩石面片切成两半,以便分别设置不同的材质或物理属性。分割线不能是任意的——它必须起止于面片的边上,且不能与现有顶点重合(否则会引入退化三角形)。我的方案是:先找到分割线与面片三条边的交点(最多两个),然后将原三角形拆成三个新三角形。关键技巧是:新顶点的UV和法线必须通过双线性插值(Bilinear Interpolation)从原三角形的三个顶点继承。Unity的Mesh API不提供插值函数,必须自己写。公式很简单:对于交点P,设其在边AB上的参数为t(0≤t≤1),则P的UV = UV_A + t×(UV_B - UV_A),法线同理。但要注意浮点精度:t值必须严格钳制在[0,1]内,否则插值结果会溢出。

3.3 布尔运算:不是“合并”,而是“求交集/并集/差集”的几何解算

“把玩家挖的洞和山体模型合并”——这句话背后是计算几何的硬核战场。Unity没有内置布尔运算,网上流行的“Mesh Boolean”插件大多基于CSG(Constructive Solid Geometry),但CSG要求输入是凸体(Convex Hull),对不规则有机模型效果极差。我最终采用的是基于网格切割(Mesh Cutting)的改进算法,它不追求理论完美,但胜在稳定、快速、可预测。

核心思想:将“洞”视为一个封闭的切割体(Cutting Volume),通常是球体、胶囊体或自定义的低多边形封闭网格。然后,对山体Mesh的每一个三角面片,执行“面片-体素相交检测”。检测分三步:第一,用分离轴定理(SAT)粗筛,快速排除明显不相交的面片;第二,对可能相交的面片,计算其与切割体表面的交点(最多3个);第三,根据交点数量和位置,将原面片裁剪成0~3个新面片。例如,一个面片被球体切割,产生两个交点,则原面片被分成一个“内部三角形”(在球体内)和一个“外部四边形”(在球体外),再将四边形三角化。

难点在于“内部面片”的生成。切割后,所有被移除的“内部”部分,其边界会形成一个闭合的环(Loop)。这个环必须被三角化,才能生成新的“洞内壁”面片。我用的是“耳切法(Ear Clipping)”,它是针对简单多边形(Simple Polygon)最稳定的三角化算法。关键预处理是:确保环上顶点顺序一致(全部顺时针或全部逆时针),且环是平面的(对不规则模型,需先将环投影到最佳拟合平面)。实测下来,对100个顶点的洞口环,耳切法耗时约0.8ms,完全可以接受。

3.4 变形编辑:让不规则模型“呼吸”起来的蒙皮替代方案

传统角色动画用SkinnedMeshRenderer,但环境物体不需要骨骼。我们需要一种轻量级的、基于顶点的变形系统,让山体能随风起伏,让桥梁能因承重而微弯。这不是简单地加正弦波,而是要模拟材料的物理响应。

我的方案叫“顶点约束网络(Vertex Constraint Network)”。首先,在模型上手工或自动生成一组“锚点(Anchor Points)”,比如桥墩、山脚、建筑地基。然后,为每个顶点V,计算它到所有锚点的距离,并赋予一个“刚度权重”:离锚点越近,权重越大,受锚点位移影响越强。当某个锚点发生位移ΔP时,顶点V的新位置 = V旧位置 + Σ(Weight_i × ΔP_i)。这个Σ是加权求和,权重由距离的倒数平方决定,模拟胡克定律(Hooke's Law)的力衰减。

为了性能,这个计算不能在Update里每帧做。我把它做成“事件驱动”:只有当锚点位移超过阈值(如0.01米)时,才触发一次全网格更新。更新本身用Job System并行化,每个Job处理一段顶点数组。测试表明,对5万顶点的模型,在i7-9750H上单次更新耗时1.2ms,完全不影响主线程。更重要的是,这个系统可以叠加:风力让山顶锚点晃动,重力让桥中央锚点下沉,两者效果自然融合,无需任何状态机。

4. 性能生死线:如何让网格建造在手机上跑出60帧

“功能做完就扔给QA”是项目死亡的开始。在移动端,一次不当的网格重建,可能让帧率从60暴跌到15。我见过太多团队,功能Demo跑得飞起,一进真机测试就卡成幻灯片。性能优化不是最后一步,而是从第一行代码就开始的设计哲学。

4.1 内存墙:为什么每次new Mesh()都是自杀行为

最致命的性能陷阱,是频繁创建新Mesh对象。new Mesh()会分配大块内存(顶点数组、索引数组),触发GC(Garbage Collection)。在Unity中,GC是Stop-the-World的,一次Full GC可能卡顿200ms以上。我的第一个版本就犯了这个错:玩家每挖一铲,就new Mesh()一次,结果iOS上挖三下就卡死。

解法是对象池(Object Pool)+ 增量更新(Incremental Update)。第一步,预分配一个Mesh对象池,大小为5(足够覆盖绝大多数并发操作)。第二步,所有网格修改操作,都复用池中已有的Mesh实例,只更新其vertices、triangles等数组内容,而不是新建。第三步,用Mesh.MarkDynamic()标记Mesh为动态,告诉Unity GPU缓存可以被频繁更新。最关键的是:永远不要在运行时调用Mesh.RecalculateBounds()。这个函数会遍历所有顶点计算AABB,O(n)复杂度。正确的做法是:在修改顶点后,用增量方式更新Bounds——记录本次修改影响的最大/最小坐标,与原Bounds取并集。我写了一个Bounds.Expand(Vector3 min, Vector3 max)扩展方法,耗时恒定在0.01ms。

4.2 GPU同步瓶颈:Draw Call爆炸与顶点上传开销

即使Mesh对象复用,频繁调用MeshFilter.mesh = mesh也会触发GPU同步。Unity需要把CPU内存中的顶点数据上传到GPU显存,这个过程叫“Upload”。如果每帧都上传,带宽会成为瓶颈。我的测试数据:一个10万顶点的Mesh,单次Upload耗时约3.5ms(iPhone 12)。如果每秒挖10次,就是35ms,直接吃掉半帧。

破局点在于延迟上传(Lazy Upload)与脏标记(Dirty Flag)。我不在每次修改后立即赋值meshFilter.mesh,而是在修改操作结束时,设置一个isMeshDirty = true标记。然后,在LateUpdate里统一检查:如果isMeshDirty为真,才执行meshFilter.mesh = currentMesh,并重置标记。这样,无论玩家一秒挖100铲还是1铲,每帧最多只上传一次Mesh。配合前面的对象池,Draw Call数也降到了最低——因为MeshFilter引用的是同一个Mesh对象,Unity的批处理(Batching)机制能自动合并。

4.3 多线程加速:Job System不是银弹,但用对了就是核弹

Unity的Job System能并行化CPU密集型任务,但绝不能滥用。我最初把整个布尔运算塞进一个Job,结果编译失败——因为Job里不能访问Unity的API(如Vector3、Mathf),只能用Unity.Mathematics库的类型(float3、math.sin)。重构后,我把布尔运算拆成三个Job:第一个Job负责面片-体素相交检测(纯数学计算);第二个Job负责交点分类与新面片生成(需要float3运算);第三个Job负责顶点属性插值(UV、法线)。每个Job只处理顶点数组的一段,用NativeArray<T>传递数据。

关键技巧是:Job之间用NativeList<T>作为中间结果容器,但必须预先分配容量NativeListAdd()方法在多线程下是线程安全的,但会触发内存重分配,代价巨大。我的做法是:在主Job启动前,预估新面片的最大数量(基于原面片数×1.5),调用nativeList.Capacity = estimatedCount。实测下来,对10万面片的山体,三个Job总耗时从单线程的18ms降到4.2ms,提速4倍以上。但注意:Job System的调度开销本身约0.1ms,所以面片数少于1000时,单线程反而更快。我在代码里加了分支判断:if (triangles.Length > 1000) UseJobs(); else UseMainThread();

4.4 移动端特供:顶点压缩与LOD策略

手机GPU的顶点着色器能力有限,过多的顶点会成为瓶颈。我的终极优化是运行时顶点压缩(Runtime Vertex Compression)。原理很简单:对不规则模型,很多顶点的位置、法线、UV存在高度冗余。比如一面平整的岩壁,相邻顶点的Z坐标几乎相同。我设计了一个“量化压缩器”:将顶点位置从float3压缩为int3,用16位表示X/Y,12位表示Z(因为Z变化小),再乘以一个缩放因子(Scale Factor)还原。法线同理,用octahedral encoding压缩为2个字节。这套压缩让顶点数据体积减少65%,上传带宽压力骤降。

但压缩带来新问题:精度损失。我的对策是“分级压缩”:远距离(>50米)用高压缩比,中距离(10~50米)用中等压缩,近距离(<10米)保留原始float精度。这需要一个动态LOD系统,根据摄像机距离实时切换Mesh实例。我写了MeshLODManager组件,它维护三个预压缩的Mesh副本,在LateUpdate里根据距离切换MeshFilter.mesh。切换本身是瞬时的,但为了避免视觉跳跃,我加入了0.3秒的淡入淡出过渡——不是Alpha混合,而是用Shader控制顶点位置的插值(Lerp),从旧Mesh顶点平滑过渡到新Mesh顶点。这个细节让性能提升的同时,完全消除了LOD切换的“Pop-in”现象。

5. 踩坑实录:那些让项目延期三个月的“幽灵Bug”

功能文档里不会写这些,但它们真实存在,且足以让一个功能在上线前夜崩溃。我把最痛的五个坑,连同完整的排查链路和修复方案,毫无保留地列出来。这不是“注意事项”,而是血泪教训。

5.1 Bug:玩家挖洞后,物理碰撞体(MeshCollider)完全失效,角色直接掉进地心

现象:一切渲染正常,但Rigidbody穿过模型,仿佛模型是空气。Debug.DrawRay显示射线能正确击中Mesh,但Physics.Raycast却返回false。

排查链路

  1. 首先确认MeshCollider是否启用convex=false(非凸体)——是。
  2. 检查Mesh是否为封闭流形——用拓扑检查器确认,是。
  3. 尝试MeshCollider.smoothSphereCollisions=true——无效。
  4. 关键转折:在Inspector里手动点击MeshCollider的“Edit Collider”按钮,发现碰撞体预览窗口一片空白。这说明Mesh数据本身有问题。

根因定位:深入查看Mesh数据,发现triangles数组里存在重复的三角面片索引序列。例如,[0,1,2]出现了两次。Unity的MeshCollider在构建BVH加速结构时,遇到重复面片会静默失败,但不报错。而我们的布尔运算代码,在生成“洞内壁”面片时,因环三角化算法的边界条件处理不当,偶尔会生成两个完全重合的三角形。

修复方案:在布尔运算的最终输出阶段,加入“面片去重(Triangle Deduplication)”。不是简单比较索引数组,而是计算每个三角形的质心(Centroid)和面积(Area),用(centroid.x, centroid.y, centroid.z, area)作为哈希键。哈希冲突率极低,且计算开销可忽略(每个面片约0.02ms)。实测后,MeshCollider 100%稳定。

5.2 Bug:在Android设备上,网格编辑后出现随机的黑色三角面片,且只在特定GPU(Adreno)上出现

现象:iOS和PC完美,但三星手机上,某些新生成的面片永远是黑色,无论光照如何调整。

排查链路

  1. 排除Shader问题:换用Standard Shader,问题依旧。
  2. 排除法线问题:用Debug.DrawLine可视化所有法线,方向正确。
  3. 关键线索:黑色面片总是出现在“面片分割”操作后生成的新面片上,且位置随机。

根因定位:Adreno GPU对顶点属性的内存对齐(Memory Alignment)要求极其严格。Unity的Mesh API在Android上,如果verticesnormalsuv数组的长度不是4的倍数,GPU读取时会越界,读到垃圾数据,导致法线为(0,0,0),光照计算结果为0。而我们的面片分割代码,生成的新顶点数往往是奇数(如3、5、7),导致数组长度不是4的倍数。

修复方案:在每次mesh.vertices = verticesArray赋值前,检查数组长度。如果不是4的倍数,就用Array.Resize()补零(添加虚拟顶点),并在triangles数组中,将引用这些虚拟顶点的索引,全部替换为第一个真实顶点的索引(这样虚拟顶点不会被渲染,但内存对齐了)。这个补丁让Adreno设备的渲染100%稳定,且对性能无影响。

5.3 Bug:多人协作编辑时,两个玩家同时在同一个区域挖洞,服务器同步后模型出现严重扭曲和穿插

现象:单人测试完美,但联机时,客户端看到的模型像被揉皱的纸。

排查链路

  1. 确认网络同步的是顶点坐标,而非操作指令——是。
  2. 检查同步频率:每秒10次,带宽充足。
  3. 关键发现:在Network Profiler里,看到同一帧内,服务器发来了两组顶点数据,但客户端应用顺序是随机的。

根因定位:网络包到达顺序不保证。玩家A和玩家B的操作,其顶点数据包可能乱序到达。客户端如果按接收顺序应用,就会先应用B的修改(基于原始Mesh),再应用A的修改(也基于原始Mesh),结果A的修改覆盖了B的修改,造成数据丢失和几何冲突。

修复方案:引入操作变换(Operational Transformation, OT)。每个编辑操作携带一个逻辑时钟(Logical Clock)戳,格式为(clientId, sequenceNumber)。客户端收到操作后,不立即应用,而是放入一个有序队列,按(clientId, sequenceNumber)排序。然后,对每个新操作O_new,遍历队列中所有已应用但时间戳更小的操作O_old,执行“变换”:计算O_new在O_old之后的效果。例如,O_old是“移动顶点5到位置P1”,O_new是“移动顶点5到位置P2”,那么变换后的O_new就是“移动顶点5到位置P2”(不变);但如果O_old是“删除顶点5”,O_new就必须被取消。这个OT引擎只有200行C#代码,却彻底解决了协同编辑的几何一致性问题。

5.4 Bug:长时间运行后(>2小时),网格编辑速度越来越慢,最终卡死

现象:项目启动时编辑流畅,但挂机两小时后,一次挖洞操作耗时从5ms涨到500ms。

排查链路

  1. Profile查看CPU热点——Mesh.vertices.getMesh.triangles.get调用次数暴增。
  2. 检查代码:发现每次编辑前,都调用mesh.vertices获取副本,编辑后再赋值回去。这是Unity的“Copy-on-Read”陷阱:每次get都会触发一次深拷贝,生成新数组。

根因定位:Unity的Mesh API为了线程安全,对verticestriangles等属性做了Copy-on-Read。频繁get,等于频繁分配内存,触发GC,而GC又导致后续操作更慢,形成恶性循环。

修复方案永远用Mesh.GetVertices()Mesh.SetVertices()替代属性访问器。前者是显式调用,后者明确告知Unity你要批量写入。我重构了所有编辑函数,确保顶点数组只在必要时获取一次,编辑完成后一次性Set。这个改动让长期运行的性能衰减归零。

5.5 Bug:使用URP(Universal Render Pipeline)后,自定义网格的阴影完全丢失,且Shader Graph里看不到任何顶点数据

现象:切换到URP后,所有动态生成的网格,Shadow Caster Pass完全不工作,Shadow Map里一片空白。

排查链路

  1. 确认MeshRenderer的receiveShadowscastShadows为true——是。
  2. 检查URP Asset里的Shadow Distance——已设为200。
  3. 关键线索:在Frame Debugger里,看到Shadow Caster Pass根本没有执行,Draw Call数为0。

根因定位:URP的Shadow Caster Pass,要求Mesh必须有lightmapTilingOffset属性,且Mesh.bounds必须正确。而我们的动态Mesh,bounds是手动计算的,但lightmapTilingOffset从未设置。URP在提交Shadow Caster时,会检查这个属性,如果为null或非法,就跳过该Mesh。

修复方案:在每次meshFilter.mesh = mesh后,立即执行:

meshFilter.sharedMaterial.EnableKeyword("_RECEIVE_SHADOWS_OFF"); meshFilter.sharedMaterial.DisableKeyword("_RECEIVE_SHADOWS_OFF"); // 强制刷新材质关键字

并确保mesh.bounds是精确计算的(不能用mesh.RecalculateBounds(),要用增量更新)。这个冷知识,官方文档里根本没提,是我在URP源码里翻了3小时才找到的。

6. 附:项目源码结构与集成指南(可直接抄作业)

源码已开源在GitHub(链接见文末),但比代码更重要的是,如何把它集成进你的项目。这不是一个“导入就用”的黑盒,而是一个需要理解其设计哲学的框架。以下是我推荐的集成路径,按项目成熟度分三级。

6.1 快速上手:5分钟接入基础编辑功能

如果你只想先体验“挖洞”功能,按此顺序操作:

  1. /Runtime/MeshBuilder/文件夹拖入Assets;
  2. 在场景中创建一个空GameObject,命名为TerrainBuilder
  3. 添加MeshBuilderController组件(它管理全局状态);
  4. 将你的山体模型拖到TerrainBuilderTarget Mesh Filter字段;
  5. 创建一个球体(Sphere),添加MeshToolDig组件,设置Radius=2f,Strength=1f
  6. 运行游戏,用鼠标拖拽球体,即可实时挖洞。

注意:首次运行会触发Mesh拓扑检查,耗时约1秒(对10万顶点模型),之后所有操作都是毫秒级。检查结果会打印在Console,绿色表示健康,红色表示需修复。

6.2 生产就绪:与现有管线无缝对接

要接入正式项目,必须处理三个耦合点:

  • 与美术管线对接:在MeshBuilderSettings中,设置AutoCleanTopology=true,系统会在加载FBX时自动运行拓扑净化,无需美术额外操作。
  • 与物理系统对接MeshBuilderController提供OnMeshUpdated事件,监听此事件,在回调里调用meshCollider.sharedMesh = newMesh。注意:MeshCollider必须设置convex=falsesmoothSphereCollisions=true
  • 与UI系统对接:所有编辑操作都通过IMeshOperation接口定义。你可以轻松实现MeshOperationPaint(涂装材质)、MeshOperationSmooth(平滑表面)等,统一注册到MeshBuilderController.operations列表,UI按钮只需调用builder.Execute(operation)

6.3 源码核心类图与职责划分

为避免你迷失在数百个文件中,这是最精简的核心类图:

MeshBuilderController ── 主控制器,协调所有操作,持有Mesh引用 ├── MeshTopologyChecker ── 拓扑检查与修复(八叉树索引) ├── MeshBooleanProcessor ── 布尔运算核心(基于网格切割) ├── MeshDeformer ── 变形编辑(顶

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询