opengl 纹理贴到对应的位置_图形学底层探秘 - 纹理采样、环绕、过滤与Mipmap的那些事

前言

上一篇文章地址:图形学底层探秘 - 更现代的三角形光栅化与插值算法的实现与优化

我们继续补全那些在网上资料中讲得含混不清的技术的原理与实现细节,本期的主题是纹理。让我们从名词与概念入手,详细了解与纹理相关的技术细节。

纹理(Texture)、贴图(Map)与材质(Material)

一般来说,纹理所指的对象是图片,一张图片就是一张纹理;贴图指的是映射关系,即“如何将纹理像素映射到uv坐标上”;材质描述了渲染所需的数据集合,通常可以包括基础颜色、镜面反射颜色、自发光颜色、光泽度等数值参数,以及多张贴图和要贴的纹理,还有渲染时所用的shader程序。

纹理采样(Texture Sample)

既然纹理是一张图片,那么自然就有分辨率的存在,纹理采样便是从纹理图片中采集一个像素颜色的操作。例如在下图中,我们采样(0,1)这个位置的像素,获得了浅蓝色。在纹理采样中,我们使用的坐标值应是整数。

dc48fe4b4e007016b1c63c291f884e67.png
来源:LearnOpenGL

纹理环绕(Texture Wrap)

纹理环绕的作用是为了处理超出0.0~1.0范围的纹理坐标,例如采用重复(REPEAT)的环绕方式,采样(4.5,-4.5)的纹理坐标,实际采样的纹理坐标应是(0.5,0.5)。对于负数纹理坐标,采样的实际位置应是1-uv,例如(-0.2,-0.6)应该采样(0.8,0.4)

eea2c1c942ef6ad315f4292f1bc03f8a.png
来源:LearnOpenGL

请注意,纹理坐标的取值范围是[0.0,1.0],两端都是闭区间。许多人(包括我自己)之前在写软件渲染器时都是简单地将纹理坐标减去了小数部分。

float u = texCoord.x - floor(texCoord.x);
float v = texCoord.y - floor(texCoord.y);
int x = u * (width - 1);
int y = v * (height - 1);

这样操作会丢失uv = 1.0的边界纹理。虽然在绝大部分情况下并无大碍,但在绘制天空盒等较大物体时会出现纹理接缝。

2298030eb674c448cd8b8999d1292ddb.png
将(0.0,y)一列画到了(1.0,y)

此问题的原因在于,我们在映射纹理坐标时像素的坐标是(0,0)到(width-1,height-1),但这两个坐标对应的是像素中心的坐标。

e43e3e1de383df450961e56355a4a256.png
来源:D3D9文档,纹理坐标与OpenGL上下相反

真实的纹理像素边界是(-0.5,-0.5)到(width-0.5,height-0.5)。例如上图中从(0,0)到(4,4)画一个正方形,理论上应该覆盖这个范围

5de9369fa04bd39613dc9998558ed2d9.png
来源:D3D9文档

但由于一个像素只能填充一种颜色,实际光栅化的区域是下图所示

642173a22602a44b9fd3b179f19a046c.png
来源:D3D9文档

这就导致我们在贴上纹理时发生了错位,uv=1.0超出了显示范围

b8b0a22d352888b7c62c425bf12aef81.png
来源:D3D9文档,纹理坐标与OpenGL上下相反

解决方法是在进行纹理采样时,对纹理坐标加上-0.5的偏移

int x = (int)(texCoord.x * width - 0.5f) % width;
int y = (int)(texCoord.y * height - 0.5f) % height;
x = x < 0 ? width + x : x;
y = y < 0 ? height + y : y;

这里贴上D3D9的文档地址:从像素到纹素

纹理过滤(Texture Filtering)

纹理过滤的作用是将浮点型纹理坐标转换为整数的像素坐标,并对采样结果进行处理。简单地说,由于我们用于显示纹理的图形与纹理图像存在大小、形状的区别,我们需要在采样过程中进行一定的处理来进行滤波,否则会显示为伪像,包括重叠、错位等。

98479232949569ac2ca3fd486085e7ba.png

OpenGL中有两种基础过滤方式,邻近点(Nearest)和双线性(Bilinear)

