Skip to content

着色(插值、高级纹理映射)

上一章把纹理理解为“可查询的材质数据”,并说明了片元着色器如何通过 UV 坐标读取纹理。这个理解已经足够写出最简单的贴图材质,但还不够解释真实项目里经常出现的问题:为什么远处地面会闪烁?为什么贴图边缘会有接缝?为什么法线贴图能让平面看起来有凹凸?为什么同一张纹理在倾斜角度下会糊成一片?

这些问题都和两个核心环节有关:

  • 光栅化之后,顶点属性如何被插值到片元。
  • 片元着色器读取纹理时,GPU 如何决定“读哪几个纹素、怎样混合、是否换用低分辨率层级”。

本章会把这两件事拆开讲清楚。先从插值开始,再进入纹理过滤、包裹模式、Mipmap、各向异性过滤,最后用法线贴图把“纹理不仅能存颜色”这件事落实到一个具体例子上。


三角形进入光栅化阶段时,顶点通常不只携带位置,还会携带法线、颜色、UV、切线、骨骼权重等属性。光栅化器每生成一个片元,就需要为这个片元准备一组对应属性。

如果片元位于三角形内部某一点,它可以用重心坐标表示为三个顶点的加权组合:

P=αP0+βP1+γP2P = \alpha P_0 + \beta P_1 + \gamma P_2

其中:

α+β+γ=1\alpha + \beta + \gamma = 1

于是,许多顶点属性也可以用同样的权重插值得到:

UV=αUV0+βUV1+γUV2UV = \alpha UV_0 + \beta UV_1 + \gamma UV_2 N=normalize(αN0+βN1+γN2)N = normalize(\alpha N_0 + \beta N_1 + \gamma N_2)

这种插值让三角形内部拥有连续变化的数据。上一章的 Gouraud Shading 插值的是颜色,Phong Shading 插值的是法线。本章继续把这个思想推广到 UV、切线空间和纹理采样。


如果三角形已经经过透视投影,屏幕空间中的线性插值并不等价于三维空间中的线性变化。最容易暴露问题的属性就是 UV:一张地面贴图向远处延伸时,如果只按屏幕坐标线性插值,纹理会出现不自然的扭曲。

正确做法是把裁剪空间的 ww 也纳入插值。对于 uu 分量:

u=αu0w0+βu1w1+γu2w2α1w0+β1w1+γ1w2u = \frac{\alpha \frac{u_0}{w_0} + \beta \frac{u_1}{w_1} + \gamma \frac{u_2}{w_2}}{\alpha \frac{1}{w_0} + \beta \frac{1}{w_1} + \gamma \frac{1}{w_2}}

vv、世界位置、法线等需要随透视正确变化的属性也遵循同样思想。现代 GPU 会自动处理普通插值变量,所以日常写着色器时通常不需要手写这套公式。但理解它很重要,因为它解释了为什么顶点着色器输出的属性进入片元阶段后仍然能稳定贴在三维表面上。


纹理图像中的一个格点叫 纹素 (Texel),屏幕上的一个显示单元叫 像素 (Pixel)。片元着色器执行时,通常是在屏幕采样点上计算颜色,然后用 UV 去纹理中取值。

这里有一个小陷阱:屏幕像素和纹理纹素之间通常不是一一对应关系。

当模型离相机很近时,一个纹素可能被放大到多个屏幕像素上,这叫放大 (Magnification)。此时如果使用最近邻采样,图像会呈现明显块状边缘;使用双线性过滤,边缘会更平滑。

当模型离相机很远时,一个屏幕像素可能覆盖纹理中的许多纹素,这叫缩小 (Minification)。此时如果仍然只取一个纹素,画面会出现闪烁、摩尔纹和噪声。Mipmap 就是为了解决这个问题而存在。


最近邻过滤 (Nearest Filtering) 会选择距离采样点最近的一个纹素作为结果:

color=texel(round(u),round(v))color = texel(round(u), round(v))

它的优点是清晰、便宜、保留像素风格;缺点是放大时块状明显,缩小时容易跳变。

双线性过滤 (Bilinear Filtering) 会读取采样点周围的四个纹素,并按照距离进行两次线性插值:

