需要预先学习:


1. 屏幕空间插值的问题

纹理映射 介绍过属性插值:光栅化阶段用重心坐标(barycentric coordinates)把顶点属性(颜色、法线、UV)推算到三角形内部每个片元。纹理映射 的注里留了一个悬念:屏幕空间的重心坐标不能直接用于属性插值,透视投影下会产生扭曲。

先看症状。一块斜着放的地板(或墙),用均匀的棋盘格纹理铺满:

正确(透视校正)            错误(屏幕空间直接插值)
┌────┬──┬──┬────┐          ┌──┬──┬──┬──┬──┐
│    │  │  │    │          │  │  │  │  │  │   ← 远处:格子应越来越密
│    │  │  │    │          │  │  │  │  │  │      却被均匀压缩
├────┼──┼──┼────┤          ├──┼──┼──┼──┼──┤
│    │  │  │    │          │  │  │  │  │  │
│    │  │  │    │          │  │  │  │  │  │
└────┴──┴──┴────┘          └──┴──┴──┴──┴──┘
   近处格子大                  格子大小沿深度没有
   远处格子小(近大远小)        正确的透视递减

屏幕空间直接插值时,远处与近处的格子被"均匀拉平",地板看起来像被向前压扁,透视感消失。颜色与法线的插值同理出错,只是没有纹理这么直观。

问题出在哪:透视除法(除以 ww)对 xx、yy 是非线性的,而线性插值与非线性变换不可交换。这一篇把这个坑的机理讲清楚,并给出修正公式。


2. 推导

2.1 记号约定

设三角形三个顶点 V0V_0、V1V_1、V2V_2(它们已位于视图空间,见 坐标空间总览)。对某个属性 AA(UV、颜色、法线分量……),顶点处取值 A0A_0、A1A_1、A2A_2。

屏幕上一点 PP 在三角形内,屏幕空间重心坐标为 (α,β,γ)(\alpha, \beta, \gamma),满足 α+β+γ=1\alpha + \beta + \gamma = 1(面积比定义见 纹理映射)。

错误做法(屏幕空间直接插值):

Awrong=αA0+βA1+γA2A_{wrong} = \alpha A_0 + \beta A_1 + \gamma A_2

这个式子在世界空间里插值是对的,但 α\alpha、β\beta、γ\gamma 是在屏幕空间里量出来的——两个空间的重心坐标不相等。

2.2 关键事实:屏幕坐标是视图坐标除以深度

透视投影下(见 坐标空间总览),裁剪空间 w=−zvieww = -z_{view},透视除法后屏幕坐标为:

xs=xeze,ys=yezex_s = \frac{x_e}{z_e}, \quad y_s = \frac{y_e}{z_e}

(zez_e 取正深度,符号约定不影响推导。)也就是说:屏幕坐标是视图坐标的线性函数除以深度。

同样地,屏幕空间的重心坐标 (α,β,γ)(\alpha, \beta, \gamma) 是由屏幕坐标定义的线性关系:

Ps=α(V0)s+β(V1)s+γ(V2)sP_s = \alpha (V_0)_s + \beta (V_1)_s + \gamma (V_2)_s

把每个屏幕坐标按 x/zx/z 展开:

xpzp=αx0z0+βx1z1+γx2z2\frac{x_p}{z_p} = \alpha \frac{x_0}{z_0} + \beta \frac{x_1}{z_1} + \gamma \frac{x_2}{z_2}

对 yy、zz(NDC 的 zz 同样是 zz 的分式线性函数)各有一个同样的方程。联立可解出一个关键结论——屏幕空间重心坐标换算到视图空间,需要按 1/z1/z 重新加权:

1zp=α1z0+β1z1+γ1z2\frac{1}{z_p} = \alpha \frac{1}{z_0} + \beta \frac{1}{z_1} + \gamma \frac{1}{z_2}

即片元深度的倒数是顶点深度倒数的线性插值。

2.3 属性的正确插值

视图空间里,属性随位置线性变化(线性插值在本空间内正确):

Acorrect=α′A0+β′A1+γ′A2A_{correct} = \alpha' A_0 + \beta' A_1 + \gamma' A_2