a358b82421c1dfebe1f4acf3e902dd18.png
来源:LearnOpenGL

顾名思义,邻近点就是选取与纹理坐标最接近的像素点颜色,其操作伪代码如下:

vec4 Sample2D(vec2 texCoord){
        int x = (int)(texCoord.x * width - 0.5f) % width;
        int y = (int)(texCoord.y * height - 0.5f) % height;
        x = x < 0 ? width + x : x;
        y = y < 0 ? height + y : y;
        return GetColor(x,y);
}

这样操作简单粗暴,但是像素之间会呈现明显的马赛克现象,尤其是在纹理分辨率与图像大小不一致时。

双线性过滤能够很好地解决上述现象,像素之间的过渡更加平滑,但代价是对于图形的每一个像素点,我们需要在纹理上采样4个像素颜色进行插值。关于双线性过滤的具体操作过程,网上的资料大多说得比较模糊,都说是采样最近的四个像素,却并没有说明如何操作。经过一番查证,我发现在OpenGL中,双线性过滤采样的是像素本身以及往上、往右、往右上分别移动一格的像素点。获得的四个像素并不是简单平均,而是根据整数纹理坐标的小数值进行插值。举个列子,一张512x512的图片,双线性过滤采样(0.6,0.6)的位置,实际步骤如下:

  1. 换算纹理像素坐标为(307.2,307.2),实际采样的基准像素点为s1 =(307,307)
  2. 继续采样s2 =(308,307)、s3 =(307,308)和 s4 =(308,308)三个点
  3. 小数部分为(0.2,0.2),以x部分0.2分别在s1,s2和s3,s4间插值
  4. 以y部分0.2插值上一步中的两个结果
vec4 Sample2D(vec2 texCoord) {
        texCoord = texCoord * vec2(width,height) - vec2(0.5f);
        float f = fract(texCoord);
        int x = (int)(texCoord.x) % width;
        int y = (int)(texCoord.y) % height;
        x = x < 0 ? width + x : x;
        y = y < 0 ? height + y : y;
	vec4 s1 = GetColor(x,y);
	vec4 s2 = GetColor(x+1,y);
	vec4 s3 = GetColor(x,y+1);
	vec4 s4 = GetColor(x+1,y+1);
	return lerp(lerp(s1, s2, f.x), lerp(s3, s4, f.x), f.y);
}

上述代码中并没有考虑边界问题,这是因为边界颜色跟环绕设置有关,如果设置为Clamp to border 模式,超出边界的部分会采样背景色;在Repeat模式下,采样(width-1, y)和(x,height-1)位置时,会从(0,y)和(x,0)获取颜色。

Mipmap

我们已经知道了在采样纹理时,纹理大小跟图形大小接近才会有好的显示效果,因此便有了Mipmap技术。Mipmap的原理是预先生成一系列以2为倍数缩小的纹理序列,在采样纹理时根据图形的大小自动选择相近等级的Mipmap进行采样。

53281de09eaa76960ee1de36d77a126b.png
一张被用烂了的说明图

使用Mipmap(通常结合使用双线性过滤)可以有效消除远处物体出现的纹理重叠现象

d7bfdee3b1a5726e2b95a21f62a5d6a5.png

那么问题来了,我们知道像素着色器是以像素为单位运行的,采样时该如何得知图形的大小呢?实际上在GPU中像素着色器并不是逐个像素运行,而是同时处理2x2的像素块,并提供了(唯一)一组获取相邻像素信息的函数——偏导函数dFdx和dFdy。偏导数代表了函数在某一方向的变化率,那么如果相邻两个像素间纹理坐标变化很大,不就能说明绘制的图形很小了吗?

事实上确实是这么做的,例如OpenGL就是通过计算出纹理坐标在纵向和横向的偏导数(并取最大值)来计算Mipmap级别

float MipmapLevel(vec2 texCoord)
{
    // The OpenGL Graphics System: A Specification 4.2
    //  - chapter 3.9.11, equation 3.21
    vec2 dx = dFdx(texCoord);
    vec2 dy = dFdy(texCoord);
    float delta = max(dot(dx, dx), dot(dy, dy));
    return 0.5 * log2(delta);
}

