简介一份基于 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 Dictionaryint, Vector3[] undoStack; // 这里只存受影响顶点 public void RecordUndoStep(Listint 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.15Albedo 取深米黄色上层是釉面Smoothness 0.85Albedo 高光接近白色然后用高度图或顶点色做 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.03Zoom 到 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 个顶点为候选。在那之后我每次做陶艺项目都强制在变形流程里跑一遍厚度报警 顶点快照养成了肌肉记忆后面出问题的概率少了很多。做这种交互式模拟最值钱的不一定是华丽的手法而是「你捏一下程序就有响应」的及时感。希望这些踩坑记录对你有帮助。本文还有配套的精品资源点击获取
