啊,不知不觉得已经是第三篇技术日志啦。本来以为这里关注技术实现的人并不会特别多(毕竟现在的游戏引擎强大了,很多问题都被引擎解决了)。但是意外的是依然有人愿意看(倍感欣慰哈!)。本来打算开个连载写点大的东西了,不过考虑到现在的情况,估计是有些分身乏术。所以连载的技术日志还是晚些再说吧。因此这次依旧聊一些小一些的主题。这次要讲讲图片使用过程中大家都很有可能碰到的问题,以及对应的解决方案。这些问题也有不少人问过我了,或许这篇日志里的内容对碰到同样问题的小伙伴们会有些帮助。
1. 采样越界问题:
为了性能考虑,很多情况下我们需要将多个游戏资源图片拼合到一张资源图中(很多引擎都会有拼合资源的功能,也算是引擎的标配了吧)。但是这样做的话却会产生一些使用上的困扰。最常见的困扰就是采样越界。采样越界就是采样时我们采样到了来自其它图片的内容,导致图片边界位置出现不正常的“线”。尤其是在使用了非最近邻采样(比如线性采样)的时候,就非常容易出现这种采样越界的问题。
为了解释我们使用一张有两块不同颜色的图片作为我们的合并图:
同时使用线性采样的方式显示左半张图片:
可以很明显看出来,显示的右边出现了染色,那是因为图片越界采样到右半张图片上导致的。当然,这个问题在最近邻采样的时候依旧会出现,只是没有非最近邻采样那么频繁(不过最近邻采样出现此问题时将会更为明显)。很幸运的是,很多引擎在处理拼合资源时都会考虑到这个问题,所以会在引擎层面将这个问题处理掉。不过如果各位开发者使用一些没有这方面考虑的引擎时,就需要自己处理掉此问题了。比较简单的解决方案就是对使用的所有单个资源图在合并之前进行“扩边”处理:
1. 对这个精灵图片进行扩边:扩边可以通过拷贝复制最外圈的像素实现;也可以选择补充alpha为0的边来实现。这样原本比如一个尺寸为16×16图片经过扩边一圈的扩边后就变成尺寸为17×17的图片。我们在合并时就使用扩边后的图片进行合并。注意一点,到底对边缘进行多少圈的扩充与开发者是否打算在程序中对此精灵进行放大以及放大多少倍有关系;同时也和合并后的资源图尺寸有关系(如果合并后的资源图特别大,出于对精度的考虑也需要多扩充几圈)。如果不打算放大或是放大倍率很小(小于两倍)的话,只要扩充一圈就可以了。实际上我们可以简单地认为,图片可能会被放大几倍就扩充几圈。
2. 在使用时我们也需要对采样使用的UV坐标进行一些调整。因为现在合并图片要比原始图片大几圈,而我们的UV坐标依旧还是需要对应到原始图片的区域上。比如上面例子所说的17×17的图片。我们真实使用的采样区域就为[ 1, 1 ] [ 16, 16 ]。对应的UV坐标即是[ 1/17, 1/17 ] [ 16/17, 16/17]。而在合并图内,UV还需要进行一次换算。
在这里,我们隐约可以发现非最近邻采样似乎会更容易出问题。那是不是非最近邻采样会产生其它问题呢?答案是肯定的,下面我们就讲讲非最近邻采样在图片的使用上还会导致什么问题:
2. 非最近邻采样引发的问题:Alpha插值问题与Alpha预乘(Pre-multiplied alpha):
Alpha预乘这个概念在很多地方都能听到。尤其是引擎在支持PNG这种带Alpha分量的图片格式时,都会提供Alpha预乘的选项或是直接就用alpha预乘的方式来读取图片。简单来说呢,Alpha预乘就是在处理那些带透明度的图片时,将Alpha值预先乘上像素的RGB值。这时,图片数据里的RGB值就不再是单纯的像素颜色,而是乘以Alpha值之后的像素颜色。这样在进行图片混合时,我们的公式也由:
color =(src.rgb * src.a)+ dst.rgb * ( 1 - src.a )
转变为:
color = src.rgb + dst.rgb * ( 1 - src.a )
为了使用经过Alpha预乘的图片我们需要对混合函数进行修改(OpenGL中使用glBlendFunc),将其调整为上述公式(另外友情提醒一下:如果使用Alpha预乘了还发现混合后的图片出现黑色描边的情况。请优先检查混合函数的公式是否已经修改)。而又因为我们的RGB颜色已经是乘以对应像素的alpha值了,所以源像素就不需要再乘以自身的Alpha值了(嗯,省去一个乘法,也算是Alpha预乘对性能的一点贡献了吧)。那么问题来了,为什么需要alpha预乘?这个就和图片使用非最近邻采样获取像素颜色的方式密切关联了。
我们以线性采样为例子:现在我们有一张只有两个像素的2×1的图片,左边的像素颜色是(1,0,0,1)右边的像素颜色是(0,1,0,1)。那如果我们将这张图片缩小到1×1之后,这个只有唯一一个像素的图片的像素颜色在线性采样的作用下就等于左右两个像素的颜色各取一半:
(1,0,0,1)* 0.5 + (0,1,0,1)* 0.5 = (0.5,0.5,0,1)
在两个像素的Alpha相等的情况下,插值得到的结果是没有问题的。但是如果两个像素的Alpha不同就会出现问题了。我们现在假设左边的像素Alpha为0.9;而右边的像素Alpha为0.1:
(1,0,0,0.9)* 0.5 + (0,1,0,0.1)* 0.5 = (0.5,0.5,0,0.5)
仅从计算从结果上,就能够发现我们的颜色不对。因为左边的像素颜色的Alpha值远大于右边像素的颜色,就意味着最终颜色更应该接近左边像素。但结果上,像素的颜色却只是对两个像素做了个平均值。对,没错。Alpha预乘需要解决的就是这个问题。现在,我们对两个像素做一次Alpha预乘来看看结果:
经过Alpha预乘之后,我们两个像素的颜色将要转变为:(0.9,0,0,0.9)和(0,0.1,0,0.1)。这个时候再次得到的插值结果就为:
(0.9,0,0,0.9)* 0.5 + (0,0.1,0,0.1)* 0.5 = (0.45,0.05,0,0.5)
嗯,这个结果看起来就正确多了。
这里推荐一篇专门讲Alpha预乘的文章,有兴趣的读者可以看看:https://developer.nvidia.com/content/alpha-blending-pre-or-not-pre
上面的文章是英文的,如果觉得阅读困难可以看看一篇中文的(这篇文章也是转载自其它地方的转文,可惜我原文链接打不开,就只能发它了):https://www.cnblogs.com/xiaonanxia/p/9448444.html
当然啦,Alpha预乘虽然解决了一些问题,但自身也带来一些使用上的不便。即是经过Alpha预乘后的像素颜色将不再直观。因为经过Alpha预乘后,图片的像素颜色将不再是原本的颜色而是经过Alpha变换后的颜色(除非Alpha为1)。当我们希望获取图片的原本颜色时带来一些困扰,我们不得不将RGB颜色再次除以Alpha值得到原本颜色才行。当我们需要图片进行Alpha连续变化时,此种不便将不可避免(对于那些需要Alpha连续变化的图片,还是建议将它们的Alpha统一设为1最好)。
除了Alpha预乘以外,有条件的开发者也可编写在shader内自行编写一个专门处理非最近邻插值采样的函数来代替图形API自带的插值采样。这样就可以不使用Alpha预乘的图片,转而在自己的插值采样函数内处理Alpha插值问题。这样做性能会弱一点(插值采样函数应该都已经固化在GPU内部了)。不过,可以避免使用Alpha预乘带来的不直观的问题。对于有复杂需求的情况下,可能会更为合适。
3. 像素图片缩小带来的失真问题
感谢当年网友(@ffnumber1)在我的评论里提的问题。给我编写的内容开启了一道新的大门。(要不是小伙伴的提问,我还真把mipmap它老人家给忘了)。
先看看我们使用的测试原图长啥样:
在看看图片缩小时,使用了最近邻插值采样的情况下的失真效果吧:
接着是线性采样的方式:
很明显无论是最近邻采样还是线性采样,我们都能发现图片中的边线时而出现时而消失。而这种情况会随着动态缩放的过程持续发生,直到缩放比例达到一个合适的程度才会停止这种现象。这个就是所谓的采样失真。
在显示像素小于贴图像素时,会出现这种采样失真的情况。所谓的显示像素小于贴图像素就比如我要在8×8的屏幕区域上显示16×16的贴图。这种情况下就是显示像素(8×8)小于贴图像素(16×16)。导致这个问题的根本原因就是我们在显示像素小于贴图像素时,每个显示像素之间的采样间距过大,导致采样丢失了过多的信息而出现失真。比如我要在2×2的区域绘制16×16的贴图,那么每个显示像素相当于每经过8个贴图像素采样一次,那么这8个像素中除了第一个像素被采样,到其余7个像素的信息都会被忽略。
因此,为了解决这个问题就出现了mipmap算法。此算法多用于3D场景中,远景贴图的采样失真(LOD需求的标配之一),不过它解决的问题2D场景中也会出现。mipmap算法的解决方法就是生成分级贴图,然后根据缩放比选取相邻的两张进行混合采样以保证降低或不出现这种采样失真的情况。举个简单的例子:假如我们还是使用上面的16×16的贴图,程序就会对其生成:8×8;4×4;2×2:1×1一系列不同尺寸级别的贴图(不同尺寸级别的贴图也可以自己指定,mipmap支持自定义不同尺寸级别的贴图)。而采样时根据缩放比率选择相邻两张进行混合采样(比如缩放比在1~2之间时,就会选择16×16和8×8两张贴图进行混合采样)。具体关于mipmap的算法细节以及使用方式不多展开了,反正网上一搜一大把。
另外需要注意的是,mipmap对使用的贴图尺寸是有要求的:尺寸大小必须是2的幂次,同时所有尺寸级别的贴图尺寸也必须是2的幂次。这个原因和计算机使用二进制有关,在2的幂次的尺寸下不同级别的贴图可以生成到最好的效果。(事实上,贴图尺寸尽量使用2的幂次是有意义的,之前提到的几乎所有与采样有关的问题,如果作用于2的幂次尺寸的贴图,大多问题都会得到缓解)
一般情况下,我们如果碰到缩放失真的问题,可以优先尝试mipmap的方式。mipmap本身灵活性比较大,能调整优化的地方也比较多。不过如果只是常规使用的话(系统生成mipmap贴图组,如何选择贴图也是按照标准的做法)也会出现点问题。我们先看看常规使用的条件下,mipmap针对上面的图片的处理效果。(在我的OpenGL版本中)mipmap有四种不同的混合采样方式可以选择:mipmap首先在选择出来的相邻两张mipmap贴图中,获得两个采样点。而这两个采样点可以选择通过最近邻或线性的方式从mipmap贴图中获取;之后再对得到的这两个采样点进行混合采样得到最终结果,这个过程一样可以选择最近邻或线性的方式混合。所以一共有四张不同的混合采样方式。我们依次给出效果:
先最近邻;再最近邻:
先最近邻;再线性:
先线性;再最近邻:
先线性;再线性:
首先,我们可以发现闪烁的问题确实被解决了。同时,双线性的整体效果不出意外地是四种方式中最好的。但是新问题也随之出现:在缩放程度较为大的情况下,有很严重的模糊效果。的确,这个模糊的问题是mipmap不可避免的(毕竟mipmap是混合采样方式,比起简单的最近邻采样会混合多个像素的颜色进而更容易模糊)。不过依然有办法对它进一步优化。
其中一种比较暴力的优化方式就是:让美术自己画不同级别的贴图代替图形API自动生成的贴图(刚才说过了,mipmap可以自定义不同级别的贴图)。虽然这样做些微有点反人类。但也不得不说真用这种人工的解决方案的话,效果应该可以调整到相对满意的情况。
那么,除此之外可以调整mipmap算法选择贴图的方式让它看起来相对合适一些。标准做法最大的问题就是在于:系统生成的那些非常小的mipmap贴图因为太模糊而使得它们的适用性比较低。那么最简单的做法就是不使用它们就可以了。当我们尝试只使用原始贴图和一级mipmap贴图(就是长宽都缩小到二分之一的那张mipmap贴图)两张mipmap贴图来做mipmap采样时,模糊的问题就会改善不少:
先最近邻;再最近邻:
先最近邻;再线性:
先线性;再最近邻:
先线性;再线性:
好啦。这次的内容就聊到这里吧。
暂无关于此日志的评论。