ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Unity动态网格变形实战:从算法到交互的陶艺模拟系统开发

2026/8/11 7:11:52 拓冰建站 浏览量
Unity动态网格变形实战:从算法到交互的陶艺模拟系统开发

1. 项目概述:当陶艺遇上Unity

几年前,我在一个数字艺术展上看到一个互动装置,观众可以用手在屏幕上“捏”出一个虚拟的陶罐,看着它随着手指的滑动旋转、拉伸、变形,那种直观的创作感和即时反馈让我印象深刻。后来才知道,那背后是Unity引擎和一套自定义的网格变形算法。从那时起,我就琢磨着,能不能自己动手,在Unity里复现甚至优化一套这样的陶艺模拟系统?这不仅仅是做一个好看的Demo,它涉及到实时图形学里几个非常核心且有趣的话题:动态网格的构建与高效更新、基于物理(或伪物理)的顶点变形算法,以及如何将粗糙的触控输入转化为细腻的艺术创作工具。

简单来说,这个“Unity陶艺模拟”项目,目标就是创建一个允许用户通过触摸屏(或鼠标)直接对3D陶土模型进行塑形的交互应用。它的核心链条非常清晰:输入(触控/鼠标位置与力度) -> 算法(计算变形区域与位移) -> 渲染(实时更新网格顶点数据) -> 输出(视觉上的陶土形变)。听起来好像就是改改顶点坐标?但魔鬼全在细节里。比如,如何让变形看起来自然柔软,而不是像捏橡皮泥一样生硬?如何在高面数模型下保持实时性能?触控时是应该“推”开顶点还是“拉”拢顶点?这些问题的答案,共同构成了从网格构建到触控塑形的完整技术栈。

这个项目非常适合有一定Unity和C#基础,并对计算机图形学、交互设计感兴趣的朋友。无论你是想为自己的独立游戏增加一个独特的玩法模块,还是为艺术教育类应用开发核心功能,亦或是单纯想深入理解网格操作的原理,这套实战解析都能提供一条从理论到实现的清晰路径。接下来,我就把自己趟过的路、踩过的坑,以及最终打磨出来的方案,毫无保留地拆解给你看。

2. 核心思路与架构设计

在动手写第一行代码之前,我们必须把整个系统的骨架搭好。一个鲁棒的、可扩展的陶艺模拟系统,不能一上来就对着MeshFilter的vertices数组蛮干。我的设计核心围绕“数据驱动”“计算与渲染分离”这两个原则展开。

2.1 为什么选择动态网格而非Blend Shape或骨骼?

这是第一个要做的技术选型。塑造陶土,本质上就是改变模型的形状。在Unity里,改变形状常见的有几种方法:

  1. Blend Shape(形状键):预定义多个目标形状,通过权重混合。适合做表情动画或有限的几种预设变形,但对于无限可能的、由用户自由创作的陶艺来说,预定义所有形状是不可能的。
  2. 骨骼蒙皮(Skinned Mesh Renderer):通过骨骼驱动顶点变形。控制力强,性能也不错,但更适合角色动画这类关节驱动变形。用骨骼来模拟手指按压陶土这种局部、连续的表面凹陷,需要极其密集且精心设置的骨骼,工作量大,且不够直观。
  3. 动态网格变形:直接操作网格的顶点坐标。这是最直接、最灵活的方法。用户触控哪里,我们就计算哪里顶点的位移。它给了我们最大的控制权,可以实现任意复杂的变形效果。虽然对性能的挑战最大,但通过合理的算法优化,完全可以满足实时交互的要求。因此,动态网格是我们唯一的选择。

2.2 系统架构分层

我将整个系统分为四个逻辑层,这样结构清晰,也便于调试和优化:

1. 交互输入层:

  • 职责:采集原始的触控/鼠标输入信息。
  • 实现:使用Unity的Input系统(新旧皆可,推荐新的Input System Package,便于跨平台)。获取每帧的输入点屏幕坐标、触控压力(如果设备支持)、拖拽向量等。
  • 输出:一个结构化的“触控指令”,包含世界空间中的交互点位置、作用力方向、影响半径等。

