简介:一份基于 Unity 的陶艺制作模拟工程,面向 Unity 开发者、游戏美术及对程序化建模感兴趣的读者,演示如何通过动态生成模型、动态调整模型顶点与动态平滑法线,实现拉坯成型的交互效果。压缩包共 43 个文件,约 56KB,以 20 个 asset 资源、C# 脚本、材质与项目配置为主,并包含主场景与 StreamingAssets 数据目录,结构紧凑,便于直接打开工程查看 Mesh 生成与顶点更新的核心实现。目前已有 2500 人学习下载。通过这套工程可厘清动态 Mesh 的构建流程、顶点坐标修改与法线重新计算的衔接方式;场景、脚本、材质分层清晰,可直接运行观察陶瓷模型从平面到立体的变化,也可作为陶艺触控交互或类似变形建模玩法的可运行参考。适合有一点 C# 基础、想从实践中理解 Mesh 编程的 Unity 学习者。
1. 陶艺制作模拟:从旋转对称体到动态 Mesh 变形
打开 Unity 做陶艺模拟,第一道坎不是材质,不是交互,而是「怎么让一个圆柱体像陶土一样被你捏瘪」。之前我把整块泥坯当作静态模型来处理,用贴图去骗眼睛,结果换个角度就穿帮;换成动态 Mesh 后,拉坯、捏口、压纹全部由顶点位移驱动,那种「泥跟着手指走」的反馈才是陶艺模拟的灵魂。这篇笔记就围绕这个核心,聊聊动态 Mesh 变形、旋转对称体建模、陶瓷材质参数与性能红线,适合正在做 Unity 数字雕塑、陶瓷定制 App 或虚拟手工课的开发者。你能从我踩过的坑里省下一个星期。
2. 陶坯的旋转对称模型:Lathe 参数化与顶点结构设计
2.1 把圆柱体拆成「子午线 + 旋转段」的数学表示
陶艺拉坯与普通捏泥最大的区别是:初始坯体几乎严格旋转对称。用 Lathe(车削)方法生成网格,意味着我把圆心角切成 N 段,把轮廓线切成 M 个点,顶点总数 = N × M,比直接塌掉一个 Unity 内置 Cylinder 可控得多。为什么不用 Cylinder?因为内置圆柱的顶点布局是均匀经纬分布,而陶艺变形集中在腹部和口沿,我需要「轮廓线稀疏、圆周密集」的非均匀权重。
public class LatheMeshBuilder { public static Mesh Build(Vector2[] profile, int segments, float radius) { var vertices = new Vector3[profile.Length * segments]; var uvs = new Vector2[vertices.Length]; var triangles = new int[(profile.Length - 1) * segments * 6]; for (int j = 0; j <= segments - 1; j++) { float angle = Mathf.PI * 2f * j / segments; for (int i = 0; i < profile.Length; i++) { int vid = j * profile.Length + i; float x = profile[i].x * Mathf.Cos(angle) * radius; float z = profile[i].x * Mathf.Sin(angle) * radius; vertices[vid] = new Vector3(x, profile[i].y, z); uvs[vid] = new Vector2((float)j / segments, profile[i].y); } } int t = 0; for (int j = 0; j < segments - 1; j++) { for (int i = 0; i < profile.Length - 1; i++) { int a = j * profile.Length + i; int b = (j + 1) * profile.Length + i; int c = (j + 1) * profile.Length + i + 1; int d = j * profile.Length + i + 1; triangles[t++] = a; triangles[t++] = c; triangles[t++] = b; triangles[t++] = a; triangles[t++] = d; triangles[t++] = c; } } // 收尾段:让首尾缝合,不然接缝处会出现一条透光的裂缝 int jLast = segments - 1; for (int i = 0; i < profile.Length - 1; i++) { int a = jLast * profile.Length + i; int b = i; int c = i + 1; int d = jLast * profile.Length + i + 1; triangles[t++] = a; triangles[t++] = c; triangles[t++] = b; triangles[t++] = a; triangles[t++] = d; triangles[t++] = c; } return new Mesh { vertices = vertices, uv = uvs, triangles = triangles }; } }这里profile是轮廓点的二维数组,x 代表半径、y 代表高度。拉坯时意味着顶点结构固定,只是某个轮廓位置被推挤,这样法线重算、UV 保持、碰撞体同步都有相对稳定的索引规律可循。
2.2 顶点索引的稳定是后续变形的生命线
很多照着教程做完 Lathe 的人会在第一步把顶点合并掉,让接缝处光滑。这个决定很危险:一旦合并了首尾顶点,后续做变形时很难判断哪一列顶点属于旋转接缝。
我一般不做合并焊接,而是在生成三角形的收尾段里做跨越接缝的三角形索引,就像上面代码里jLast那一层循环做的那样。这种「顶点不合并、索引跨接缝」的做法保留了完整的经纬结构,按下顶点索引做邻域查找时不会因为共享顶点被打乱而出错。
处理 UV 时有一个关键点:陶瓷器物的花纹在圆周方向闭合,所以 U 方向必须满足首尾自然衔接。如果直接在0~1区间铺满,拉坯后你会在接缝处看到一道竖着的纹理裂缝。我采取的方式是 U 坐标按段数归一化到0~1-1/segment,而不是1。类似这样的边界处理,几乎每个做陶艺的朋友都会踩一遍。
3. 变形交互:射线拾取、权重场与增量重建
3.1 鼠标射线与顶点邻域权重场
陶艺变形要解决的核心问题是:用户点到某个顶点,怎么把作用力扩散到周围一圈。用一个极简的衰减函数就能做到:点到顶点的距离小于交互半径时,该顶点位移量为最大值乘以距离衰减系数,然后沿法线或水平方向推挤。
void ApplyPush(Ray ray, float strength, float radius) { Physics.Raycast(ray, out var hit); meshCollider.sharedMesh = sharedMesh; // 同步当前帧 Mesh,否则射线“穿过”变形后的表面 var localPoint = transform.InverseTransformPoint(hit.point); var verts = sharedMesh.vertices; var normals = sharedMesh.normals; for (int i = 0; i < verts.Length; i++) { float dist = Vector3.Distance(verts[i], localPoint); if (dist > radius) continue; float falloff = 1f - dist / radius; falloff = falloff * falloff * (3f - 2f * falloff); verts[i] += normals[i] * strength * falloff; verts[i].x = Mathf.Max(verts[i].x, 0.02f); // 中心轴保护,阻止捏成负半径 } sharedMesh.vertices = verts; sharedMesh.RecalculateNormals(); meshCollider.sharedMesh = sharedMesh; }这段代码是「首先要能跑、其次才是效率」的版本。我用的sharedMesh是单例网格,合并在一个 MeshFilter 上;每帧只有一个交互点,所以RecalculateNormals花的时间可以勉强接受。交互半径我一般设置为 0.05 到 0.15 米,对应口沿和腹部的粗细差异。
需要说明:上面是「水平推挤」的支持方式。真正的拉坯还应该把旋转速度和半径变化耦合起来,否则陶壁会越捏越薄而不出问题,这在物理上不合理。我一般会在变形前记录顶点原始半径,变形后按位移前后的半径变化补偿高度,让泥量近似守恒。
3.2 撤销系统避坑:顶点快照太大,历史栈随时崩
陶艺模拟的撤销功能比普通编辑器难做,是因为每个顶点坐标都是连续变化量,离散的「一撮泥岗」动作之间很难定义自然的撤销步长。如果每次拖拽都存一份全量顶点数组,一个中等复杂度的陶坯约 3 万顶点,快照大约 300 多 KB,连续操作 50 次就接近 20 MB,在低端手机上会卡到爆。
我的做法是增量缓存:每个操作只记录被影响的顶点索引和它们变化前的位置,恢复时只改写这些索引。代价是增量的历史栈需要判断相邻操作的冲突,比如第一笔捏了口沿,第二笔捏腹部,二者没有重叠,就可以合并成一个撤销步。
public class VertexDeltaRecorder { private Dictionary<int, Vector3[]> undoStack; // 这里只存受影响顶点 public void RecordUndoStep(List<int> indices, Vector3[] priorPositions) { undoStack.Add(nextId++, priorPositions.ToArray()); } public Vector3[] Revert(int stepId) { return undoStack[stepId]; } }这样平均每步的存储量可能只有几百个顶点,内存大幅下降。需要注意的点:如果两个操作的索引集合有交叠,那么第二个操作的起点快照可能被第一个操作的撤销破坏,此时应当直接合并两步,而不是单独撤销。这一条写着容易,实现时却很容易被忽略——有不少项目明明做了完善的历史栈,但「撤销一下回退成中间混乱状」,原因就是这个。
3.3 相机控制与会跟丢目标的「反向遮罩组件」
陶艺模拟中相机绕物体旋转,不是普通的轨道相机,而是需要保持注视点位于陶坯的重心附近,且参照物的缩放会改变。如果你直接把第三人称相机的LookAt对准变形后的陶坯,陶坯一旦被捏歪,相机就会大幅倾斜,用户很容易失去方向感。
我推荐的做法:单独设一个游戏对象 TrackTarget,每一帧让它跟随陶坯「当前激活变形区域」的平均位置,而不是全局中心;相机旋转只基于鼠标横向位移和固定俯仰角,缩放以目标包围盒半径做归一化。反向遮罩在这个场景里很有用:默认相机层只照射陶坯内部部件,而捏泥控制手、控制器把手等 UI 物体放在另一个层,这样视线里的「临时显示文字」不被物理射线拾取干扰。一开始我只是把 UI 放到 Ignore Raycast,结果总有一个可点击的半透明挡板控制不了相机的旋转——后来把相机转向从原来作用于所有可交互层,改成了 Render 在相机层再加一层反向遮罩,问题才彻底消失。
4. 陶瓷材质:从粗糙陶土到釉面的 PBR 参数映射
4.1 一套材质参数让陶坯「看起来像真的」的基本盘
陶艺模拟渲染不能只靠一个 Standard Shader 拖贴图。陶瓷在视觉上最大的特点是「高光硬、散射强、边缘透光」,釉面高光几乎像镜子,而素陶表面则完全漫反射。PBR 工作流里只需要控制几个参数就能区分:金属度几乎都是 0,但平滑度和高光折射率差距巨大。
我用 Shader Graph 做了双层材质:底层是素陶(Smoothness 0.15,Albedo 取深米黄色),上层是釉面(Smoothness 0.85,Albedo 高光接近白色),然后用高度图或顶点色做 Mask。这样同一个网格看起来就像釉面从剖面蔓延到陶壁。在 HDRP 里没有映射到各向异性高光的话,也可以用RoughnessMap加一张噪声图模拟窑变的细微毛糙感。
4.2 双面材质 Shader 与法线方向的坑(隐藏翻车点)
陶艺网格是单面 Mesh,拉坯时如果修正了负半径但没修正法线反转,结果就是「一半陶壁看起来是亮的,另一半被 Dunkel 吃掉」。正常双面材质最简单的方案是 Shader 里开启Cull Off,然后Normal使用相对相机的翻转判断。这在 OpenGL 风格管线里最容易写错:UnityWorldSpaceViewDir在片元里是视线方向,但法线是物体空间还是世界空间,每次都要想清楚。
我给新手提个建议:本质不是做两种 Shader 变体,而是直接把陶坯的 Mesh 渲染两次,背面用薄一点的透明材质,做「素坯描边」效果。这样即使法线错了,背面的轮廓也能被勾出来,看起来像手工陶器的分模线,反而增加了真实感。不过注意,如果你用Cull Off,阴影渲染会自动变成双面,性能可能下降,且ShadowCaster里必须自己写剔除逻辑,否则 shadow acne 很严重。
4.3 菲涅尔与边缘光:切身体验到的陶艺模拟加分项
素陶在真实环境下有一种非常微弱的边缘提亮,原因是光线在陶器边缘多次反弹;用在虚拟陶艺上,这种边缘光能极大改善质感。我没有用昂贵的次表面散射,而是在片元里计算dot(N, V)然后乘一个经验系数加进 Emission。
float fresnel = pow(1.0 - saturate(dot(normalWS, viewDir)), 3.0); emissive += fresnel * _EdgeGlowStrength;_EdgeGlowStrength建议设 0.15 左右,太高就像假塑料,太低了没有手作感。釉面部分可以把 Fresnel 强度提升 到 0.45,因为釉面对高角度入射光反射更强。
5. 常见坑与排查:动态 Mesh 撕裂、法线断层与浮点漂移
5.1 网格撕裂:顶点合并算法的粗心后果
现象:拉了十几笔之后,陶坯口沿出现一条从顶到底的裂缝,转动视角能看到背景颜色从裂缝里透出来。
原因:用的是最朴素的方案——相邻两列顶点在旋转接缝处没有焊接,而在绘制三角形时只画了前半圈的索引,后半圈索引引用了错误的顶点;或者我在生成profile时把首尾的半径值设成了近似但不完全相等(比如 0.4998 和 0.5002),接缝处就有了微小的几何间隙。
解决:先检查生成网格的收尾段是否由「跨越接缝的索引」补齐,其次在变形前设一个顶点容差:当两顶点距离小于 1e-4 时,视为同一位置。实际操作中尽量不做顶点合并,而是保证变形权重场在接缝处是对称的。每做一次完整变形帧后,用一遍Mesh.RecalculateNormals和RecalculateBounds兜底。
5.2 法线断层与烘焙光照的衔接
现象:陶坯在阴影里看是连续的,但在灯光直接照射下出现一条「硬边」,像棱线一样。
原因:Lathe 网格表面是连续光滑的,但我在变形时只位移了顶点,没有同步tangent和normal,再加上 Shader 里用了Normal Map,导致光照计算用到了旧法线。烘焙光照贴图也是重灾区——我加了变形功能后忘掉Renderer.RegisterUVModifier,结果静态光照照在旧网格上。
解决:每次变形后必须调用Mesh.RecalculateNormals;如果用了自定义法线合并算法,确保在RecalculateTangents(使用 MeshUtility)之后再做。另一个更稳的技巧是用顶点色叠加一个较轻的法线扰动,用 Lightmap 只存低频光照,高光则靠实时反射探针补,这样法线错误不会直接暴露。
5.3 浮点漂移与坐标归零
现象:拖拽 50 次后,陶坯的中心不再在世界原点,微小的缩放和旋转让后续交互手感完全变味。
原因:连续verts[i] += normals[i] * strength * falloff累积浮点误差,尤其当模型处于远离坐标原点的位置时,Mathf.Max(verts[i].x, 0.02f)这种保护会把中心轴慢慢推向非 0 值。
解决:每次撤销/重做后或者每 20 帧,把所有顶点坐标的包围盒中心归到模型原点;我一般是transform.position不动,用bounds.center做校正,把物体坐标中心拉回原模型 pivot。浮动单位一律在局部空间操作,不要在屏幕空间累积位移。
5.4 缩放手势与触摸直径的映射
现象:在 iPad 上陶器口沿太细,手指轻轻一碰就捏穿;而同一套代码在 PC 鼠标上体验正常。
原因:移动端触摸的单位是像素,而我在鼠标代码里把拖拽半径设成了 0.05 米的固定世界距离;触摸屏上同样 0.05 米换算成物理像素可能只有十几像素,极其灵敏。
解决:把触摸半径映射为「相对于相机伸缩比例」的物理尺寸,比如相机 Zoom 到离陶坯 0.6 米时取 0.03,Zoom 到 0.3 米时取 0.015。做法并不复杂:在 Update 里拿相机到目标距离乘以一个经验系数再乘DeviceDPI归一化。这里可以用Mathf.Clamp限制最小半径,否则指尖微颤在陶坯表面就是灾难。
5.5meshCollider.sharedMesh的更新时机
现象:射线穿过陶坯中间的空洞,或者点击选中了口内的背面而不是外表面。
原因:Raycast用的是collider.sharedMesh的旧版本,而变形后的MeshFilter.sharedMesh已经更新了顶点,Collider 没有跟随更新。
解决:按我前面的写法,变形后必须把新 Mesh 赋给meshCollider.sharedMesh;如果每次都new Mesh,会连续产生 GC 压力,我习惯使用MeshFilter.mesh读引用,再直接sharedMesh = meshFilter.sharedMesh侦测是否为新实例。其实更优的方案是用Physics.BoxCast检测击中点附近的包围盒,再把MeshCollider换成BoxCollider— 但这只适用于未变形的粗坯阶段,变形大的场景还是得老老实实更新 MeshCollider。
6. 进阶技巧:用 Mathf.PerlinNoise 做拉坯涡旋纹理,以及厚度检测
6.1 拉坯涡旋纹路:不是贴图,而是真实顶点偏移
陶艺拉坯的底部通常有一圈圈螺旋纹,这个用手工建模非常痛苦,但其实用Mathf.PerlinNoise就能做。取顶点当前的高度和角度,把噪声值映射为轴向上的微小偏移,让纹路的密度随高度变化保持一致。
for (int i = 0; i < verts.Length; i++) { float r = verts[i].magnitude; float h = verts[i].y; float a = Mathf.Atan2(verts[i].z, verts[i].x); float noise = Mathf.PerlinNoise(r * 1.5f + _Seed, a * 2.5f); verts[i].y += (noise - 0.5f) * _GroveStrength * Mathf.Clamp01(1f - h / _MaxHeight); }我一般把_GroveStrength设在 0.003 到 0.008 之间。太大的话纹路变成了波浪,太小的纹理在光照下完全不可见。这个偏移在拉坯阶段做一次就够了,后续的捏合不会把它抹平。需要注意:如果做的是光滑釉面陶器,这里的纹路反而会成为瑕疵;只在素陶风格或未施釉的底部区域启用这个逻辑比较合理。
6.2 厚度检测:让「成品」不只是一个空壳
陶艺模拟做得再像,如果用户把自己的作品拉伸成了一个处处等厚的壳,那也失去了陶艺本质。我用一个快速近似算法来评估局部厚度:取每个顶点沿法线正向和反向发射两条射线,记录首次撞击距离的和作为厚度估计值。这个检测不用 100% 准确,但对用户而言,能提示「这面墙快捏穿了」比什么都重要。
bool IsThinAt(Vector3 probePos, float threshold) { if (Physics.Raycast(probePos, normalDir, out hitNear, threshold + 0.05f)) { if (Physics.Raycast(probePos, -normalDir, out hitFar, threshold + 0.05f)) { return hitNear.distance + hitFar.distance < threshold; } } return false; }通常我把阈值设成 0.02 米,低于这个值就显示一键修复按钮,把变形区域回到上一个增量快照。这个检测不要每帧跑全量顶点,我一般以用户最近交互的 100 个顶点为候选。
在那之后我每次做陶艺项目,都强制在变形流程里跑一遍厚度报警 + 顶点快照,养成了肌肉记忆,后面出问题的概率少了很多。做这种交互式模拟,最值钱的不一定是华丽的手法,而是「你捏一下,程序就有响应」的及时感。希望这些踩坑记录对你有帮助。
本文还有配套的精品资源,点击获取