Unity动态Mesh光照异常?详解顶点法线原理与计算实战
1. 项目概述当Mesh遇上光照法线为何成为关键在Unity里自己动手建一个Mesh然后贴上精心准备的贴图这感觉就像亲手捏了个泥人然后给它穿上衣服成就感满满。但很多朋友都踩过这个坑当你把Mesh丢进一个有光照的场景里贴图要么变得一片死黑要么亮得刺眼完全不是贴图原本的颜色和质感。这背后的“元凶”十有八九就是法线Normal。你可能听说过法线贴图能增加细节但这里说的“法线”是更基础的东西——顶点法线Vertex Normal。它决定了Mesh的每个面、每个顶点如何与场景中的光线互动。一个自建的Mesh如果顶点法线数据是乱的或者干脆没有那它在光照计算里就会像个“隐形人”或者“怪胎”导致贴图显示异常。今天我们就来彻底搞懂这个让无数Unity开发者头疼的问题从原理到实操一步步让你的自建Mesh在光照下“正常”起来。2. 核心原理拆解法线、光照与贴图显示的三者关系要解决问题得先明白问题是怎么来的。Unity的标准着色器比如Standard Shader在计算物体颜色时是一个综合运算。它不仅仅看你贴上去的那张图片Albedo贴图还会综合考虑光照信息。而光照如何影响这个表面法线数据是核心输入之一。2.1 顶点法线Mesh表面的“方向标”你可以把Mesh的每个三角面想象成一块微小的平板。对于这块平板我们可以定义一条垂直于它表面的向量这就是面法线。而顶点法线则是共享这个顶点的所有面的面法线经过某种平均计算后赋值给该顶点的向量。在渲染管线中特别是进行光照计算如兰伯特漫反射时着色器会用到这些顶点法线或在片段着色器中插值后的法线来计算光线与表面法线的夹角从而决定该点应该有多亮。对于一个从3D建模软件如Blender、Maya导出的标准模型这些顶点法线数据是预先计算好并保存在模型文件里的。但当我们通过代码动态创建MeshMeshFilter.mesh或new Mesh()时如果我们不主动去设置这些法线Unity会怎么办注意Unity不会为你自动生成合理的法线对于动态创建的Mesh如果你没有赋值mesh.normals那么该数组默认是空的null或者是一个零向量数组。零向量是没有方向的这会导致光照计算出现不可预料的结果通常是整个面变成黑色。2.2 错误法线导致的典型贴图显示问题全黑或全暗这是最常见的情况。如果顶点法线是零向量或者方向完全错误比如指向模型内部那么计算出的光线夹角余弦值可能为负或零导致漫反射分量几乎为零物体看起来就是黑的尽管你的Albedo贴图色彩鲜艳。不自然的明暗交界或“平面化”如果你的Mesh由多个三角面组成但你为所有顶点设置了相同的法线例如都指向世界空间上方那么整个Mesh在光照下就会像一个统一的平面那样反射光线失去其应有的体积感和曲面感贴图看起来会非常平。高光异常对于包含高光计算的着色器如Standard Shader的金属工作流错误的法线同样会导致高光反射出现在奇怪的位置或者完全消失。所以“贴图受光照问题”本质上是“Mesh基础光照信息缺失或错误”导致贴图颜色被错误的光照计算结果所覆盖或调制。解决之道就是为Mesh提供正确的顶点法线数据。3. 解决方案实战为自建Mesh计算并赋予法线知道了原因我们来动手解决。假设我们正在动态创建一个简单的四边形Mesh两个三角面。以下是完整的步骤和代码解析。3.1 构建一个没有法线的Mesh问题复现首先我们创建一个会出问题的Mesh以便观察。using UnityEngine; [RequireComponent(typeof(MeshFilter), typeof(MeshRenderer))] public class CreateMeshWithoutNormals : MonoBehaviour { public Material material; // 使用Standard Shader的材质 void Start() { Mesh mesh new Mesh(); mesh.name Quad Mesh (No Normals); // 1. 定义顶点位置 (4个顶点构成一个平面四边形) Vector3[] vertices new Vector3[4]; vertices[0] new Vector3(-0.5f, -0.5f, 0); vertices[1] new Vector3(0.5f, -0.5f, 0); vertices[2] new Vector3(-0.5f, 0.5f, 0); vertices[3] new Vector3(0.5f, 0.5f, 0); mesh.vertices vertices; // 2. 定义三角形索引 (用两个三角形组成四边形) int[] triangles new int[6]; // 第一个三角形左下右下左上 triangles[0] 0; // 左下 triangles[1] 1; // 右下 triangles[2] 2; // 左上 // 第二个三角形右下右上左上 triangles[3] 1; // 右下 triangles[4] 3; // 右上 triangles[5] 2; // 左上 mesh.triangles triangles; // 3. 定义UV坐标 (用于贴图映射) Vector2[] uv new Vector2[4]; uv[0] new Vector2(0, 0); // 左下 uv[1] new Vector2(1, 0); // 右下 uv[2] new Vector2(0, 1); // 左上 uv[3] new Vector2(1, 1); // 右上 mesh.uv uv; // 关键我们没有设置 mesh.normals // 应用Mesh GetComponentMeshFilter().mesh mesh; GetComponentMeshRenderer().material material; } }把这个脚本挂到一个空GameObject上并赋予一个使用Standard Shader的材质带贴图。运行后将这个物体放到一个有方向光Directional Light的场景中。你很可能会发现这个四边形一片漆黑或者颜色极其暗淡完全看不到贴图应有的样子。这就是问题所在。3.2 方法一手动计算面法线并平均为顶点法线这是最经典、最直观的方法适合理解原理也适用于对法线有特殊控制需求的简单Mesh。核心思路遍历每一个三角形。根据三角形的三个顶点位置计算出这个三角形的面法线通过叉乘。将这个面法线累加到组成该三角形的三个顶点的法线累加器上。遍历完所有三角形后将每个顶点的累加法线向量标准化Normalize得到最终的顶点法线。using UnityEngine; [RequireComponent(typeof(MeshFilter), typeof(MeshRenderer))] public class CreateMeshWithManualNormals : MonoBehaviour { public Material material; void Start() { Mesh mesh new Mesh(); mesh.name Quad Mesh (Manual Normals); // ... 顶点、三角形、UV的设置与上一个例子完全相同此处省略 ... Vector3[] vertices new Vector3[4] { ... }; int[] triangles new int[6] { ... }; Vector2[] uv new Vector2[4] { ... }; mesh.vertices vertices; mesh.triangles triangles; mesh.uv uv; // --- 核心手动计算法线 --- Vector3[] normals new Vector3[vertices.Length]; // 初始化法线数组 // 第一步创建一个临时数组用于累加法线初始化为零向量 Vector3[] normalSums new Vector3[vertices.Length]; // 遍历所有三角形每3个索引一个三角形 for (int i 0; i triangles.Length; i 3) { int indexA triangles[i]; int indexB triangles[i 1]; int indexC triangles[i 2]; Vector3 pointA vertices[indexA]; Vector3 pointB vertices[indexB]; Vector3 pointC vertices[indexC]; // 计算三角形的两条边 Vector3 sideAB pointB - pointA; Vector3 sideAC pointC - pointA; // 通过叉乘计算面法线 (注意顺序影响法线朝向) // Unity中使用左手坐标系叉乘顺序影响法线方向。通常保证顶点顺序为逆时针时法线朝外。 Vector3 faceNormal Vector3.Cross(sideAB, sideAC).normalized; // 将这个面的法线累加到其三个顶点的累加器上 normalSums[indexA] faceNormal; normalSums[indexB] faceNormal; normalSums[indexC] faceNormal; } // 第二步将累加后的法线向量标准化得到最终的顶点法线 for (int i 0; i normals.Length; i) { // 如果累加结果不是零向量则标准化 if (normalSums[i] ! Vector3.zero) { normals[i] normalSums[i].normalized; } else { // 理论上不应该发生但安全起见给一个默认朝上的法线 normals[i] Vector3.up; } } mesh.normals normals; // 将计算好的法线数组赋给Mesh // --- 法线计算结束 --- GetComponentMeshFilter().mesh mesh; GetComponentMeshRenderer().material material; } }运行这个脚本你的四边形应该能正常显示贴图并对光照做出正确反应了。所有顶点法线都垂直于四边形平面例如如果四边形在XY平面法线就指向Z轴正方向。实操心得顶点顺序与法线方向上面代码中Vector3.Cross(sideAB, sideAC)的顺序决定了面法线的方向。在Unity的左手坐标系中当你从三角形的正面顶点顺序为逆时针方向看时Cross(A, B)得到的法线是指向你的朝外。确保你的三角形索引顺序是统一的通常是逆时针这样才能得到一致朝外的法线否则可能会导致Mesh内表面被照亮而外表面变黑。3.3 方法二使用Mesh.RecalculateNormals()方法对于大多数情况尤其是当你只是想要一个“合理”的平滑着色效果时Unity提供了一个一键式解决方案Mesh.RecalculateNormals()。这个方法会自动遍历Mesh的所有三角面计算面法线然后按照一定的加权平均方式通常是按面积或夹角分配给顶点最后标准化。using UnityEngine; [RequireComponent(typeof(MeshFilter), typeof(MeshRenderer))] public class CreateMeshWithRecalcNormals : MonoBehaviour { public Material material; void Start() { Mesh mesh new Mesh(); mesh.name Quad Mesh (Recalc Normals); // ... 同样先设置顶点、三角形、UV ... Vector3[] vertices new Vector3[4] { ... }; int[] triangles new int[6] { ... }; Vector2[] uv new Vector2[4] { ... }; mesh.vertices vertices; mesh.triangles triangles; mesh.uv uv; // 一行代码解决 mesh.RecalculateNormals(); // 可选同时重新计算边界和切线如果用到法线贴图 // mesh.RecalculateBounds(); // mesh.RecalculateTangents(); GetComponentMeshFilter().mesh mesh; GetComponentMeshRenderer().material material; } }这种方法简单快捷对于简单的凸状体或需要平滑着色的模型效果很好。它内部实现的平均算法通常能产生比较自然的效果。3.4 方法三使用Mesh.RecalculateNormals的重载方法Unity 2019.3从Unity 2019.3开始RecalculateNormals方法提供了一个重载版本允许你传入一个angle参数。这个参数定义了“平滑角度”。只有当相邻三角面之间的夹角小于这个角度时它们的法线才会被平滑平均如果大于这个角度则会在该顶点处生成一个“硬边”即顶点拥有多个不同的法线用于表现棱角。// 假设mesh已经设置了vertices和triangles mesh.RecalculateNormals(60); // 夹角小于60度的面会被平滑这对于创建既有平滑曲面又有硬边转折的模型非常有用。例如创建一个低精度的圆柱体你希望侧面是平滑的但顶部和底部与侧面交界处应该是硬边。4. 进阶议题与疑难排查解决了基本显示问题后我们可能会遇到一些更复杂的情况。4.1 平滑着色 vs 硬边Flat Shading有时我们并不想要平滑的效果比如想做一个像素风游戏的低多边形模型需要清晰的棱角感。这就是“硬边”效果在视觉上每个三角面都是平的。实现硬边的关键在于让共享一个顶点的每个三角面赋予该顶点不同的法线数据。本质上你需要“拆分”顶点。在手动计算法中这意味着同一个空间位置上的顶点因为属于不同的面而拥有不同的法线在数据上它们是不同的顶点。// 以一个立方体的一个角为例一个角被三个面共享。 // 为了硬边我们需要为这个空间位置创建3个不同的顶点索引每个索引对应一个面的法线。 Vector3[] vertices new Vector3[24]; // 立方体8个角*3个面24个顶点实际有重复位置 Vector3[] normals new Vector3[24]; // ... 为每个面单独设置其顶点的法线为该面的面法线 ...对于动态Mesh手动管理拆分顶点比较繁琐。更常见的做法是先按平滑着色的方式创建Mesh并计算法线。如果某个边需要硬边找到共享该边的两个面然后复制这条边上的顶点并修改新顶点的法线为各自面的面法线同时更新三角形索引。注意事项Mesh.RecalculateNormals(angle)方法通过角度阈值在一定程度上自动化了这个过程但对于完全精确的控制或者复杂的拓扑结构可能仍需手动处理顶点数据。4.2 法线空间模型空间 vs 切线空间我们上面计算和设置的都是模型空间下的法线。这意味着法线向量的方向是相对于模型自身的坐标系(0,0,0)通常是模型原点定义的。这对于简单的自定义Mesh和基础光照是足够的。但是如果你想要使用法线贴图来为低模增加高模的表面细节那么事情就复杂了。法线贴图中存储的法线信息通常是基于切线空间的。切线空间是一个建立在每个顶点上的局部坐标系其三个轴通常是法线N、切线T、副切线/双切线B。要正确使用法线贴图你的Mesh不仅需要normals还需要正确的tangents切线数组。Unity提供了Mesh.RecalculateTangents()方法来根据UV和法线自动计算切线但这对于某些特殊的UV展开方式可能计算不准确。对于动态生成且要使用法线贴图的Mesh你需要确保UV展开没有镜像、扭曲或者考虑更复杂的切线计算库。4.3 常见问题排查清单当你按照上述方法设置了法线但贴图显示依然不正常时可以按以下清单排查问题现象可能原因排查步骤与解决方案Mesh完全黑色1. 法线数组未设置或全为零。2. 法线方向错误指向模型内部。3. 材质球或着色器不支持光照/设置错误。1. 在代码中Debug.Log(mesh.normals.Length)和其中一个法线值确认已赋值且非零。2. 在Scene视图开启“法线显示”通常在线框模式下观察法线朝向。手动计算时检查叉乘顺序。3. 临时换用Unlit Shader材质如果贴图正常显示则问题在光照计算环节。检查光源强度、方向材质是否使用了正确的Shader如Standard。光照下颜色异常过亮/过暗1. 法线没有标准化长度不为1。2. 顶点法线计算平均方式不合理导致方向偏差。1. 确保在赋值前对每个顶点法线执行了.normalized。2. 尝试使用mesh.RecalculateNormals()替代手动计算看问题是否解决。如果手动计算检查累加逻辑是否正确。特定区域有奇怪的明暗条纹1. 顶点法线不连续存在“硬边”但非预期。2. 共享顶点的三角面之间夹角过大RecalculateNormals(angle)的平滑角度设置过小。1. 检查Mesh的拓扑结构确认是否需要硬边。如果需要平滑确保共享顶点的所有面的法线被正确平均。2. 增大RecalculateNormals的angle参数或检查手动计算中是否有顶点被错误地拆分。使用法线贴图后效果错乱1. 切线Tangents数据错误或缺失。2. UV坐标有问题如重叠、镜像导致切线计算错误。1. 调用mesh.RecalculateTangents()生成切线。如果问题依旧可能需要检查或手动计算切线。2. 检查Mesh的UV坐标。确保UV在0-1范围内且没有翻转。对于动态生成的复杂Mesh考虑使用第三方库进行更稳健的切线空间计算。在移动平台或某些情况下变黑着色器变体编译问题或者Mesh数据在修改后未标记为“脏”。1. 确保在修改完Mesh所有属性vertices, triangles, normals等后再将其赋值给MeshFilter。2. 如果动态每帧修改Mesh考虑使用mesh.MarkDynamic()并在修改后调用mesh.UploadMeshData(false)。3. 检查Player Settings中相关的图形API设置。4.4 性能考量与最佳实践何时计算法线对于静态的、一次性生成的Mesh在生成完毕后计算一次法线即可。对于动态变形的Mesh如软体、布料需要在每帧变形后重新计算法线RecalculateNormals这会带来一定的CPU开销。RecalculateNormalsvs 手动计算对于大多数常规需求RecalculateNormals()是首选它经过优化且代码简洁。只有当你需要极其特殊的法线平均权重如按面面积加权而非默认方式或者需要实现某些特定艺术效果如风格化的法线时才考虑手动计算。复用Mesh如果场景中有大量相同的自定义Mesh如相同形状的草、石块务必在内存中只创建一份Mesh实例然后被多个MeshFilter共享。不要在每一个GameObject的Start/Awake里都new一个Mesh并计算法线。法线精度Mesh.normals是Vector3数组。对于需要极高精度的项目如大型世界、科学可视化可以考虑在计算中使用双精度double但最终赋值给Mesh时仍需转换为floatVector3是float。计算过程中的精度损失通常可以接受。5. 一个综合案例生成具有平滑与硬边组合的棱柱让我们结合以上知识创建一个六边形棱柱Hexagonal Prism顶部和底部是硬边侧面是平滑的。using UnityEngine; public class CreateHexagonalPrism : MonoBehaviour { public Material material; public float radius 1f; public float height 2f; void Start() { Mesh mesh new Mesh(); mesh.name Hex Prism; // 1. 生成顶点 int segments 6; int vertexCount segments * 4 2; // 侧面上下环各6个顶部中心1个底部中心1个 Vector3[] vertices new Vector3[vertexCount]; Vector3[] normals new Vector3[vertexCount]; // 顶部中心点 (索引0) vertices[0] Vector3.up * height / 2; normals[0] Vector3.up; // 底部中心点 (索引1) vertices[1] Vector3.down * height / 2; normals[1] Vector3.down; int vertIndex 2; // 生成顶部和底部的环形顶点以及侧面的上下环顶点 for (int i 0; i segments; i) { float angle 2 * Mathf.PI * i / segments; float x Mathf.Cos(angle) * radius; float z Mathf.Sin(angle) * radius; // 顶部环形顶点 (用于顶部面和侧面) vertices[vertIndex] new Vector3(x, height / 2, z); normals[vertIndex] Vector3.up; // 顶部面的法线朝上 vertIndex; // 底部环形顶点 (用于底部面和侧面) vertices[vertIndex] new Vector3(x, -height / 2, z); normals[vertIndex] Vector3.down; // 底部面的法线朝下 vertIndex; // 侧面顶部环顶点 (与顶部环形顶点位置相同但法线不同) vertices[vertIndex] new Vector3(x, height / 2, z); // 侧面法线是水平向外的由顶点在XZ平面的投影决定 Vector3 horizontalNormal new Vector3(x, 0, z).normalized; normals[vertIndex] horizontalNormal; vertIndex; // 侧面底部环顶点 (与底部环形顶点位置相同但法线不同) vertices[vertIndex] new Vector3(x, -height / 2, z); normals[vertIndex] horizontalNormal; // 使用相同的水平法线 vertIndex; } // 2. 生成三角形索引 Listint triangles new Listint(); // 顶部面 (连接中心点到顶部环形顶点) for (int i 0; i segments; i) { int topRingVertIdx 2 i * 4; // 顶部环形顶点的起始索引 int nextTopRingVertIdx 2 ((i 1) % segments) * 4; triangles.Add(0); // 顶部中心 triangles.Add(nextTopRingVertIdx); triangles.Add(topRingVertIdx); } // 底部面 (连接底部中心点到底部环形顶点注意顶点顺序要逆时针从背面看) for (int i 0; i segments; i) { int bottomRingVertIdx 2 i * 4 1; // 底部环形顶点的起始索引 int nextBottomRingVertIdx 2 ((i 1) % segments) * 4 1; triangles.Add(1); // 底部中心 triangles.Add(bottomRingVertIdx); triangles.Add(nextBottomRingVertIdx); } // 侧面 (使用专门为侧面创建的顶点索引偏移2和3) for (int i 0; i segments; i) { int currentSeg i; int nextSeg (i 1) % segments; int sideTopCurrent 2 currentSeg * 4 2; // 侧面顶部环顶点 int sideBottomCurrent 2 currentSeg * 4 3; // 侧面底部环顶点 int sideTopNext 2 nextSeg * 4 2; int sideBottomNext 2 nextSeg * 4 3; // 第一个三角形 (左下右下左上) triangles.Add(sideBottomCurrent); triangles.Add(sideBottomNext); triangles.Add(sideTopCurrent); // 第二个三角形 (右下右上左上) triangles.Add(sideBottomNext); triangles.Add(sideTopNext); triangles.Add(sideTopCurrent); } mesh.vertices vertices; mesh.normals normals; // 使用我们精心设置的法线 mesh.triangles triangles.ToArray(); // 3. 生成UV (简化版沿侧面展开) Vector2[] uvs new Vector2[vertexCount]; uvs[0] new Vector2(0.5f, 0.5f); // 顶部中心 uvs[1] new Vector2(0.5f, 0.5f); // 底部中心 for (int i 0; i segments; i) { float u (float)i / segments; // 顶部/底部环形顶点UV uvs[2 i * 4] new Vector2(Mathf.Cos(2 * Mathf.PI * i / segments) * 0.5f 0.5f, Mathf.Sin(2 * Mathf.PI * i / segments) * 0.5f 0.5f); uvs[2 i * 4 1] new Vector2(Mathf.Cos(2 * Mathf.PI * i / segments) * 0.5f 0.5f, Mathf.Sin(2 * Mathf.PI * i / segments) * 0.5f 0.5f); // 侧面顶点UV uvs[2 i * 4 2] new Vector2(u, 1); // 侧面顶部 uvs[2 i * 4 3] new Vector2(u, 0); // 侧面底部 } // 处理最后一个侧面的接缝让UV连续 uvs[2 (segments - 1) * 4 2] new Vector2(1, 1); uvs[2 (segments - 1) * 4 3] new Vector2(1, 0); mesh.uv uvs; mesh.RecalculateBounds(); // 重新计算包围盒 GetComponentMeshFilter().mesh mesh; GetComponentMeshRenderer().material material; } }这个案例的关键在于通过在不同索引位置复用相同的空间坐标但赋予它们不同的法线我们实现了硬边。顶部和底部的面使用朝上/朝下的法线而侧面则使用水平向外的法线。这样在顶部/底部与侧面的交界处虽然顶点坐标相同但因为法线不同着色器会将其视为不同的顶点进行处理从而渲染出清晰的硬边效果。而侧面本身因为每个侧边上的两个顶点共享相同的水平法线所以侧面是平滑的。通过这个从原理到基础实现再到进阶问题和综合案例的完整梳理相信你已经对Unity中自建Mesh的法线问题有了透彻的理解。核心就是记住没有法线Mesh就无法正确参与标准的光照计算你的贴图也就失去了它应有的光彩。无论是使用内置的RecalculateNormals还是手动精细控制赋予你的Mesh正确的“方向感”是让它活起来的第一步。