2. 变形计算层(核心):

  • 职责:根据输入指令,计算网格上每个顶点需要发生的位移。
  • 这是算法的核心所在。我们不会真的遍历网格的每一个顶点(那太慢了),而是需要一种快速确定“哪些顶点受本次操作影响”的机制。我采用了“空间划分”的思路,具体来说是使用Unity的Physics.OverlapSphere或基于八叉树的逻辑,快速找到以触控点为中心、一定半径内的顶点索引。
  • 然后,对每一个受影响的顶点,根据它到触控中心的距离,计算一个衰减的位移权重。这个权重函数(如线性衰减、高斯衰减)直接决定了变形的手感是“硬”还是“软”。

3. 网格数据层:

  • 职责:持有并管理Mesh的数据,执行顶点坐标的最终更新。
  • 实现:持有一个对MeshFilter.mesh的引用。关键点:为了能够修改顶点,必须调用mesh.MarkDynamic(),并获取mesh.vertices数组的副本,修改后再赋值回去。同时,要记得在顶点移动后,根据需求重新计算法线(mesh.RecalculateNormals())和碰撞体(如果需要物理交互)。

4. 渲染与反馈层:

  • 职责:将变形后的网格渲染出来,并提供视觉/触觉反馈。
  • 实现:Unity的渲染管线会自动处理。但我们可以在这一层增加一些“调味料”,比如根据顶点位移量动态混合不同的材质或纹理(模拟陶土干湿变化),或者通过粒子系统在触控点生成“尘土飞扬”的视觉效果。如果设备支持,还可以触发细微的震动反馈。

这个分层架构的优势在于,每一层的职责都非常单一。比如,当你觉得变形算法不顺手时,可以单独修改第二层的权重函数,而完全不用关心输入是怎么来的,网格是怎么渲染的。这种解耦对于迭代开发至关重要。

3. 从零构建动态陶土网格

有了架构,我们开始打造“陶土”本身。一个初始的陶土模型,通常是一个简单的3D基本体,如球体或圆柱体。但直接从Unity内置的Primitive取用并不合适,因为我们需要对其网格有完全的控制权,并且初始面数可能也需要调整。

3.1 网格的初始化:创建与缓存

我的做法是,在运行时用代码生成一个初始网格。这里以球体为例,但原理适用于任何形状。

// 这是一个简化的示例,用于阐述思路 public class DynamicPotteryMesh : MonoBehaviour { private Mesh _mesh; private Vector3[] _originalVertices; // 缓存原始顶点位置,用于重置或计算相对位移 private Vector3[] _currentVertices; // 当前帧的顶点位置 void Start() { CreateInitialMesh(); } void CreateInitialMesh() { MeshFilter meshFilter = GetComponent<MeshFilter>(); _mesh = meshFilter.mesh; // 清除现有网格,从头创建 _mesh.Clear(); // 1. 生成球体顶点、法线、UV数据 // 这里可以调用一个自定义的球体生成函数,或者使用Procedural库。 // 为了示例,我们假设有一个方法能返回这些数组。 CreateSphereData(out Vector3[] vertices, out Vector3[] normals, out Vector2[] uvs, out int[] triangles, int segments); // 2. 赋值给Mesh _mesh.vertices = vertices; _mesh.normals = normals; _mesh.uv = uvs; _mesh.triangles = triangles; // 3. 标记为动态,并缓存数据 _mesh.MarkDynamic(); // 重要!告诉Unity这个网格会频繁更改 _originalVertices = (Vector3[])vertices.Clone(); _currentVertices = (Vector3[])vertices.Clone(); // 4. 初始计算边界和法线 _mesh.RecalculateBounds(); _mesh.RecalculateNormals(); } }