Cx0=lerp(C00,C10,tx)C_x^0 = lerp(C_{00}, C_{10}, t_x) Cx1=lerp(C01,C11,tx)C_x^1 = lerp(C_{01}, C_{11}, t_x) C=lerp(Cx0,Cx1,ty)C = lerp(C_x^0, C_x^1, t_y)

它能让纹理在放大时更平滑,但也会让边界略微变软。对于照片、材质纹理和大多数真实感渲染,双线性过滤通常比最近邻更自然;对于像素字体、像素画和精确格子图案,最近邻反而更合适。


包裹模式:UV 超出范围时怎么办

Section titled “包裹模式:UV 超出范围时怎么办”

UV 通常被描述在 [0,1][0, 1] 范围内,但实际渲染时 UV 完全可以超出这个范围。例如地面材质常常让 UV 放大到 1010 倍,让同一张砖块纹理重复铺开。

当 UV 超出范围时,需要由纹理包裹模式决定如何处理:

  • Repeat:丢掉整数部分,只保留小数部分,让纹理不断重复。
  • Mirrored Repeat:重复时每隔一段镜像翻转一次,减少方向性接缝。
  • Clamp to Edge:把 UV 限制在边缘,超出部分一直读取边缘纹素。

Repeat 适合瓷砖、草地、墙面这类周期性材质。Clamp to Edge 适合 UI 图像、贴花、天空盒边缘或不希望重复的纹理。Mirrored Repeat 常用于减少重复图案在边界处的突兀变化。

左侧是被采样的程序纹理,右侧是片元着色器看到的结果。调大 UV 缩放时,一个屏幕像素会覆盖更多纹素,此时最近邻更容易闪烁,双线性会平滑边界,Mipmap 近似会牺牲细节来减少远处噪声。

在这个实验里,调大 UV 缩放会让右侧结果重复读取更多纹理区域。切换包裹模式时,注意右侧边缘如何变化;切换过滤方式时,观察高频棋盘和斜向条纹在放大、缩小时的清晰度差异。


Mipmap:为远处纹理准备低频版本

Section titled “Mipmap:为远处纹理准备低频版本”

当一个屏幕像素覆盖很多纹素时,只读取单个纹素是不稳定的。相机稍微移动,采样点可能从黑格跳到白格,画面就会闪烁。解决思路是:如果屏幕上看不清细节,就提前把纹理变成更低频、更平滑的版本。

Mipmap 是同一张纹理的一系列预缩小版本:

128×128, 64×64, 32×32, 16×16, ...128 \times 128,\ 64 \times 64,\ 32 \times 32,\ 16 \times 16,\ ...

渲染时,GPU 会根据片元附近 UV 的变化速度估计当前像素覆盖的纹理范围,然后选择合适的 Mipmap 层级。UV 变化越快,说明一个屏幕像素跨过的纹理范围越大,就越应该使用更低分辨率的层级。

直觉上:

  • 近处物体使用高分辨率纹理层级,保留细节。
  • 远处物体使用低分辨率纹理层级,减少闪烁。
  • 两个层级之间可以继续线性混合,这就是三线性过滤 (Trilinear Filtering) 的基本思想。

Mipmap 假设一个屏幕像素在纹理空间中覆盖的区域大致接近正方形。但很多真实视角并不是这样。例如一条地面道路斜着延伸到远处,一个屏幕像素在纹理空间里可能覆盖一条很长很窄的区域。

如果只选择一个普通 Mipmap 层级,结果可能过度模糊,因为 GPU 会用一个近似正方形的低分辨率区域去代表长条形覆盖范围。

各向异性过滤 (Anisotropic Filtering) 会沿着纹理空间中拉伸最强的方向采更多样本,从而在斜视角下保留更多细节。它常用于地面、墙面、道路、桌面等倾斜平面。

可以把几种过滤方式理解成逐步增加采样认知:

方式主要解决的问题典型效果
最近邻取最近纹素清晰但跳变明显
双线性纹素之间平滑放大更柔和
Mipmap远处高频闪烁远处更稳定
各向异性倾斜表面过度模糊斜视角保留更多细节

到目前为止,我们主要把纹理当作颜色来源。但纹理可以存任何适合查询的数据。法线贴图 (Normal Map) 就是把表面方向变化存进纹理的一种技术。