值得注意的是,这么做的前提条件是假设在同一平面内偏导数是连续的[1]

94249097c80c07efe8cd7e7d390a8635.png
(s,t) 就是 (u,v)

Mipmap除了能消除采样率过低带来的失真问题,还有一个重要的优点是节约显存带宽,注意是带宽而不是容量。Mipmap实际消耗的显存大约增加了1/3,但每次仅从需要的mipmap级别进行读取,而不必每次都访问原始大小的纹理,因此可以节约带宽。

三线性过滤

在同时启用双线性过滤和Mipmap之后,我们解决了远处物体的显示失真问题,却又引入了新的问题——Mipmap跳变。因为每个Mipmap级别的分别率相差四倍,当图形的mipmap级别恰好处于两个整数之间时,就会发生跳变。

4a7b35113ae40d65fdf4e6e2632d316c.png
出处:https://www.cnblogs.com/eaglelun/p/4229537.html

在镜头不动时还不明显,一旦移动起来就可以看到一条明显的由清晰到模糊的分界线。为解决这一问题,又出现了三线性过滤方法。三线性过滤的实现就很简单了,对于浮点值mipmapLevel,分别在其前后两个整数级别的Mipmap上进行双线性过滤,然后将两个结果再进行平均。此时对于每一次采样,我们已经采样了8个纹理像素。

各向异性过滤

终于写到本文的重点了。使用三线性过滤与Mipmap之后,对于在屏幕上呈现(近似)正方形的图形,我们已经能够取得很好的效果。但是对于倾斜或者长条状的图形,显示效果依然不够好

24138a2bd944e6c3f6f79a9751749a8b.png
左边:三线性过滤 右边:各项异性过滤

究其原因,在于我们是取得纹理坐标在xy方向上较大的那个变化率计算得到的MipmapLevel,而倾斜或是长条形状的物体,在xy方向上的纹理坐标变化率可以差距很大。例如下图中,左图在同样的距离上du与dv基本相等,而右图中dv则大约是du的两倍。若在这种情况下开启Mipmap,右边的图形就会被贴上更低一级的Mipmap,导致模糊。

4f9e9700ad696295dc8a1f04317b1413.png

解决上述问题的终极方案便是各向异性过滤[2]。各项异性过滤的实现比较复杂,各位可以查阅原始的论文获取最准确的方案,这里仅说说我个人的理解步骤:

(1)计算得到纹理在xy方向上的偏导数

(2)计算绘制图形在纹理空间的投影向量 r1 r2 d1 d2

9ff6fdc6d3447095dd7cd9365fcef249.png
图形在纹理空间的投影

其中,为了后续计算简便,将向量的模进行近似

(3)计算使用的MipmapLevel

(MipmapLevel)

(4)计算各向异性比例

这里的maxAniso就是设置开启的各向异性过滤级别

(4)在同一MipmapLevel下进行多次采样,为此需要生成一系列采样坐标

(这里使用的是向量r1 r2)

其中

(5)将采样获得的颜色进行平均

一般来说,我们在游戏中最高可以开启16x的各向异性过滤,但是实际运算时会将我们设置的各向异性级别与计算得的N取较小值,因此并不是开启了16x就一定会执行16次采样。各向异性过滤相比双线性的性能消耗成倍增长,好在现代GPU上纹理采样已不是瓶颈,在平时游戏时我们可以放心开启此选项,帧数不足时优先关闭阴影、抗锯齿等特效。

总结

在现代GPU中,纹理的采样与过滤方式皆已通过硬件实现,因此我们现在已经难以见到对相关技术细节的讲解了。在本文的撰写过程中,参考了几十年前发表的原始论文,以及OpenGL规范的文档。如果你认为本文对你有所帮助,那么就请点赞收藏支持一下吧~

参考

  1. ^ Ewins JP, Waller MD, White M, Lister PF. MIP-map level selection for texture mapping. IEEE Transactions on Visualization and Computer Graphics 1998;4(4):317}29. https://ieeexplore.ieee.org/abstract/document/765326
  2. ^Implementing an anisotropic texture filter https://www.sciencedirect.com/science/article/abs/pii/S0097849399001594
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值