注意:在实际项目中,生成高精度的程序化球体代码较长。你可以从Asset Store寻找现成的Procedural Mesh生成插件,或者在项目初期直接使用一个导入的高面数球体模型,但务必确保在导入设置中勾选“Read/Write Enabled”,否则无法在运行时修改其顶点。

缓存原始顶点(_originalVertices)是一个非常重要的优化技巧。它有两个好处:第一,当你想实现“撤销上一步”功能时,可以基于原始状态和一系列操作指令来回溯;第二,在计算变形时,有时基于原始坐标进行计算比基于上一帧变形后的坐标更稳定,可以避免误差累积导致的模型漂移或扭曲。

3.2 顶点与拓扑:理解数据的关联

仅仅移动顶点是不够的。一个网格由顶点(Vertices)、三角形(Triangles,即顶点索引序列)和子网格(Submeshes)构成。当我们移动顶点时,连接这些顶点的三角形会自动被“拉伸”或“压缩”,这就是变形的视觉基础。

关键概念:顶点共享。在网格中,一个顶点位置可能被多个三角形共用。这既是优点也是缺点。优点是数据紧凑,渲染高效;缺点是当你移动一个共享顶点时,所有共享它的三角形都会受到影响,这可能导致在想要制作尖锐折痕时遇到困难。在陶艺模拟中,这通常不是问题,因为陶土变形是平滑的。但如果你需要模拟“切割”陶土的动作,就需要在切割线处复制顶点,打破共享关系,这属于更高级的主题。

法线的重要性:顶点法线决定了光线如何与表面交互,从而影响明暗。在顶点移动后,原有的法线信息就失效了,如果继续使用旧法线,模型看起来会像塑料一样有奇怪的反光。因此,每次更新顶点后,必须调用_mesh.RecalculateNormals()。这个方法会根据当前的三角形面,为每个顶点重新计算一个平均法线。对于追求更高视觉质量的情况,你可能需要实现自定义的法线计算,比如考虑顶点位移前后的平滑度,但这在初期不是必须的。

4. 触控塑形算法的核心实现

这是整个项目最“灵魂”的部分。如何把用户手指在2D屏幕上的滑动,转化为3D模型上自然、直观的形变?

4.1 从屏幕到世界的坐标转换与射线检测

用户的输入始于屏幕坐标。第一步是将其转换到3D世界空间,并确定我们想要在陶土模型的哪个位置施加影响。

