2、纹理映射基础:纹理坐标、纹理采样、纹理过滤
好,咱们今天聊聊纹理映射最核心的几个概念。说实话,我刚入行那会儿,觉得纹理映射不就是把一张图贴到模型上吗?后来被坑了几次才明白,这里面的门道深着呢。
纹理映射,说白了就是让三维模型表面拥有细节。没有纹理,你的模型就是一堆纯色多边形,跟玩具似的。有了纹理,它才有了「皮肤」、有了「质感」。
2.1 纹理坐标——模型的「皮肤定位系统」
纹理坐标,也叫UV坐标。我习惯把它理解成「模型的皮肤定位系统」。每个顶点都绑着一组(u, v)值,范围通常是0.0到1.0。
- u轴:纹理的水平方向,从左到右
- v轴:纹理的垂直方向,从下到上(注意,有些API里v轴是反的)
你想想看,一个三角形有三个顶点,每个顶点都有UV。那三角形内部的像素怎么知道该采样纹理的哪个位置?靠插值。GPU在光栅化阶段,会沿着三角形表面线性插值UV坐标。
关键点:纹理坐标是归一化的。不管纹理是256x256还是2048x2048,UV范围都是0~1。这样模型就不用关心纹理的实际分辨率了。
我在项目中遇到过一个问题:美术同学给的模型UV超出了0~1范围。比如某个顶点的u=1.5。这时候怎么办?GPU提供了几种寻址模式:
| 寻址模式 | 行为 | 典型场景 |
|---|---|---|
| 重复(Repeat/Wrap) | 取小数部分,相当于平铺 | 地板、砖墙纹理 |
| 镜像重复(Mirror) | 像镜子一样来回反射 | 对称图案、避免接缝 |
| 截取(Clamp) | 超出部分取边界值 | UI元素、天空盒 |
| 边界颜色(Border) | 超出部分显示指定颜色 | 特殊效果 |
避坑指南:我曾经调试过一个奇怪的接缝问题,折腾了两天。最后发现是纹理寻址模式设成了Repeat,而美术给的UV刚好在整数边界上,导致采样到了另一边的纹素。后来我建议团队统一用Clamp模式处理角色贴图,问题就再没出现过。
2.2 纹理采样——从纹理坐标到颜色值
纹理采样,就是根据UV坐标从纹理中取出颜色值的过程。听起来简单?嗯,但这里有个精度问题。
假设纹理是256x256,某个像素的UV是(0.5, 0.5)。那对应的纹素坐标是(128, 128)。但如果是(0.501, 0.499)呢?它落在四个纹素之间。这时候GPU必须决定取哪个颜色。
这就是纹理过滤要解决的问题。我先说最简单的——最近邻过滤。
2.3 纹理过滤——三种核心方法
2.3.1 最近邻过滤(Nearest Neighbor)
最近邻,说白了就是「四舍五入」。计算UV对应的纹素坐标,取最近的整数坐标,直接返回那个纹素的颜色。
// 伪代码:最近邻过滤
int u_int = round(u * textureWidth);
int v_int = round(v * textureHeight);
color = texture[u_int][v_int];
优点:快,非常快。不需要任何计算,直接读内存。
缺点:放大时会出现严重的锯齿和马赛克。你玩过老任天堂游戏吗?那种像素风就是最近邻的效果。
我个人习惯在像素风格的游戏里用最近邻。比如《我的世界》那种风格,用双线性反而显得模糊,失去了味道。
2.3.2 双线性过滤(Bilinear Filtering)
双线性过滤,就是取最近的2x2四个纹素,按距离做加权平均。说白了,它做了两次线性插值:先插u方向,再插v方向。
// 伪代码:双线性过滤
float u_frac = u * texWidth - 0.5;
float v_frac = v * texHeight - 0.5;
int u0 = floor(u_frac), u1 = u0 + 1;
int v0 = floor(v_frac), v1 = v0 + 1;
float fu = u_frac - u0, fv = v_frac - v0;
// 先插u方向
color_top = lerp(tex[u0][v0], tex[u1][v0], fu);
color_bot = lerp(tex[u0][v1], tex[u1][v1], fu);
// 再插v方向
color = lerp(color_top, color_bot, fv);
双线性过滤的效果比最近邻好太多了。放大时边缘平滑,没有马赛克。但代价是每个像素要读4个纹素,带宽消耗是最近邻的4倍。
注意:双线性过滤在纹理缩小时效果很差。因为缩小意味着一个像素覆盖多个纹素,但你只采样了4个,高频细节会丢失,产生闪烁和摩尔纹。这就是为什么需要三线性过滤。
2.3.3 三线性过滤(Trilinear Filtering)
三线性过滤,是双线性过滤的升级版。它解决了纹理缩小时的闪烁问题。
原理是这样的:GPU会预先计算好纹理的多个缩小版本,这叫Mipmap链。每个级别分辨率减半。三线性过滤会做以下事情:
- 根据当前像素在屏幕上的覆盖面积,计算出应该用哪两个Mipmap级别
- 在两个级别上分别做双线性过滤
- 在两个结果之间再做一次线性插值
所以三线性过滤一共读了8个纹素(两个Mipmap级别各4个),做了三次插值。
// 伪代码:三线性过滤
float lod = computeLOD(); // 计算细节级别
int level_high = floor(lod);
int level_low = level_high + 1;
float blend = lod - level_high;
// 在两个级别上分别做双线性过滤
color_high = bilinearSample(level_high, u, v);
color_low = bilinearSample(level_low, u, v);
// 混合两个结果
color = lerp(color_high, color_low, blend);
核心要点:三线性过滤保证了纹理在缩放时过渡平滑。从近到远,Mipmap级别逐渐升高,纹理细节自然消失,不会出现突然的跳变或闪烁。
我记得有一次做地形渲染,远处的地面纹理一直在闪,像水波纹一样。排查了半天,发现是Mipmap生成时用了错误的过滤方式。后来改用三线性过滤,配合各向异性过滤,问题就解决了。
2.4 三种过滤方式的对比
| 过滤方式 | 采样数 | 放大效果 | 缩小效果 | 性能开销 |
|---|---|---|---|---|
| 最近邻 | 1 | 马赛克严重 | 锯齿严重 | 极低 |
| 双线性 | 4 | 平滑 | 闪烁、摩尔纹 | 中等 |
| 三线性 | 8 | 平滑 | 平滑过渡 | 较高 |
我的建议:移动端GPU带宽有限,我一般对UI用最近邻(反正UI不缩放),对场景物体用双线性,对地形和远景用三线性。当然,如果性能允许,全用三线性最省心。
嗯,纹理映射的基础就这些。说白了就是三个问题:坐标怎么算、颜色怎么取、取的时候怎么平滑。下一节我们会聊更高级的各向异性过滤,那才是真正考验GPU纹理单元性能的地方。