我们先从两个简单的问题入手:
什么是纹素?
纹素是一个纹理的最小颗粒,即一个大小1024 * 1024 大小的纹理,它包含1024 * 1024 个纹素。
什么是像素?
像素显示设备上的最小颗粒,即一个大小1024 * 1024 大小的屏幕,它包含1024 * 1024 个像素。像素的多少与密度,决定了这块屏幕分辨率的大小。
像素与纹素看起来非常相似,只不过一个主体是物理设备,一个主体是一张图片。
在日常生活中的很多情况下,我们通常不会对像素与纹素概念做区分,我们也经常会说一张图片的大小是 xxx 像素。但是在游戏的概念中,这两个概念很多时候都不应该被混淆。
举个例子,假设一块 4k 分辨率屏幕上,正正好好铺满了一张 4k 大小的图片。此时纹理的纹素数量与屏幕的像素数量之比为 4k:4k = 1:1,即一个纹素对应一个像素。那么如果此时将屏幕变更为 2k 分辨率的屏幕呢?此时纹理的纹素数量与屏幕的像素数量之比为 4k:2k = 2:1 即两个纹素对应一个像素,此时像素明显是不够用的。
纹理显示在屏幕上时,最理想的状态就是每个屏幕像素对一个纹素。但是这并不现实,摄像机距离纹理的远近决定了纹素对应像素的多少。当纹素对应像素的比例在某些值时,纹理在屏幕上显示出奇怪的效果。
左:无mipmap;右:mipmap
miamap 机制会提前按照特定算法,生成不同缩放比例的纹理,并且根据纹素与屏幕像素的比例,来选择不同缩放比例的纹理,尽量将纹素像素比维持在一个合理的大小。
左 128*128;中 64*64;右 32*32
由上图可知:
mipmap0(128 * 128) ÷ 2 = mipmap1(64 *64)
mipmap0(128 * 128) ÷ 4 = mipmap2(32 *32)
mipmap 等级递进(尺寸缩小)是以 2 的幂为系数的。
现在我们假设,一张 128*128 尺寸的纹理显示在屏幕上 128*128 大小的区域中,此时纹素像素比为1。
然后我们将摄像机拉远,直至该纹理显示区域大小变为 64*64,此时纹素像素比为 128/64 = 2,即两个纹素对应一个像素,这种情况下,我们理想的状态是将纹理切换成 mipmap1,也就是尺寸为 64*64 的纹理,即一个纹素对应一个像素。换个角度就是说我们要将原始纹理大小缩小2倍,即 2的1次幂(mipmap1)。
继续推算,当纹理显示区域大小为32*32,我们可以计算出,原始纹理大小要缩小4倍,即2的2次幂(mipmap2)。
以此类推我们可以得到一个结论,我们会按照上述计算方式,选择结果中2的幂数作为mipmap等级。
实际计算中,为了确认纹素与像素比,我们必然要提前计算两个像素之间对应的纹素差值,还是上述例子,一张 128*128 尺寸的纹理显示在屏幕上 64*64 大小的区域中,此时纹素与像素比值为 2:1,即两个纹素对应一个像素,那么两个像素之间对应的纹素位置值(uv * 纹理分辨率)的差值即为2。
这种计算差值的操作,可以使用 gpu 提供的方法dFdx 与 dFdy (偏导数函数)来实现,如下图:
gpu 在渲染像素时,都是以 2*2 大小的像素块为最小单位进行的(上图中红色块),这就为偏导数计算提供了前提条件。每一块 2*2 的像素块中的相邻像素会共同计算偏导数,然后这个偏导数会在这两个相邻的像素成员中共享。
在 shader 中计算伪代码如下:
为了更直观的理解上述 shader 中的计算方法,我们可以以下图为参考:
如图,我们假设这是一个 6 * 4 = 24 大小的显示器,灰框表示像素;这个屏幕上要显示一张 6 * 8 = 48 大小的纹理,红色数字表示纹素的编号。此时纹素与像素比为 48:24 = 2:1,即两个纹素对应一个像素,所以图中一个灰框中有两个数字。我们假设被标记为绿色像素为 gpu 最小渲染单位:一组 2*2 像素块。
由蓝色框标出的数字相减,我们便得到一个值为 2 的横向偏导数(ddx),这个偏导数的值代表了相邻像素对应的纹素之间的差值,即两个相邻像素间相差多少纹素。
为了计算 mipmap 的 level 值,我们将的得到的偏导数值导入公式 ㏒b(Xⁿ),此公式计算时,偏导数由向量的点乘得到,原因是因为 uv 可能导致偏导数为负,所以使用向量对自身的点乘,得到向量长度的平方值。结合数值与这个公式 ㏒b(Xⁿ) ,即:
frag_size = 2 * 2 = 4
㏒2(√4) = ㏒2(4½) = 0.5 * ㏒2(4) = 0.5 * 2 = 1
所以我们要取 mipmap1 为最适合当前状况的纹理等级。