void HandleTouchInput() { if (Input.GetMouseButton(0)) // 以鼠标左键模拟触控 { Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition); RaycastHit hit; // 关键:射线检测的目标是什么? // 方案A:检测陶土模型本身的Mesh Collider。这是最直接的,能精准命中表面。 // 方案B:检测一个不可见的、用于交互的代理碰撞体(如一个更大的球体)。这可以避免在模型凹陷很深时射线无法命中。 // 这里我们采用方案A。 if (Physics.Raycast(ray, out hit)) { if (hit.collider.gameObject == this.gameObject) { Vector3 interactionPoint = hit.point; // 世界空间中的交互点 Vector3 interactionNormal = hit.normal; // 该点的法线方向(用于决定“推”还是“拉”) float interactionStrength = 1.0f; // 可以关联触控压力或鼠标拖拽速度 ApplyDeformation(interactionPoint, interactionNormal, interactionStrength); } } } }

这里有一个常见的坑:如果你的模型变形非常剧烈,凹陷处可能会“吞噬”射线,导致后续的交互点无法被检测到。为了解决这个问题,一个实用的技巧是使用两层碰撞体:一个简单的、大致包裹模型的球形或盒形碰撞体(设为Trigger)用于接收初始的射线点击,一旦点击成功,后续的拖拽操作可以不再依赖射线检测,而是直接使用上一帧的交互点结合鼠标移动向量来计算新的作用点。这能大大提升交互的鲁棒性。

4.2 变形影响域与权重计算

找到作用点后,我们需要确定模型上哪些顶点会受到这次操作的影响,以及每个顶点受影响的程度(权重)。

1. 确定影响顶点:最朴素的方法是遍历所有顶点,计算与世界空间作用点的距离,筛选出距离小于“影响半径”的顶点。这在顶点数不多(比如几千个)时勉强可行,但对于更精细的模型,性能开销巨大。优化方案是使用空间数据结构。Unity提供了Physics.OverlapSphere,你可以为每个顶点附加一个微小的球形碰撞体(显然不现实),或者更常见的做法是:使用一个预先构建的“空间索引”。例如,在初始化时,将模型的包围盒划分为均匀的网格(Grid),将每个顶点根据其坐标注册到对应的网格单元格中。当需要查找影响顶点时,只需计算作用点所在的单元格及其相邻单元格,然后遍历这些单元格内的顶点即可。这能将计算复杂度从O(N)降低到接近O(1)。对于本项目,如果面数在2万以下,且进行适当的剪枝优化,直接遍历所有顶点在当代移动设备上也可能达到60fps,但为了良好的扩展性,我建议在早期就引入简单的网格空间索引。

2. 计算衰减权重:对于每一个受影响的顶点,我们计算一个基于距离的权重weight,范围在[0, 1]。

  • weight = 0表示完全不影响。
  • weight = 1表示施加全部的作用力。
  • 常用的衰减函数有:
    • 线性衰减:weight = 1 - (distance / radius)。简单,但边缘处变化不自然。
    • 平滑衰减(如二次函数):weight = 1 - (distance / radius)^2。手感更柔和,是我更推荐的选择。
    • 高斯衰减:能产生非常平滑自然的效果,但计算量稍大。
float CalculateWeight(Vector3 vertexWorldPos, Vector3 interactionPoint, float radius) { float distance = Vector3.Distance(vertexWorldPos, interactionPoint); if (distance >= radius) return 0f; // 使用平滑的二次衰减 float normalizedDistance = distance / radius; float weight = 1f - (normalizedDistance * normalizedDistance); // 可以再加一个平滑过渡:weight = Mathf.SmoothStep(0f, 1f, weight); return weight; }

4.3 顶点位移算法:推、拉与平滑

权重告诉我们“影响多大”,而位移算法则决定“往哪个方向移动顶点”。

1. 基础位移:最直观的算法是沿着表面法线方向或交互方向移动顶点。

  • 沿法线方向(雕刻模式):displacement = hit.normal * strength * weight * Time.deltaTime;。这会让顶点沿着模型表面的垂直方向移动,适合做凹陷或凸起。
  • 沿交互方向(拖拽模式):displacement = dragVector * strength * weight;。这里dragVector是当前帧与上一帧交互点之间的世界空间向量。这模拟了用手指“抹平”或“推动”陶土的感觉。

2. 混合位移与约束:单纯地移动顶点可能会导致模型局部过度拉伸或产生不自然的尖刺。为了得到更类似真实陶土的行为,我们需要引入一些约束:

  • 体积保持(可选):在向内凹陷顶点时,同步将周围一小圈顶点轻微向外推,以近似模拟不可压缩的黏土特性。这需要更复杂的物理模拟,但一个简单的近似实现也能大幅提升质感。
  • 平滑处理(Laplacian Smoothing):在每帧变形计算后,对受影响区域的顶点进行一次轻微的拉普拉斯平滑。即,将每个顶点的位置向其相邻顶点的平均位置拉近一点点。这能有效消除因不均匀位移产生的“噪点”和尖锐凸起,让表面看起来更光滑。这是一个让手感产生质变的技巧。
void ApplyLaplacianSmooth(Vector3[] vertices, int[] affectedVertexIndices, float smoothStrength) { // 需要预先知道每个顶点的邻居信息(可以从三角形索引推导并缓存) // 这里简化为对受影响顶点应用一个基于其自身位置的简单平滑(效果有限,仅作示意) foreach (int index in affectedVertexIndices) { // 假设我们能获取到顶点`index`的所有邻居顶点索引列表 `neighbors` // Vector3 averagePos = Vector3.zero; // foreach(int neighborIdx in neighbors) { averagePos += vertices[neighborIdx]; } // averagePos /= neighbors.Count; // vertices[index] = Vector3.Lerp(vertices[index], averagePos, smoothStrength); } }

3. 性能优化实践:

  • 分帧处理:如果单帧需要变形的顶点数量极多(>1000),可以考虑将计算分摊到多帧完成,避免卡顿。但要注意交互的连贯性。
  • 使用Job System和Burst Compiler:这是Unity高性能计算的大杀器。顶点位移计算是典型的“数据并行”任务,非常适合用C# Job System来重构。将顶点数组、作用点、半径等数据包装成NativeArray,在Job中进行并行计算,可以轻易获得数倍甚至数十倍的性能提升。这对于在移动端维持高帧率至关重要。这也是为什么在热词中你会看到“unity jobs burst”的原因。
  • 减少不必要的RecalculateNormals调用:法线重计算开销较大。可以只在变形操作结束的一帧(如手指抬起时)或每隔几帧计算一次,在连续变形过程中,视觉上的轻微延迟是可以接受的。

5. 高级效果与系统打磨

基础塑形功能实现后,我们可以添加更多细节,让模拟更加逼真和可用。

5.1 表面纹理与状态模拟

真实的陶土在塑形过程中,表面颜色、粗糙度会发生变化。我们可以通过Shader来实现:

  • 顶点颜色驱动:将顶点的位移量(或速度)编码到顶点颜色(Color)或额外的UV通道中。在Shader中读取这个值,用来混合“干燥陶土”和“湿润陶土”两种纹理,或者改变高光强度。这样,被用力揉捏的区域看起来就会更“湿”、更“亮”。
  • 细节法线贴图混合:即使网格面数不高,也可以通过一张细节法线贴图来模拟陶土颗粒感。在变形剧烈的区域,可以减弱细节法图的强度,模拟表面被抹平的效果。

5.2 旋转与视角控制

一个好的陶艺模拟,必须允许用户自由旋转作品进行观察。这通常通过触控板的双指旋转或鼠标右键拖拽来实现。这里需要注意交互冲突:当双指操作开始时,要准确判断用户意图是旋转模型还是进行塑形。一个简单的策略是,如果双指起始距离较大,则判定为旋转;如果是单指或双指距离很近的移动,则判定为塑形。可以将旋转逻辑放在另一个独立的脚本中,与塑形脚本协同工作。

5.3 撤销/重做与序列化

创作离不开修改。实现一个简单的命令模式来记录每一次变形操作。

  • 命令对象:记录下该次操作影响的顶点索引列表、这些顶点在操作前的坐标。
  • 栈管理:使用两个栈(Stack),一个用于撤销(Undo),一个用于重做(Redo)。
  • 执行与反转:执行命令时,将顶点移动到新位置,并将命令压入Undo栈。撤销时,从Undo栈弹出命令,执行其“反向操作”(将顶点移回旧坐标),并将该命令压入Redo栈。 这样,我们就拥有了基本的创作安全保障。

序列化保存:如果想保存作品,不能直接保存MeshFilter的mesh,因为那是运行时对象。需要将_currentVertices数组(以及可能的颜色等属性)和三角形索引一起,序列化成自定义的二进制或JSON格式文件。加载时,再根据这些数据重新构建Mesh。

6. 实战中遇到的坑与解决方案

理论很美好,实践却总是磕磕绊绊。下面是我在开发过程中遇到的几个典型问题及解决办法。

问题一:变形后模型闪烁或撕裂。

  • 现象:移动顶点时,模型表面出现随机三角面闪烁或严重的视觉撕裂。
  • 原因:最可能的原因是顶点索引(Triangles)没有随着顶点移动而更新。但等等,我们只改了顶点坐标,没改三角形连接关系啊?问题出在共享顶点法线上。当两个三角形共享一个顶点,而这个顶点被移动到一个位置,使得两个三角形的面法线夹角变得极大时,在渲染管线中(特别是某些光照模型下)就可能出现异常。此外,如果更新顶点和渲染帧不同步,也可能导致短暂撕裂。
  • 解决:
    1. 确保法线更新:ApplyDeformation函数末尾,务必调用mesh.RecalculateNormals()
    2. 使用mesh.SetVerticesmesh.UploadMeshData相比于直接赋值mesh.vertices,使用SetVertices并配合UploadMeshData(false)(仅上传顶点数据,不上传拓扑)有时更高效稳定。
    3. 检查背面剔除:确保没有三角形因为过度变形而翻转了法线方向,导致被背面剔除。可以在Shader中暂时关闭背面剔除(Cull Off)测试。

问题二:在模型边缘或凹陷处触控失灵。

  • 现象:当模型被捏出一个深坑后,手指再放到坑里想继续塑形,射线检测不到了。
  • 原因:射线被坑口边缘的三角形遮挡,无法击中坑底的表面。
  • 解决:
    1. 采用“代理碰撞体”方案:如前所述,使用一个始终包裹整个变形模型的球形Trigger碰撞体来接收最初的射线点击。一旦点击开始,后续的拖拽位移就基于屏幕坐标增量来计算世界空间偏移,不再依赖射线检测。
    2. 使用MeshColliderconvex属性:如果非要用MeshCollider,可以尝试勾选Convex。但注意,凸包碰撞体无法准确表示凹陷形状,会导致交互点计算不准。这不是一个好方案。

问题三:性能随面数增加急剧下降。

  • 现象:初始球体很流畅,但当细分程度增加(面数过万)后,帧率暴跌。
  • 原因:每帧遍历所有顶点计算距离,是O(N)的复杂度。同时,RecalculateNormalsRecalculateBounds也是O(N)级别的操作。
  • 解决:
    1. 实现空间索引(如网格化空间分区)。这是解决性能问题的根本。
    2. 将计算搬移到Job System中。将顶点数组、作用点等转换为NativeArray,编写一个IJobParallelFor作业来并行计算位移。你会惊讶于其性能提升。
    3. 降低更新频率:如果不是每一帧都需要极致流畅(比如在缓慢、精细的塑形时),可以将变形计算固定在30Hz甚至更低,通过插值来平滑顶点运动轨迹。
    4. 分LOD(多层次细节):在交互时使用一个中等面数的网格进行计算和显示,当交互停止时,再用一个高面数网格进行平滑和细节还原。这对实时性要求高的VR/AR应用尤其有用。

问题四:变形手感生硬,不像陶土。

  • 现象:移动顶点时,模型像橡皮泥一样有弹性,或者像金属一样坚硬,缺乏陶土的“塑性”感。
  • 原因:位移算法过于简单,只有即时位移,没有“迟滞”、“流动”的模拟。
  • 解决:
    1. 引入“塑性”系数:为每个顶点存储一个“可塑性”值。每次位移时,只移动该顶点目标位置的一部分(例如80%)。剩下的部分在后续帧中逐渐接近。这会产生一种黏着、缓慢变形的感觉。公式可以简化为:currentPos = Vector3.Lerp(currentPos, targetPos, plasticity * Time.deltaTime);
    2. 结合平滑算法:如前所述的拉普拉斯平滑,能极大改善手感,让变形过渡自然。
    3. 调整衰减曲线:尝试使用更平滑的衰减函数(如高斯函数),让影响区域的边缘过渡更柔和。

开发这个陶艺模拟系统的过程,就像在数字世界里真正地“捏泥巴”——你需要不断调整参数,感受反馈,直到找到那个最舒服、最自然的点。它不仅仅是代码的堆砌,更是对物理直觉和交互设计的一种训练。当你最终看到自己用代码创造出来的虚拟陶土,能够随着手指的舞动而流畅地改变形态时,那种成就感是无可比拟的。希望这份详细的解析,能帮你少走弯路,更快地体验到这份乐趣。