当3D图形处理器在屏幕上渲染一个三角形时,要进行逐行的光栅化。三角形的顶点除了包含相机空间的位置信息外,还包含光照颜色和纹理映射坐标等信息,三角形面中的此类信息都要经过插值获得。当绘制三角形的一条扫描线时,每个像素的信息为左右两个端点的同类信息的插值。
如图5.13所示,三角形面上的正确插值不是线性的,这是由于在投影平面上的相同步长随着三角形面与相机之间的距离增加而在三角形面上产生更大的步长。图形处理器必须采用非线性插值方法来计算纹理映射坐标,以避免纹理映射图的扭曲变形。
尽管现代图形硬件对顶点关联的其他类型信息如光照颜色,也进行非线性插值,较老的图形卡仍然使用简单的线性插值来计算光照颜色一类的信息,这是因为这些信息受插值方法的不同影响不大,不像纹理映射那样影响很明显。

5.4.1 深度插值
三角形面上一点的zzz坐标(表示深度)可由3D图形硬件通过线性插值得到,这与本节提到的透视校正插值相反,其原因将在5.5.1节解释,5.5.1节将讨论透视投影矩阵。如图5.14所示,−z-z−z平面上存在一条与三角形的某一扫描线对应的线段,通过计算投影平面上等步长的点发出的投射光线与该线段的交点,对该线段进行点采样,投影平面上等步长的点表示显示器屏幕的像素。假设该线段不属于通过原点的直线,否则三角形将被从侧面观察而不可见,则该线段的方程为
ax+bz=c(5.29) ax + bz = c \tag{5.29}ax+bz=c(5.29)

对于该直线上一点 (x,z)(x, z)(x,z),从坐标系原点(相机位置)发出一束光线照射到该点,通过计算可知该光线与投影平面的交点。投影平面的 zzz 坐标总等于 −e-e−e。由图 5.14 可知,通过相似三角形定理可推导出以下关系式,通过该关系式可计算出投影平面上与点 (x,z)(x, z)(x,z) 对应的点的 xxx 坐标值 ppp。
px=−ez(5.30) \frac{p}{x} = \frac{-e}{z}\tag{5.30} xp=z−e(5.30)
解这个关于 xxx 的方程,并将其代入式 (5.29),直线方程可重写为
(−ape+b)z=c(5.31) \left( -\frac{ap}{e} + b \right) z = c\tag{5.31} (−eap+b)z=c(5.31)
为方便计算,直线方程进一步写成 1/z1/z1/z 位于一边的形式:
1z=−apce+bc(5.32) \frac{1}{z} = -\frac{ap}{ce} + \frac{b}{c}\tag{5.32} z1=−ceap+cb(5.32)
考虑线段的两个端点 (x1,z1)(x_1, z_1)(x1,z1) 和 (x2,z2)(x_2, z_2)(x2,z2),以及它们在投影平面上的对应点 (p1,−e)(p_1, -e)(p1,−e) 和 (p2,−e)(p_2, -e)(p2,−e)。对于任一满足条件 0≤t≤10 \leq t \leq 10≤t≤1 的 ttt 值,令 p3=(1−t)p1+tp2p_3 = (1-t)p_1 + tp_2p3=(1−t)p1+tp2 为在投影平面上的插值点的 xxx 坐标值。可知通过点 (p3,−e)(p_3, -e)(p3,−e) 的光线与三角形面交于点 (x3,z3)(x_3, z_3)(x3,z3),该点的 zzz 坐标值可通过将 p3=(1−t)p1+tp2p_3 = (1-t)p_1 + tp_2p3=(1−t)p1+tp2 和 z3z_3z3 代入式 (5.32) 获得,如下式所示。
1z3=−ap3ce+bc \frac{1}{z_3} = -\frac{ap_3}{ce} + \frac{b}{c} z31=−ceap3+cb
=−ap1ce(1−t)−ap2cet+bc = -\frac{ap_1}{ce}(1-t) - \frac{ap_2}{ce}t + \frac{b}{c} =−ceap1(1−t)−ceap2t+cb
=(−ap1ce+bc)(1−t)+(−ap2ce+bc)t = \left( -\frac{ap_1}{ce} + \frac{b}{c} \right)(1-t) + \left( -\frac{ap_2}{ce} + \frac{b}{c} \right)t =(−ceap1+cb)(1−t)+(−ceap2+cb)t
=1z1(1−t)+1z2t(5.33) = \frac{1}{z_1}(1-t) + \frac{1}{z_2}t\tag{5.33} =z11(1−t)+z21t(5.33)
该结果表明三角形面上的插值点的坐标值的倒数是线性插值。
5.4.2 顶点属性插值
顶点携带的光照颜色和纹理映射坐标等信息统称为顶点属性。当对三角形进行光栅化时,三角形面中任一点的属性需对相应顶点属性进行插值而获得。假设扫描线端点的深度值为 z1z_1z1 和 z2z_2z2,分别包含标量属性值 b1b_1b1 和 b2b_2b2,则插值属性值 b3b_3b3 与两个端点的属性差之比等于插值深度值 z3z_3z3 与两个端点的深度值差之比,以下等式成立:
b3−b1b2−b1=z3−z1z2−z1(5.34) \frac{b_3 - b_1}{b_2 - b_1} = \frac{z_3 - z_1}{z_2 - z_1}\tag{5.34} b2−b1b3−b1=z2−z1z3−z1(5.34)
将由式 (5.33) 得出的下式:
z3=11z1(1−t)+1z2t(5.35) z_3 = \frac{1}{\frac{1}{z_1}(1-t) + \frac{1}{z_2}t}\tag{5.35} z3=z11(1−t)+z21t1(5.35)
代入式 (5.34),并解关于 b3b_3b3 的方程得:
b3=b1z2(1−t)+b2z1tz2(1−t)+z1t(5.36) b_3 = \frac{b_1 z_2 (1-t) + b_2 z_1 t}{z_2 (1-t) + z_1 t}\tag{5.36} b3=z2(1−t)+z1tb1z2(1−t)+b2z1t(5.36)
给分子和分母同乘 1/z1z21/z_1 z_21/z1z2,则等式右边可提取公因子z3z_3z3。
b3=b1z1(1−t)+b2z2t1z1(1−t)+1z2t=z3[b1z1(1−t)+b2z2t](5.37)b_3 = \frac{\frac{b_1}{z_1} (1-t) + \frac{b_2}{z_2} t}{\frac{1}{z_1} (1-t) + \frac{1}{z_2} t} = z_3 [\frac{b_1}{z_1}(1-t)+\frac{b_2}{z_2}t]\tag{5.37} b3=z11(1−t)+z21tz1b1(1−t)+z2b2t=z3[z1b1(1−t)+z2b2t](5.37)
该结果表明三角形面上的插值属性与 zzz 坐标值的倒数的乘积 b/zb/zb/z 是线性插值。当光栅化一条扫描线时,图形处理器首先计算 1/z1/z1/z 的线性插值,再计算其倒数,最后乘以 b/zb/zb/z 的线性插值结果,则可获得顶点属性 bbb 的透视校正插值结果。
CPU模拟插值
1.新建场景,结构如下

