在实时渲染中,纹理贴图是赋予模型表面细节的关键技术。然而,当一个带有高分辨率纹理的模型距离摄像机很远,或者以一个倾斜的角度观察时,屏幕上的一个像素可能会对应纹理上的多个纹素(Texel)。如果不进行处理,直接采样会导致严重的摩尔纹(Moiré patterns)和闪烁(Shimmering)现象,即纹理混叠(Texture Aliasing)。
Mipmapping 是解决这一问题的经典技术。其核心思想是预先生成一系列分辨率递减的纹理版本(Mip 层级),并在渲染时根据屏幕像素所需的细节程度(Level of Detail, LOD)选择合适的 Mip 层级进行采样,从而有效减少混叠并提高渲染性能。
本文将详细阐述 Mipmap 的实现过程,包括 Mipmap 的生成、关键的 LOD 计算(包含数学推导)以及最终的三线性过滤采样。
1. Mipmap 的生成
Mipmap 的基础是创建一系列低分辨率的纹理图像:
- Level 0: 原始的、最高分辨率的纹理。
- Level 1: 分辨率是 Level 0 的一半(宽和高各一半)。
- Level 2: 分辨率是 Level 1 的一半。
- … 以此类推,直到某个维度的分辨率达到 1。
生成方法
最常用的方法是下采样(Downsampling)。一个简单的实现是使用 2x2 盒子滤波器(Box Filter):将上一层级中每 2x2 个像素的颜色进行平均,得到下一层级的一个像素颜色。
以下是 TGATexture
实现中的 generateNextMipLevel
函数示例:
1 | namespace { // Anonymous namespace |
在 TGATexture::load
中,生成 Mipmap 的流程如下:
1 | // Inside TGATexture::load after loading base level |
加载预生成 Mipmap
某些纹理格式(如 DDS)允许直接存储预先生成好的 Mipmap 层级。加载时只需按顺序读取并解压(如果需要)每个层级的数据:
1 | // Inside DDSTexture::load |
2. 细节级别 (Level of Detail - LOD) 计算
LOD 计算是 Mipmapping 的核心。我们需要为屏幕上的每个像素计算一个 LOD 值,表示该像素需要多大程度的纹理细节。LOD 值越高,表示需要的细节越少,应使用分辨率更低的 Mip 层级。
LOD 的计算基于纹理坐标在屏幕空间的变化率。如果纹理坐标 $(u, v)$ 相对于屏幕坐标 $(x, y)$ 变化很快(例如,纹理被强烈压缩),则需要更模糊的 Mip 层级(高 LOD);反之,如果变化很慢(纹理被放大),则需要更清晰的 Mip 层级(低 LOD)。
数学推导
目标是计算偏导数:$\frac{\partial u}{\partial x}$, $\frac{\partial u}{\partial y}$, $\frac{\partial v}{\partial x}$, $\frac{\partial v}{\partial y}$。由于透视投影的存在,$u$ 和 $v$ 并非屏幕坐标 $x$ 和 $y$ 的线性函数,直接计算这些偏导数较为复杂。
经过透视除法后的**透视矫正(Perspective-Correct)**属性是屏幕坐标的线性函数。这些属性包括 $u’ = \frac{u}{w}$, $v’ = \frac{v}{w}$ 以及 $q = \frac{1}{w}$,其中 $w$ 是顶点变换到裁剪空间后的齐次坐标 $W$ 分量。
我们可以先计算这些矫正后属性对屏幕坐标的梯度:$\frac{\partial u’}{\partial x}$, $\frac{\partial u’}{\partial y}$, $\frac{\partial v’}{\partial x}$, $\frac{\partial v’}{\partial y}$, $\frac{\partial q}{\partial x}$, $\frac{\partial q}{\partial y}$。这些梯度在三角形内部是恒定的,可在三角形设置阶段(光栅化之前)计算一次。
假设三角形在屏幕空间的顶点坐标为 $(x_0, y_0), (x_1, y_1), (x_2, y_2)$,对应的某个透视矫正属性值为 $a_0, a_1, a_2$。我们可以建立线性方程组:
$$
\begin{aligned}
a_0 &= A x_0 + B y_0 + C \
a_1 &= A x_1 + B y_1 + C \
a_2 &= A x_2 + B y_2 + C
\end{aligned}
$$
解这个方程组得到 $A = \frac{\partial a}{\partial x}$ 和 $B = \frac{\partial a}{\partial y}$。使用克莱姆法则或直接代入消元:
$$
\frac{\partial a}{\partial x} = \frac{(a_1 - a_0)(y_2 - y_0) - (a_2 - a_0)(y_1 - y_0)}{(x_1 - x_0)(y_2 - y_0) - (x_2 - x_0)(y_1 - y_0)}
$$
$$
\frac{\partial a}{\partial y} = \frac{(a_2 - a_0)(x_1 - x_0) - (a_1 - a_0)(x_2 - x_0)}{(x_1 - x_0)(y_2 - y_0) - (x_2 - x_0)(y_1 - y_0)}
$$
分母是三角形屏幕空间面积的两倍(带符号)。令 $\Delta = (x_1 - x_0)(y_2 - y_0) - (x_2 - x_0)(y_1 - y_0)$。将 $a$ 替换为 $u’, v’, q$,即可计算 $\frac{\partial u’}{\partial x}$, $\frac{\partial v’}{\partial x}$, $\frac{\partial q}{\partial x}$ 等。
以下是相关代码实现:
1 | // Inside calculateAccurateGradients(const ScreenVertex v[3]) |
使用链式法则计算原始纹理坐标 $(u, v)$ 的导数。因为 $u = \frac{u’}{q}$ 且 $v = \frac{v’}{q}$:
$$
\frac{\partial u}{\partial x} = \frac{\partial}{\partial x} \left( \frac{u’}{q} \right) = \frac{\frac{\partial u’}{\partial x} q - u’ \frac{\partial q}{\partial x}}{q^2} = \frac{1}{q} \frac{\partial u’}{\partial x} - \frac{u’}{q^2} \frac{\partial q}{\partial x} = w \frac{\partial u’}{\partial x} - u w \frac{\partial q}{\partial x}
$$
$$
\frac{\partial v}{\partial x} = w \frac{\partial v’}{\partial x} - v w \frac{\partial q}{\partial x}
$$
$$
\frac{\partial u}{\partial y} = w \frac{\partial u’}{\partial y} - u w \frac{\partial q}{\partial y}
$$
$$
\frac{\partial v}{\partial y} = w \frac{\partial v’}{\partial y} - v w \frac{\partial q}{\partial y}
$$
这些计算需在每个像素执行,因为 $u, v, w$(以及 $q = \frac{1}{w}$)在像素间通过插值得到:
1 | // Inside drawScanlines pixel loop (x loop) |
接下来,计算标量值 $\rho$,表示纹理在屏幕上被拉伸或压缩的程度。通常取 $x$ 和 $y$ 方向上变化率向量长度的最大值:
$$
\rho = \max \left( \sqrt{ \left( \frac{\partial u}{\partial x} \right)^2 + \left( \frac{\partial v}{\partial x} \right)^2 }, \sqrt{ \left( \frac{\partial u}{\partial y} \right)^2 + \left( \frac{\partial v}{\partial y} \right)^2 } \right)
$$
$\rho$ 表示屏幕上移动一个像素的距离,大约对应于纹理空间中移动 $\rho$ 个纹素的距离。最终,计算 LOD 值 $\lambda$(OpenGL 术语):
$$
\lambda = \log_2(\rho)
$$
若 $\lambda = 0$,表示屏幕一个像素对应纹理一个纹素,使用 Level 0;若 $\lambda = 1$,表示屏幕一个像素对应纹理 2x2 个纹素,使用 Level 1;若 $\lambda = k$,表示屏幕一个像素对应纹理 $2^k \times 2^k$ 个纹素,使用 Level $k$。
在代码中,通常考虑纹理尺寸 $W_{tex}, H_{tex}$,并直接计算 $\rho^2$ 以避免开方:
$$
\rho^2 \approx \max \left( \frac{\left| \frac{d(u,v)}{dx} \right|^2}{W_{tex}^2}, \frac{\left| \frac{d(u,v)}{dy} \right|^2}{H_{tex}^2} \right)
$$
这里使用向量 $\frac{d(u,v)}{dx} = \left( \frac{\partial u}{\partial x}, \frac{\partial v}{\partial x} \right)$ 的长度平方,并假设纹理是各向同性的。LOD 计算为:
$$
\lambda = \frac{1}{2} \log_2(\rho^2)
$$
代码实现如下:
1 | // Inside Texture::sample(..., const vec2f& ddx, const vec2f& ddy) |
3. Mipmap 采样 (三线性过滤)
计算出 LOD 值 $\lambda$ 后,使用三线性过滤(Trilinear Filtering)从 Mipmap 层级中采样颜色:
选择层级:根据 $\lambda$ 确定两个最接近的 Mip 层级:
- $D_0 = \lfloor \lambda \rfloor$(向下取整)
- $D_1 = D_0 + 1$
确保 $D_0$ 和 $D_1$ 不超过最大 Mip 层级索引。
层内双线性采样:对 $D_0$ 和 $D_1$,使用纹理坐标 $(u, v)$ 进行双线性过滤(Bilinear Filtering),得到颜色 $C_0$ 和 $C_1$。
1 | // Texture::sampleBilinear(const MipLevel& level, float u, float v) helper function |
- 层间线性插值:计算 $\lambda$ 的小数部分 $t = \lambda - \lfloor \lambda \rfloor$,在 $C_0$ 和 $C_1$ 之间进行线性插值,得到最终颜色 $C$:
$$
C = C_0 \times (1 - t) + C_1 \times t
$$
代码实现如下:
1 | // Inside Texture::sample(...) after calculating lod |
4. 整合到渲染管线
Mipmap 流程在渲染器中的整合如下:
顶点着色器:
- 处理顶点,输出裁剪空间坐标和需要插值的 Varyings(包括纹理坐标 $uv$)。
三角形处理 (processFace):
- 对顶点进行透视除法和视口变换,得到屏幕坐标 $(x, y)$ 和 $invW$。
- 进行背面剔除。
- 调用
calculateAccurateGradients
计算三角形的 $\frac{\partial (u/w)}{\partial x}$, $\frac{\partial (1/w)}{\partial x}$ 等梯度。 - 调用
drawTriangle
。
三角形光栅化 (drawTriangle, drawScanlines):
- 遍历三角形覆盖的像素。
- 对每个像素,使用重心坐标或边插值计算插值后的 Varyings(包括 $uv$)和 $invW$。
- 使用链式法则和预计算的梯度,计算像素的 $\frac{\partial u}{\partial x}$, $\frac{\partial v}{\partial x}$, $\frac{\partial u}{\partial y}$, $\frac{\partial v}{\partial y}$。
- 调用片段着色器,传入插值后的 Varyings 和 UV 导数。
片段着色器 (fragment):
- 接收插值后的 Varyings 和 UV 导数 ($uv_ddx$, $uv_ddy$)。
- 调用
Texture::sample(u, v, uv_ddx, uv_ddy)
执行 LOD 计算和三线性过滤,返回颜色。 - 使用采样结果进行光照计算,输出最终像素颜色。
5. 结论
Mipmapping 是现代实时渲染中不可或缺的技术。通过预计算多级分辨率的纹理,并根据屏幕空间变化率智能选择合适的层级进行采样(通常使用三线性过滤),它显著减少纹理混叠现象,提高渲染图像质量,同时通过减少访问高分辨率纹理数据提升性能。