普通几何表面只有顶点法线,经过插值后形成较平滑的方向场。如果想表现砖缝、皮革纹理、细小划痕,真的增加几何细节会非常昂贵。法线贴图的做法是:保持几何形状不变,但在片元着色时用纹理中的法线扰动原本的表面法线。

法线贴图中的 RGB 通常会被解释为一个方向:

Ntexture=normalize(2colorrgb1)N_{texture} = normalize(2 \cdot color_{rgb} - 1)

因为纹理颜色通道范围是 [0,1][0, 1],而方向向量分量需要落在 [1,1][-1, 1],所以要做一次范围映射。


法线贴图里存的方向通常不是世界空间方向,而是切线空间 (Tangent Space) 方向。切线空间是贴在表面上的局部坐标系,由三个方向组成:

  • Tangent:沿着纹理 uu 方向变化的表面方向。
  • Bitangent:沿着纹理 vv 方向变化的表面方向。
  • Normal:表面原本的法线方向。

这三个方向组成一个 TBN 矩阵:

TBN=[TxBxNxTyByNyTzBzNz]\mathbf{TBN} = \begin{bmatrix} T_x & B_x & N_x \\ T_y & B_y & N_y \\ T_z & B_z & N_z \end{bmatrix}

片元着色器从法线贴图读到切线空间法线后,可以用 TBN 把它转换到世界空间或视图空间:

Nworld=normalize(TBNNtexture)N_{world} = normalize(\mathbf{TBN} \cdot N_{texture})

这样,同一张法线贴图就可以贴到模型的不同部位,并跟随 UV 展开自然旋转。没有切线空间,纹理中的“向右凸起”“向上凹陷”就很难和模型表面的实际方向对应起来。


现代 GPU 在片元着色器中可以估计相邻片元的属性变化,例如:

vec2 dx = dFdx(uv);
vec2 dy = dFdy(uv);

这些导数能告诉 GPU:当前屏幕附近,UV 在横向和纵向变化得有多快。纹理采样器正是利用这类信息估计像素覆盖的纹理范围,从而选择 Mipmap 层级。

这也解释了一个实际现象:纹理采样不是孤立地看当前片元的 UV,它还会参考屏幕邻域中的变化速度。片元着色器虽然看起来是“每个片元独立执行”,但硬件会以小组形式处理片元,以便计算导数、做纹理过滤和优化执行。


实际写材质时,纹理问题往往不是公式错,而是数据、坐标和采样状态没有对齐。

现象常见原因优先检查
纹理上下颠倒图片坐标系和 UV 坐标系约定不同是否需要翻转 vv
边缘出现接缝UV 岛边界、过滤跨到相邻区域纹理 padding、wrap 模式
远处闪烁缺少 Mipmap 或高频细节过强是否生成 Mipmap
倾斜地面模糊普通 Mipmap 过度低频化是否启用各向异性过滤
法线贴图方向反了绿通道约定不同或 TBN 错误DirectX/OpenGL 法线方向约定
高光碎裂法线未归一化或贴图压缩问题normalize、法线贴图格式

这些排查项背后都指向同一件事:纹理是数据,采样是规则,二者必须和坐标空间、过滤方式、材质解释保持一致。


本章进一步拆解了从顶点属性到片元颜色之间的插值与纹理采样过程。请重点记住以下结论:

  1. 光栅化器使用重心坐标为片元插值属性;方向向量插值后通常要重新归一化。
  2. 透视正确插值通过裁剪空间的 ww 修正屏幕空间线性插值造成的扭曲,是纹理能稳定贴在 3D 表面上的关键。
  3. 最近邻过滤保留硬边但容易跳变;双线性过滤更平滑;Mipmap 用低分辨率层级降低远处高频闪烁;各向异性过滤改善倾斜表面的过度模糊。
  4. 包裹模式决定 UV 超出 [0,1][0, 1] 后如何读取纹理,常见选择包括 Repeat、Mirrored Repeat 和 Clamp to Edge。
  5. 法线贴图把方向存进纹理,通过 TBN 矩阵从切线空间转换到着色所需空间,让低几何复杂度模型表现出丰富表面细节。

下一章会离开着色与纹理,进入几何表示的基础:点云、三角网格、隐式表面、参数曲面等不同方式如何描述形状。