注意,相机要调成透视相机,不然下面的测试结果会看起来一样
场景示意图

2.创建脚本,挂载在RendererSimulator物体上

// ManualTriangleRenderer.cs
using UnityEngine;
using UnityEngine.UI;
public class ManualTriangleRenderer : MonoBehaviour
{
// --- 在Inspector中设置 ---
public Transform sphere1;
public Transform sphere2;
public Transform sphere3;
public RawImage displayImage; // 将场景中的Raw Image拖到这里
// --- 模拟类型 ---
public enum InterpolationMode
{
CorrectPerspective, // 正确的透视校正插值
IncorrectLinear // 错误的直接线性插值
}
public InterpolationMode mode = InterpolationMode.CorrectPerspective;
// --- 私有变量 ---
private Texture2D _renderTexture;
private new Camera _camera;
private Color32[] _clearColors;
// 一个帮助我们存储每个顶点处理后数据的结构体
private struct ProcessedVertex
{
public Vector2 ScreenPos; // 最终的屏幕/纹理坐标
public float OneOverW; // 1 / w
public Color Color; // 原始颜色
}
void Start()
{
_camera = Camera.main;
if (displayImage == null)
{
Debug.LogError("请分配 RawImage!");
enabled = false;
return;
}
// 创建用于绘制的纹理
_renderTexture = new Texture2D(Screen.width, Screen.height, TextureFormat.RGBA32, false);
displayImage.texture = _renderTexture;
// 创建一个用于快速清空纹理的颜色数组
_clearColors = new Color32[Screen.width * Screen.height];
Color32 clearColor = new Color32(0, 0, 0, 0); // 透明
for (int i = 0; i < _clearColors.Length; i++)
{
_clearColors[i] = clearColor;
}
}
void Update()
{
if (sphere1 == null || sphere2 == null || sphere3 == null) return;
// 1. 清空上一帧的纹理
_renderTexture.SetPixels32(_clearColors);
// =================================================================
// 阶段 1: 手动顶点处理 (Vertex Processing)
// =================================================================
// 获取视图和投影矩阵
Matrix4x4 viewMatrix = _camera.worldToCameraMatrix;
Matrix4x4 projMatrix = _camera.projectionMatrix;
Matrix4x4 vpMatrix = projMatrix * viewMatrix;
// 处理三个顶点
ProcessedVertex v1 = ProcessVertex(sphere1.position, sphere1.GetComponent<Renderer>().material.color, vpMatrix);
ProcessedVertex v2 = ProcessVertex(sphere2.position, sphere2.GetComponent<Renderer>().material.color, vpMatrix);
ProcessedVertex v3 = ProcessVertex(sphere3.position, sphere3.GetComponent<Renderer>().material.color, vpMatrix);
// 如果任何顶点在相机后面,则不绘制(简单的裁剪)
if(v1.OneOverW < 0 || v2.OneOverW < 0 || v3.OneOverW < 0)
{
_renderTexture.Apply();
return;
}
// =================================================================
// 阶段 2: 手动光栅化和插值 (Rasterization & Interpolation)
// =================================================================
// 计算三角形在屏幕上的边界框,以减少像素检查范围
int minX = (int)Mathf.Min(v1.ScreenPos.x, v2.ScreenPos.x, v3.ScreenPos.x);
int maxX = (int)Mathf.Max(v1.ScreenPos.x, v2.ScreenPos.x, v3.ScreenPos.x);
int minY = (int)Mathf.Min(v1.ScreenPos.y, v2.ScreenPos.y, v3.ScreenPos.y);
int maxY = (int)Mathf.Max(v1.ScreenPos.y, v2.ScreenPos.y, v3.ScreenPos.y);
// 遍历边界框内的每一个像素
for (int y = minY; y < maxY; y++)
{
for (int x = minX; x < maxX; x++)
{
// 计算当前像素的重心坐标
Vector3 barycentricCoords = GetBarycentricCoords(new Vector2(x, y), v1.ScreenPos, v2.ScreenPos, v3.ScreenPos);
// 如果重心坐标的任何一个分量为负,说明像素在三角形外
if (barycentricCoords.x < 0 || barycentricCoords.y < 0 || barycentricCoords.z < 0)
{
continue;
}
// --- 这是我们讨论的核心 ---
Color finalColor;
if (mode == InterpolationMode.CorrectPerspective)
{
// ** 正确的透视校正插值 **
// 1. 使用重心坐标插值 1/w
float oneOverW = barycentricCoords.x * v1.OneOverW +
barycentricCoords.y * v2.OneOverW +
barycentricCoords.z * v3.OneOverW;
// 2. 使用重心坐标插值 color/w
Color colorOverW = barycentricCoords.x * (v1.Color * v1.OneOverW) +
barycentricCoords.y * (v2.Color * v2.OneOverW) +
barycentricCoords.z * (v3.Color * v3.OneOverW);
// 3. 恢复最终颜色
finalColor = colorOverW / oneOverW;
}
else
{
// ** 错误的直接线性插值 **
finalColor = barycentricCoords.x * v1.Color +
barycentricCoords.y * v2.Color +
barycentricCoords.z * v3.Color;
}
_renderTexture.SetPixel(x, y, finalColor);
}
}
// 3. 应用所有SetPixel调用,更新纹理显示
_renderTexture.Apply();
}
private ProcessedVertex ProcessVertex(Vector3 worldPos, Color color, Matrix4x4 vpMatrix)
{
ProcessedVertex output = new ProcessedVertex();
// 将世界坐标转换为齐次裁剪空间坐标 (手动矩阵乘法)
Vector4 clipPos = vpMatrix * new Vector4(worldPos.x, worldPos.y, worldPos.z, 1.0f);
// 提取 w, 并计算 1/w
float w = clipPos.w;
output.OneOverW = 1.0f / w;
// 手动进行透视除法,得到NDC坐标
Vector3 ndcPos = new Vector3(clipPos.x / w, clipPos.y / w, clipPos.z / w);
// 将NDC坐标 [-1, 1] 转换为屏幕/纹理坐标 [0, width/height]
output.ScreenPos.x = (ndcPos.x + 1.0f) * 0.5f * _renderTexture.width;
output.ScreenPos.y = (ndcPos.y + 1.0f) * 0.5f * _renderTexture.height;
// 存储原始颜色
output.Color = color;
return output;
}
// 计算点P相对于三角形(a, b, c)的重心坐标
private Vector3 GetBarycentricCoords(Vector2 p, Vector2 a, Vector2 b, Vector2 c)
{
Vector2 v0 = b - a, v1 = c - a, 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);
}
}
3.线性插值结果