其中 (α′,β′,γ′)(\alpha', \beta', \gamma') 是视图空间的重心坐标。它与屏幕空间重心坐标的关系(由上面的换算推出):

α′=α/z0α/z0+β/z1+γ/z2,β′、γ′同理\alpha' = \frac{\alpha / z_0}{\alpha / z_0 + \beta / z_1 + \gamma / z_2}, \quad \beta'、\gamma' 同理

代入整理,得到透视校正插值(perspective-correct interpolation)公式:

Acorrect=αA0z0+βA1z1+γA2z2α1z0+β1z1+γ1z2A_{correct} = \frac{\alpha \dfrac{A_0}{z_0} + \beta \dfrac{A_1}{z_1} + \gamma \dfrac{A_2}{z_2}}{\alpha \dfrac{1}{z_0} + \beta \dfrac{1}{z_1} + \gamma \dfrac{1}{z_2}}

用裁剪空间的 ww(wi=ziw_i = z_i,仅差符号)写就是教科书常见形式:

Acorrect=∑iαiAiwi∑iαi1wiA_{correct} = \frac{\sum_i \alpha_i \dfrac{A_i}{w_i}}{\sum_i \alpha_i \dfrac{1}{w_i}}

结论:可插值的不是属性本身,而是"属性除以 ww"——分子分母各做一次屏幕空间线性插值,再相除。因为除以 ww 抵消了透视除法的非线性,两次线性插值都在"除以 ww 后的世界"里成立。

注:这类"先除、插值、再除回"形式的量称为双曲线插值(hyperbolic interpolation),因为插值结果对屏幕坐标是分式(双曲线)函数,而不是线性函数——远处属性变化被自然压密,正是透视该有的样子。


3. 数值例子

用两个顶点的线段(退化成"一维三角形")验证。设线段两端:

  • V0V_0:视图空间 (0,0,1)(0, 0, 1),属性 A0=0A_0 = 0,即屏幕 x0=0/1=0x_0 = 0/1 = 0
  • V1V_1:视图空间 (1,0,4)(1, 0, 4),属性 A1=1A_1 = 1,即屏幕 x1=1/4=0.25x_1 = 1/4 = 0.25

取屏幕中点 xp=0.125x_p = 0.125,对应线性权重 α=β=0.5\alpha = \beta = 0.5。

错误做法:

Awrong=0.5×0+0.5×1=0.5A_{wrong} = 0.5 \times 0 + 0.5 \times 1 = 0.5

透视校正:

Acorrect=0.5×01+0.5×140.5×11+0.5×14=0.1250.625=0.2A_{correct} = \frac{0.5 \times \dfrac{0}{1} + 0.5 \times \dfrac{1}{4}}{0.5 \times \dfrac{1}{1} + 0.5 \times \dfrac{1}{4}} = \frac{0.125}{0.625} = 0.2

验证 0.20.2 是否正确:屏幕 xp=0.125x_p = 0.125 的点,视图空间应满足 xe/ze=0.125x_e / z_e = 0.125 且落在线段上(ze=1+3tz_e = 1 + 3t,xe=tx_e = t,A=tA = t):t/(1+3t)=0.125⇒t=0.2t / (1 + 3t) = 0.125 \Rightarrow t = 0.2。属性真实值正是 0.20.2——校正公式正确,屏幕中点对应的属性偏向了近处顶点(近处顶点在屏幕上"占据"了更多区间,属性变化也应更慢)。


4. 硬件如何实现

透视校正插值不需要你在着色器里手写——光栅化硬件对 in/out 变量的插值默认就是透视校正的。硬件的实现策略:

  1. 顶点着色器输出裁剪空间坐标与属性后,硬件对每个可插值属性预乘 1/w1/w(同时单独插值 1/w1/w 本身);
  2. 光栅化时对 Ai/wiA_i / w_i 与 1/wi1 / w_i 分别做普通的屏幕空间线性插值;
  3. 片元着色器读取前,用 (A/w)插值÷(1/w)插值(A/w)_{插值} \div (1/w)_{插值} 还原出 AA。

除法与乘法分摊到每个片元,代价由硬件摊薄。正交投影下 ww 恒为 1,公式退化为普通线性插值——两种投影共用同一套硬件逻辑。

4.1 noperspective:故意关掉校正

GLSL 提供插值限定符关闭这个行为(见 glsl语法 的插值限定符):

// 顶点着色器
noperspective out vec2 screenUV;
// 片元着色器
noperspective in vec2 screenUV;   // 按屏幕空间线性插值,不做透视校正

适用场景:属性本来就定义在屏幕空间上——全屏后处理的坐标、某些 UI 参数。它们不需要(也不应该)被透视校正。

注意:flat(不插值,取 provoking vertex 的值)与 noperspective(线性但不校正)是两回事:前者用于实例 ID、整数属性这类"图元内恒定"的数据,后者用于"屏幕空间意义上线性"的数据。


5. 与深度的关系

有一个容易混淆的相邻问题:片元的深度(NDC 的 zz)本身也是插值出来的,但它不是线性插值。

由本篇第二个关键事实,1/z1/z 在屏幕空间线性插值,等价地:NDC 的 zz 在屏幕空间按双曲线插值(先插 1/z1/z 再取倒数)。这正是 视图(摄像机)与投影 注与 深度缓冲 深度精度一节的来源:

  • 视图空间的 zz 被 zndc=a+b/zz_{ndc} = a + b/z 的形式非线性压缩:近处一小段深度占满 [0,1][0,1] 的大区间,远处一大段深度挤在尾部;
  • 两个工程对策也由此而来:控制 near/far 比值(压缩程度由它决定)、reverse-z(反转映射方向,利用浮点数在 0 附近精度高的特性,见 深度缓冲)。

而硬件存进深度缓冲、参与深度测试的正是这个非线性 zz——深度测试比较的是"非线性深度",结果只受相对远近影响,不受非线性影响;非线性只在"精度分布"上体现为远处更容易 z-fighting。

量 屏幕空间插值方式 说明
颜色、UV、法线等顶点属性 透视校正(除以 ww 插值再还原) 硬件默认行为
1/w1/w(深度倒数) 线性插值 硬件内部使用
NDC 深度 zz 双曲线(非均匀) 由 1/z1/z 线性间接得到,精度近密远疏
noperspective 标记的属性 线性插值 显式关闭校正,用于屏幕空间属性

一句话总结:透视除法是非线性的,一切"跨越透视除法还想保持线性"的插值都必须按 1/w1/w 加权——属性插值如此(本篇),深度精度分布如此(深度缓冲),ShadowMap与PCF、PCSS 在光源空间比较深度时同样如此。