图形学基础:重心坐标插值原理与在Unity/Unreal中的平滑着色实践
1. 项目概述与核心价值最近在几个独立游戏项目里我反复遇到了一个看似基础但直接影响画面表现的问题如何在3D模型的三角形面上实现顶点颜色或纹理坐标的平滑过渡。比如你想让一个低多边形的地形根据高度渐变颜色或者让一个角色模型的皮肤光影过渡更自然。如果只是简单地在三个顶点上设置颜色然后在面上进行线性插值结果往往会得到生硬的色块边缘的锯齿感非常明显尤其是在移动视角或模型变形时这种不连续感会破坏沉浸感。这个问题的核心就在于我们如何定义三角形内部任意一点的颜色。最朴素的想法是平均但平均无法反映点与顶点之间的空间关系。这时重心坐标插值法就登场了。它不仅是计算机图形学中一个基础得不能再基础的概念更是现代GPU渲染管线无论是Unity的ShaderLab还是Unreal的HLSL/Material Graph进行光栅化插值的数学基石。理解它你就能真正明白为什么你的顶点色可以平滑铺开为什么法线贴图能在低模上生效甚至能为后续更高级的着色器技巧打下坚实的基础。简单来说这个项目就是带你亲手揭开这层“魔法”的面纱。我们将不依赖引擎的黑盒插值而是自己动手在CPU侧比如用C#脚本实现重心坐标的计算并应用到一个三角形网格的颜色插值上。我会分别在Unity和Unreal Engine中给出可运行的示例让你不仅能看懂理论更能立刻在编辑器中看到平滑的渐变效果。无论你是刚接触Shader的新手还是想巩固图形学基础的老手这个过程都能让你对渲染的理解更深一层。2. 重心坐标插值法原理深度拆解2.1 什么是重心坐标想象一个三角形它有三个顶点A、B、C。对于这个三角形内的任意一点P我们能否用A、B、C的某种组合来表示它呢重心坐标 (Barycentric Coordinates) 给出了答案存在三个权重系数 α, β, γ使得P α * A β * B γ * C并且满足α β γ 1 且α ≥ 0, β ≥ 0, γ ≥ 0。这三个系数 (α, β, γ) 就是点P关于三角形ABC的重心坐标。它们的几何意义非常直观α表示点P“靠近”顶点A的程度。当P就是A点时(α, β, γ) (1, 0, 0)。β表示点P“靠近”顶点B的程度。γ表示点P“靠近”顶点C的程度。如果某个系数为0说明点P位于该系数对应顶点对面的边上。如果三个系数都大于0则点P在三角形内部。这是一种基于面积的解释α 的值正比于子三角形PBC的面积与总面积之比。2.2 为什么它能实现平滑插值平滑过渡的关键在于“连续性”和“线性”。重心坐标提供了一种在三角形域内线性插值的完美工具。假设我们三个顶点A、B、C分别有颜色Color_A,Color_B,Color_C。对于三角形内任意点P其插值后的颜色Color_P可以定义为Color_P α * Color_A β * Color_B γ * Color_C由于α, β, γ随着点P的位置变化而连续、线性地变化因此Color_P也会随之连续、线性地变化。从顶点Aα1到边BCα0颜色的过渡是严格沿着一条直线的权重变化这就保证了在三角形内部不会出现突兀的跳跃。当多个三角形共享顶点颜色时只要共享顶点的颜色值相同那么跨三角形的颜色过渡也必然是平滑的因为沿着共享边的插值权重是一致的。注意这里说的“线性”是指在三角形这个二维空间内的线性而不是指颜色空间如sRGB中的线性。在实际着色器中我们通常在线性颜色空间进行计算以获得物理正确的结果。2.3 与常见错误方法的对比为了凸显重心坐标的优势我们来看看两种常见的错误或简陋方法顶点颜色平均面着色直接取(Color_A Color_B Color_C) / 3作为整个三角形的颜色。结果就是每个三角形都是一个纯色块模型看起来像低折纸毫无平滑感。基于距离的权重根据P点到A、B、C三个顶点的距离的倒数来分配权重。这个方法直觉上似乎可行但它不是线性插值。在边上其变化率可能不是线性的导致插值结果不满足“在边上仅由两个顶点决定”的性质容易在共享边处产生接缝或不连续。下表清晰地对比了这几种方法插值方法平滑性计算复杂度跨三角形连续性是否符合图形学标准重心坐标法完美平滑线性过渡中等需计算面积或解方程保证连续当顶点属性一致时是GPU光栅化标准顶点颜色平均不平滑色块化极低不连续否基于距离的权重可能平滑但非线性较高需计算3次距离通常不连续否由此可见重心坐标法是唯一能同时保证三角形内部线性平滑和三角形之间连续性的正确方法这也是它被GPU硬件广泛采用的原因。3. 核心算法实现与代码解析理解了原理我们来实现它。计算点P在三角形ABC中的重心坐标有多种方法这里介绍两种最常用的基于面积比的方法和基于向量求解的方法。3.1 方法一基于面积比的计算几何直观根据几何意义α 面积(PBC) / 面积(ABC) β 面积(PCA) / 面积(ABC) γ 1 - α - β。 三角形面积可以通过二维向量叉积的模来计算。对于三维空间中的三角形我们通常投影到某个平面如三角形所在平面来计算。// C# 示例代码 (Unity) public static Vector3 Barycentric(Vector2 p, Vector2 a, Vector2 b, Vector2 c) { Vector2 v0 b - a; Vector2 v1 c - a; Vector2 v2 p - a; float d00 Vector2.Dot(v0, v0); float d01 Vector2.Dot(v0, v1); float d11 Vector2.Dot(v1, v1); float d20 Vector2.Dot(v2, v0); float d21 Vector2.Dot(v2, v1); float denom d00 * d11 - d01 * d01; // 平行四边形面积平方 float v (d11 * d20 - d01 * d21) / denom; // 对应 β float w (d00 * d21 - d01 * d20) / denom; // 对应 γ float u 1.0f - v - w; // 对应 α return new Vector3(u, v, w); // (α, β, γ) }代码解读 这段代码使用了更稳定的向量点积方法来计算面积比避免了直接叉积可能带来的数值精度问题。denom是两倍三角形ABC面积的平方的某种表示。计算出的u, v, w分别对应 α, β, γ。这个方法在点P位于三角形外时会计算出负的权重。3.2 方法二基于线性方程组的求解通用高效将P α*A β*B γ*C和α β γ 1结合可以推导出求解 β 和 γ 的方程组然后得到 α。在二维情况下公式非常简洁。// C 示例代码 (Unreal Engine 风格) FVector2D Barycentric(const FVector2D P, const FVector2D A, const FVector2D B, const FVector2D C) { FVector2D v0 B - A; FVector2D v1 C - A; FVector2D v2 P - A; float d00 FVector2D::DotProduct(v0, v0); float d01 FVector2D::DotProduct(v0, v1); float d11 FVector2D::DotProduct(v1, v1); float d20 FVector2D::DotProduct(v2, v0); float d21 FVector2D::DotProduct(v2, v1); float denom d00 * d11 - d01 * d01; float beta (d11 * d20 - d01 * d21) / denom; float gamma (d00 * d21 - d01 * d20) / denom; float alpha 1.0f - beta - gamma; return FVector2D(alpha, beta); // γ 可以通过 1-α-β 得到 }实操心得 在真实项目中尤其是处理大量三角形时如软件光栅化方法二的变体更为常用。对于三维空间中的点你需要先将点P和三角形投影到二维平面通常是剔除法向量的最大分量后的平面以防止退化。Unity的Mesh类或Unreal的ProceduralMeshComponent中处理顶点数据时本质上也是遵循这个逻辑。3.3 颜色插值函数一旦有了重心坐标插值就水到渠成// Unity C# 颜色插值 Color InterpolateColor(Vector3 barycentricCoords, Color colorA, Color colorB, Color colorC) { float alpha barycentricCoords.x; float beta barycentricCoords.y; float gamma barycentricCoords.z; // 对每个颜色通道进行线性插值 float r alpha * colorA.r beta * colorB.r gamma * colorC.r; float g alpha * colorA.g beta * colorB.g gamma * colorC.g; float b alpha * colorA.b beta * colorB.b gamma * colorC.b; float a alpha * colorA.a beta * colorB.a gamma * colorC.a; return new Color(r, g, b, a); }重要提示在图形学中除了颜色法线、纹理坐标UV、顶点切线等属性都需要进行插值。但法线插值后必须重新归一化Normalize否则长度会变影响光照计算。而深度值Z在透视投影下需要进行透视校正插值这是一个关键进阶知识点。简单的屏幕空间线性插值会导致纹理扭曲。不过在我们这个颜色插值的入门示例中可以暂不考虑。4. Unity引擎实战示例让我们在Unity中创建一个可视化的例子。我们将动态生成一个三角形网格并为每个顶点赋予不同的颜色然后在Update中根据鼠标位置计算重心坐标并实时插值显示一个跟随鼠标的采样点颜色。4.1 场景搭建与脚本编写创建对象在Unity中新建一个空物体命名为BarycentricDemo。添加组件为其添加Mesh Filter和Mesh Renderer组件。在Mesh Renderer的材质中使用Standard或Unlit/Color着色器。创建脚本新建一个C#脚本BarycentricInterpolation.cs并挂载到该物体上。using UnityEngine; [RequireComponent(typeof(MeshFilter), typeof(MeshRenderer))] public class BarycentricInterpolation : MonoBehaviour { public Color colorA Color.red; public Color colorB Color.green; public Color colorC Color.blue; private Mesh mesh; private Vector3[] vertices; private Color[] vertexColors; void Start() { CreateTriangleMesh(); UpdateVertexColors(); } void Update() { // 示例在屏幕上显示插值逻辑实际插值由Shader完成。 // 这里演示CPU计算我们可以画一个Gizmo或UI来展示。 DebugDrawInterpolation(); } void CreateTriangleMesh() { MeshFilter meshFilter GetComponentMeshFilter(); mesh new Mesh(); meshFilter.mesh mesh; // 定义三角形的三个顶点在模型本地空间 vertices new Vector3[] { new Vector3(0, 0, 0), // A new Vector3(1, 0, 0), // B new Vector3(0, 1, 0) // C }; // 定义三角形顶点索引顺时针或逆时针决定正面 int[] triangles { 0, 2, 1 }; // Unity通常使用顺时针为正面 // 分配顶点颜色数组 vertexColors new Color[vertices.Length]; mesh.vertices vertices; mesh.triangles triangles; mesh.RecalculateNormals(); // 计算法线用于基础光照 } void UpdateVertexColors() { // 为每个顶点赋予预设颜色 vertexColors[0] colorA; vertexColors[1] colorB; vertexColors[2] colorC; mesh.colors vertexColors; // 关键将顶点颜色数组赋给mesh } void DebugDrawInterpolation() { // 获取鼠标在屏幕上的位置并转换为世界空间射线 Ray ray Camera.main.ScreenPointToRay(Input.mousePosition); RaycastHit hit; // 如果射线击中了当前物体的碰撞器需要添加MeshCollider if (Physics.Raycast(ray, out hit) hit.collider.gameObject this.gameObject) { // hit.barycentricCoordinate 是Unity RaycastHit提供的重心坐标 Vector3 barycentric hit.barycentricCoordinate; Color interpolatedColor colorA * barycentric.x colorB * barycentric.y colorC * barycentric.z; // 在场景中绘制该点的颜色 Debug.DrawRay(hit.point, hit.normal * 0.2f, interpolatedColor, 0f); // 也可以在UI上显示RGB值 // Debug.Log($插值颜色: {interpolatedColor}); } } // 在Scene视图绘制Gizmo可视化三角形和顶点 void OnDrawGizmosSelected() { if (vertices null) return; Gizmos.color colorA; Gizmos.DrawSphere(transform.TransformPoint(vertices[0]), 0.05f); Gizmos.color colorB; Gizmos.DrawSphere(transform.TransformPoint(vertices[1]), 0.05f); Gizmos.color colorC; Gizmos.DrawSphere(transform.TransformPoint(vertices[2]), 0.05f); } }4.2 关键步骤解析与避坑指南Mesh Collider为了让Physics.Raycast生效你必须为BarycentricDemo物体添加一个Mesh Collider组件。否则射线无法检测到三角形面。hit.barycentricCoordinate这是Unity提供的一个极其便利的属性。当射线击中一个三角形网格碰撞器时它会自动返回击中点在那个三角形上的重心坐标。这省去了我们手动计算的麻烦非常适合调试和可视化。顶点颜色赋值mesh.colors vertexColors;这一行是将颜色数据传递给GPU的关键。在默认的Standard Shader中顶点颜色COLOR语义可能不会被使用你需要一个能响应顶点颜色的Shader。Unlit/Color着色器或自定义Shader可以。着色器中的插值在真正的渲染流程中我们不需要在CPU端为每个像素计算颜色。CPU脚本只负责将colorA, colorB, colorC作为顶点属性传递给Shader。GPU在光栅化阶段会自动利用硬件加速的重心坐标插值为每个片段像素计算出插值后的颜色、UV、法线等。我们的脚本演示的是原理而GPU做的是高效实践。常见问题排查看不到颜色渐变检查使用的材质球着色器是否支持顶点颜色。尝试使用Unlit/Color或创建一个简单的自定义Shader在片段着色器中直接返回v2f i : COLOR的值。射线检测无效确认Mesh Collider已添加并且其Convex选项对于简单三角形网格通常不需要勾选。确保射线检测的层Layer正确。插值颜色不对检查hit.barycentricCoordinate的顺序。它返回的(u, v, w)对应的是被击中三角形的三个顶点索引mesh.triangles中的顺序确保你的colorA/B/C赋值顺序与顶点顺序匹配。5. Unreal Engine引擎实战示例在Unreal Engine中我们可以通过ProceduralMeshComponent动态创建三角形并利用其顶点颜色数据实现插值。同时我们也可以用蓝图或C实现鼠标交互采样。5.1 使用ProceduralMeshComponent创建彩色三角形创建Actor在UE编辑器中创建一个新的C类或蓝图Actor命名为BP_BarycentricDemo。添加组件在蓝图编辑器中添加一个Procedural Mesh Component。构建网格在Actor的BeginPlay或构造脚本中生成三角形。以下是蓝图节点的核心逻辑示意用文字描述设置顶点Vertices添加三个FVector例如 (0,0,0), (100,0,0), (0,100,0)。设置三角形索引Triangles添加三个整数顺序决定正面UE默认逆时针为正面例如 0, 2, 1。设置顶点颜色Vertex Colors添加三个FLinearColor分别对应红、绿、蓝。调用Create Mesh Section将以上数组传递给Procedural Mesh Component的Create Mesh Section节点。记得勾选Enable Collision以便后续射线检测。5.2 C实现重心坐标计算与采样在UE C中我们可以编写一个函数来计算重心坐标并在点击事件中调用。// 在您的Actor头文件.h中声明函数 public: UFUNCTION(BlueprintCallable, Category Barycentric) static FLinearColor CalculateBarycentricColor(const FVector Point, const FVector A, const FVector B, const FVector C, const FLinearColor ColorA, const FLinearColor ColorB, const FLinearColor ColorC); // 在源文件.cpp中实现 FLinearColor ABarycentricDemoActor::CalculateBarycentricColor(const FVector P, const FVector A, const FVector B, const FVector C, const FLinearColor ColorA, const FLinearColor ColorB, const FLinearColor ColorC) { // 转换为二维假设三角形在XY平面上忽略Z。更健壮的做法是投影到三角形平面 FVector2D v0(B.X - A.X, B.Y - A.Y); FVector2D v1(C.X - A.X, C.Y - A.Y); FVector2D v2(P.X - A.X, P.Y - A.Y); float d00 FVector2D::DotProduct(v0, v0); float d01 FVector2D::DotProduct(v0, v1); float d11 FVector2D::DotProduct(v1, v1); float d20 FVector2D::DotProduct(v2, v0); float d21 FVector2D::DotProduct(v2, v1); float denom d00 * d11 - d01 * d01; if (FMath::Abs(denom) KINDA_SMALL_NUMBER) { return FLinearColor::White; // 退化三角形返回默认色 } float beta (d11 * d20 - d01 * d21) / denom; float gamma (d00 * d21 - d01 * d20) / denom; float alpha 1.0f - beta - gamma; // 线性插值颜色 FLinearColor Result ColorA * alpha ColorB * beta ColorC * gamma; return Result; }然后在蓝图中你可以在Event Tick或鼠标点击事件中进行射线检测Line Trace by Channel。如果击中自己的ProceduralMeshComponent从Hit Result中获取击中点Impact Point和碰撞组件。由于UE的射线检测不直接提供重心坐标你需要获取三角形的三个顶点数据。这需要你存储创建网格时的顶点数据或者通过碰撞组件的接口查询更复杂。对于演示一个简单的方法是如果你知道三角形是Actor下唯一的ProcMesh部分且顶点位置固定可以直接使用预设的A、B、C顶点位置和颜色。调用上面编写的CalculateBarycentricColor函数传入击中点和预设的顶点信息得到插值颜色。用这个颜色来更新某个Decal、UI Text或Dynamic Material的参数实现可视化反馈。5.3 Unreal Material中的顶点颜色插值更符合引擎常规工作流的方式是直接在材质中使用顶点颜色。当你通过Create Mesh Section设置了顶点颜色后创建一个新的材质M_VertexColor。在材质图表中添加Vertex Color节点。将其RGB输出连接到Base Color输入。将这个材质赋给你的ProceduralMeshComponent。这样GPU就会自动完成重心坐标插值并在屏幕上渲染出平滑过渡的三角形。这是最高效、最标准的方式。Unreal实战要点顶点顺序Unreal中逆时针绕序的三角形被认为是正面。如果你的三角形不可见可能是顶点顺序错了尝试调整Triangles数组的顺序如 0, 1, 2。坐标空间注意ProceduralMeshComponent的顶点坐标是相对于该组件的本地空间。性能ProceduralMeshComponent适合动态创建简单几何体。对于复杂或静态网格建议使用静态网格体Static Mesh。材质域如果只显示颜色材质域Material Domain使用Surface即可。如果需要后期效果可能需要Post Process。6. 常见问题、优化与扩展应用6.1 常见问题速查表问题现象可能原因解决方案三角形显示为纯色/无渐变1. 着色器不支持顶点颜色。2. 顶点颜色数据未正确传入GPU。3. 三个顶点颜色被错误地设置成相同值。1. 更换为Unlit/Color(Unity) 或使用Vertex Color节点(UE)。2. 检查mesh.colors赋值(Unity)或CreateMeshSection参数(UE)。3. 确认预设的顶点颜色是不同的。射线检测无响应1. 物体缺少碰撞器。2. 碰撞器未启用。3. 射线检测的通道Channel不匹配。1. 添加MeshCollider(Unity) 或启用Enable Collision(UE)。2. 检查碰撞器组件的启用状态。3. 在Unity检查Physics.Raycast的layerMask在UE检查Line Trace的Collision Channel。插值颜色明显错误1. 重心坐标计算错误。2. 顶点顺序与颜色赋值顺序不匹配。3. 使用屏幕空间坐标时未考虑透视投影。1. 使用本文提供的稳定算法并处理除零错误。2. 确保colorA对应vertices[0]以此类推。3. 对于3D空间中的透视插值需使用“透视校正插值”公式。三角形背面不可见1. 背面剔除Backface Culling开启。2. 顶点顺序错误导致三角形被认为是背面。1. 在材质中关闭背面剔除可能影响性能。2. 纠正三角形索引顺序Unity常为顺时针UE为逆时针。在曲面或多边形上插值不自然顶点属性如法线在共享顶点处不一致。确保共享顶点处的法线、颜色等属性完全相同。对于硬边需要拆分顶点Duplicate Vertices。6.2 性能优化考量CPU vs GPU重心坐标插值的核心计算应交给GPU在光栅化阶段完成这是硬件优化的。我们的CPU代码仅用于理解原理、工具制作或特定算法如拾取、网格处理。不要在每帧用CPU为大量像素计算插值颜色。预计算与缓存如果需要在CPU端频繁对同一网格的固定点进行插值如布料模拟的锚点可以预计算这些点相对于某个三角形网格的重心坐标并缓存。数值稳定性当三角形面积非常小退化三角形时面积计算的分母可能接近零。在代码中必须添加容错判断返回一个默认值或跳过该三角形。6.3 扩展应用场景掌握了重心坐标插值你就打开了一扇门可以应用到许多更酷炫的效果中纹理动画与变形通过随时间改变顶点的UV坐标并利用插值可以实现纹理在网格表面的平滑流动和变形效果。顶点动画与蒙皮在骨骼动画中每个顶点受多个骨骼影响其最终位置是骨骼变换矩阵根据权重可视为一种广义重心坐标插值的结果。地形着色与混合根据地形的海拔、坡度等信息为顶点分配不同的材质索引或混合权重在像素着色器中利用插值后的权重混合多个纹理实现逼真的地形材质过渡。软阴影与光照如PCF百分比渐近过滤软阴影技术中需要对阴影贴图进行多次采样并平均采样点的分布可以利用重心坐标在像素内的小三角形上进行插值。屏幕空间效果在后期处理中对全屏四边形进行插值传递UV等参数。重心坐标插值法是连接离散顶点数据与连续像素表现的桥梁。它如此基础又如此重要。我建议你在理解这个示例后尝试修改代码比如为三角形添加第四个顶点做成四边形由两个三角形组成观察颜色在接缝处是如何连续过渡的或者尝试插值顶点的法线并实现一个简单的朗伯光照模型亲眼看看平滑的光照效果是如何产生的。这些动手实验比读十篇理论文章都管用。

相关新闻

最新新闻

日新闻

周新闻

月新闻