// ** 错误的直接线性插值 **
finalColor = barycentricCoords.x * v1.Color +
barycentricCoords.y * v2.Color +
barycentricCoords.z * v3.Color;

可以看到颜色基本是均匀分布在三角形内部的
4.非线性插值结果

// ** 正确的透视校正插值 **
// 1. 使用重心坐标插值 1/w
float oneOverW = barycentricCoords.x * v1.OneOverW +
barycentricCoords.y * v2.OneOverW +
barycentricCoords.z * v3.OneOverW;
// 2. 使用重心坐标插值 color/w
Color colorOverW = barycentricCoords.x * (v1.Color * v1.OneOverW) +
barycentricCoords.y * (v2.Color * v2.OneOverW) +
barycentricCoords.z * (v3.Color * v3.OneOverW);
// 3. 恢复最终颜色
finalColor = colorOverW / oneOverW;

可以看到,蓝色球体在远处,所以三角形蓝色区域的比重比较小,这个才是正确的颜色插值
GPU差值
插值这部分由GPU处理了,只需在shader里面使用就好了
1.新建场景,结构如下

2.创建shader
// UnlitVertexColorShader.shader
Shader "Tutorial/UnlitVertexColor"
{
// 这个着色器没有任何可调整的属性,因为颜色直接来自模型顶点
Properties
{
}
SubShader
{
// 使用透明队列,确保我们的三角形绘制在所有不透明物体之上
Tags { "RenderType"="Transparent" "Queue"="Transparent" }
LOD 100
// 关闭深度写入,这样三角形就不会遮挡后面的物体
ZWrite Off
// 使用标准的Alpha混合
Blend SrcAlpha OneMinusSrcAlpha
Pass
{
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "UnityCG.cginc"
// 顶点着色器的输入结构
// 只需要顶点位置和顶点颜色
struct appdata
{
float4 vertex : POSITION;
float4 color : COLOR;
};
// 顶点着色器的输出结构 (传递给片元着色器)
struct v2f
{
float4 vertex : SV_POSITION;
float4 color : COLOR;
};
// 顶点着色器
v2f vert (appdata v)
{
v2f o;
// 1. 将顶点位置从模型空间转换到裁剪空间
// 就在这一步,w 分量被正确计算出来了!
o.vertex = UnityObjectToClipPos(v.vertex);
// 2. 将顶点颜色直接传递给输出
o.color = v.color;
return o;
}
// 片元着色器
fixed4 frag (v2f i) : SV_Target
{
// GPU已经为我们完成了所有透视校正插值。
// 我们收到的 i.color 已经是这个像素上正确的颜色了。
// 我们要做的就是把它返回,显示在屏幕上。
return i.color;
}
ENDCG
}
}
}
3.在相机上挂上TriangleDrawer脚本
// TriangleDrawer.cs
using UnityEngine;
public class TriangleDrawer : MonoBehaviour
{
// 在Unity编辑器中,将三个球体拖到这里
public Transform sphere1;
public Transform sphere2;
public Transform sphere3;
// 将我们刚刚创建的着色器拖到这里
public Shader vertexColorShader;
// 我们的绘图材质
private Material _lineMaterial;
// 当脚本被加载时调用
void Awake()
{
if (vertexColorShader == null)
{
Debug.LogError("请在Inspector中分配顶点颜色着色器!");
return;
}
// 基于我们的着色器创建一个新的材质
_lineMaterial = new Material(vertexColorShader);
}
// OnPostRender会在相机完成场景渲染后被调用
// 这是绘制自定义图形的理想位置
void OnPostRender()
{
// 确保所有引用都已设置
if (sphere1 == null || sphere2 == null || sphere3 == null || _lineMaterial == null)
{
return;
}
// 获取三个球体的世界坐标位置
Vector3 pos1 = sphere1.position;
Vector3 pos2 = sphere2.position;
Vector3 pos3 = sphere3.position;
// 获取三个球体材质的颜色
// 注意:这要求球体使用了支持 _Color 属性的标准材质
Color color1 = sphere1.GetComponent<Renderer>().material.color;
Color color2 = sphere2.GetComponent<Renderer>().material.color;
Color color3 = sphere3.GetComponent<Renderer>().material.color;
// --- 核心绘图逻辑 ---
// 激活我们的材质以供绘图使用
_lineMaterial.SetPass(0);
// 开始绘制一个三角形
GL.Begin(GL.TRIANGLES);
// 发送第一个顶点的数据(颜色 + 位置)
GL.Color(color1);
GL.Vertex(pos1);
// 发送第二个顶点的数据
GL.Color(color2);
GL.Vertex(pos2);
// 发送第三个顶点的数据
GL.Color(color3);
GL.Vertex(pos3);
// 结束绘制
GL.End();
}
}
4.点击运行

可以看到三角形内部颜色是和CPU模拟的结果是一样的


被折叠的 条评论
为什么被折叠?



