像素游戏的后期特效将主要适用于这第三种情况:

  1. 通过临近采样的方式放大图像而达到加强像素化的目的。

  2. 更多的模拟LCD屏幕而不是CRT屏幕(包括屏幕扭曲,通道分离的效果)。

  3. psp模拟器,将扫描线效果应用到Tactics Ogres(中文译为:皇家骑士团)上。

  我主要从两方面完成对像素图的画面增强:

  1.利用微小的分割线来分隔开像素,让人们产生像素相连的错局。

  2.利用低通滤波器稍许的平滑像素边界(但是不宜平滑太多,不然会失去像素风格的特点)

  为了统一,后面的演示代码都用CG来写,输入的纹理尺寸为512 x 384。

  格子的分割 硬分割

  首先,将像素放大了2倍之后,实际看到的一个“像素 pixel”(叫纹素 texel更为贴切)是2 x 2个像素。虽然我们想营造出的效果是让玩家觉得游戏的像素与像素之间产生了间距,但除了在原先的一个像素上通过勾画边缘来实现分割,我们并不能真的将像素之间创造出空格。这步操作之后,最小单位仍然是像素。下图所示的分别是每2个像素进行一次分割和每4个像素进行一次分割的图示。

  

  每两格有一个明暗变化周期

  

  每四格有一个明暗变化周期

  对于后期特效来说,输入的纹理为camera input,上图是1 texel对应 4 pixel,而下面是1 texel对应 16 pixel。 为了找到分割的位置,需要能够区分一个纹素所对应的像素。方法并不复杂, 若一个纹素拆分为4*4个像素,可以在顶点着色器上输出如下vec2:

  _MainTex_TexelSize是内置uniform,记录输入纹理的相关信息,其中zw分量即为宽和高。对于ppsspp模拟器,可以通过u_texelDelta来计算屏幕的resolution,后面会提到。 有了pixel_no的信息,我们就可以在片段着色器里进行插值了:

  其中PREVIOUS_PASS是一个宏,用来嵌套伪multi-pass,这里的PREVIOUS_PASS可以简单的理解为上一个获取纹理的值的pass。这里当column为4的时候,一个纹素对应的四个像素的pixel_no的x分量分别为1/8, 3/8, 5/8, 7/8,我们可以利用这个信息来判断究竟哪个像素是这个纹素的边缘。 硬分割虽然完成了对像素的分割,但是效果比较生硬。玩家感受到的不是从屏幕上反映的图像,而更像是罩上了网格的图像。这也和asset store上的这个效果类似。

  丰富分割细节

  硬分割的效果不理想,于是很自然的想到为这个边缘添加一些过渡效果是否会好一点呢?答案是肯定的。另外,为了能取得比较好的过渡效果,我们应该适当提高pixel对texel的比例,测试下来发现一般来说3比较合适,2的话太窄,而4的话,图像放大的过大。 为了理解方便,我们将图像的边缘定义为暗,图像的中央定义为亮,这样明暗间隔就能产生所谓的扫面线。问题演变为在一个纹素所对应的所有像素中,如何找到一个亮与暗的分布,从而表现出一个荧光格子的效果 如果单纯的亮度从中心开始,依照切比雪夫距离向边缘递减,效果其实不太理想,纹素与纹素之间割裂的依旧生硬

  

  所以我们想找到一种方式柔滑这一过程,首先可以尝试用高斯平滑来处理

  

  不过作用效果还是在一个纹素内,所以还是不够好

  卷积核 简单的过渡不够,所以需要找到一个卷积核(kernel)来将像素周围的情况考虑进去,最常见的低通滤波器就是高斯滤波器(Gaussian Filter)但直接使用的话,会造成画面均匀平滑。Themaister提供了一个很好的思路(虽然由于git目录失效,原始的代码已经不可考,但是我还是在网上找到了一个

  GLSL版本

  ),效果如下图所示:

  

  除了有些恼人的小黑边,但是总体效果非常接近我想要的最终效果

  他的思路简单概述起来就是,一组像素(如4x4)向所在纹素的相邻8个纹素取样,权重为该像素到纹素距离倒数的负相关。本质上是一个非对称的低通滤波器。它的优势在于,针对每个纹素内的像素,所采样的纹素是一致的(保留了像素的质感)而在纹素内部,利用非对称的卷积核实现亮度的变化。

  

  一个纹素被分为9个像素

  

  取左上角的像素进行演示,红色的线条的长度与权重成负相关

  我们知道越靠近中间,加权值越高,对于一个靠左下角的像素来说,将其卷积核画出来可能会像这样:

  

  权重为Exp(-2.05 * 平方欧氏距离)

  

  权重为Exp(-2.05 * 欧氏距离)

  之所以不选择平方欧氏距离,是因为这会造成加权之后,中间亮度区分不开来,而周围的亮度又太低,会有种硬分割的感觉。 在对周围的采样做了积分之后可以得到下图。虽然和前面的图很像,这张图的意义和刚才的并不一样,它代表的是一个纹素内的亮度分布(假设亮度的原始分布均匀)。

  

  考虑到以上的操作局限在一个很小的范围内,所以我们可以将其离散化后观察

  

  

  从顶部看会更直观

  一些细节 滤波器的构成

  Themaister的方法中,考虑了亮度对像素最终颜色的影响,这个滤波器由两个函数构成,一个是空间域上的滤波器系数,另一个是值域(亮度)上的系数。如果采样点上的亮度越亮,意味着它将会更多的侵蚀着其他的像素。有关Glow效果,可以参考这篇文章

  这里我们除了可以自己定义gray_coeff以外,我们也可以使用unity中的内置函数,它对应的gray_coeff为fixed3(0.22, 0.707, 0.071) 另外,通过在lerp的时候增加一个系数,我将暗部的亮度稍微提高了下,弥补曝光不足的情况。

  No.的偏移

  刚才的卷积核只是一个理想状态的演示,实际上,由于任意两个纹素是相邻的,所以只能在一个纹素的两边(看成一个正方形)上进行边的绘制。否则,两个相邻纹素在交界处都绘上黑边会导致扫描线过粗。另外,如果直接采样,将会出现平顶的情况,也即是当边上为偶数个像素的时候,中间会出现高度一样的状况。于是需要对之前的pixel_no进行偏移,偏移之后将会打破原有的平衡,找到一个新的中心。这里的偏移值应该小于1/(column * 2),否则循环周期将会出问题。

  

  

  通过对比可以看出,偏移之后,左侧和上侧的亮度明显变暗,亮度会表现的更集中在中间的一个点。

  

  图所示为不同粒度下的表现

  采样的偏移

  为了给物体增加一些投影,特别是文字,会对当前像素点的周围采样。我们并不是直接用相邻像素采样(相邻像素很有可能来自于同一纹素,所以采样没有意义),而是偏移一段距离,这和ps中的投影是一个原理。只是这里需要特别注意一个问题,也即是之前看到的一张图中出现的黑边问题。

  

  注意人物轮廓周围的小黑边

  这个问题的起因是:如果采样点之间始终距离为一个纹素的时,虽然能保证取到的都是周围的纹素,但当图像中文本的边界正好是处于格子的边缘(也就是亮度最低的位置)在经历一个周期后,亮度是最低的地方(周期性所致)就会对之前还在暗色边界范围内的像素采样,这样就会出现在一个白的背景上出现了一条黑边。 解决方法就是将采样偏移限制在纹素所包含的像素个数之内,虽然这意味着我们的投影无法超过一个纹素,但是起码会避免一些比较糟糕的情况。

  

  

  欧氏距离与曼哈顿距离的选择

  前面在谈到权重的时候,我们的图示标注出来的是欧几里得距离,那么如果为了将指令减少几条,变成曼哈顿距离如何呢?结果是:并不好

  

  可以看出,形成了一个明显的十字亮斑,并且高度差异并区分度不高

  另外值得一提的是,由于编译器和显卡的优化,使用曼哈顿距离并不能节省什么开销。

  增加bloom

  Bloom能起到加光晕的效果,能进一步降低粗糙感。通常来说,bloom只是作为HDR的一环,过程还可以包括Tone Mapping、Bright Pass Filter以及Blur。但由于我们这里只考虑2D的情况,更多时候HDR可以由美术手工实现,所以我们先不讨论ToneMapping而简单实现Bright和Blur。

  1 混合横向的bloom和纵向的bloom 比较常见的bloom中的blur过程分为两次,一次横向像素上的模糊,一次纵向像素上的模糊,两次叠加。但是我们为了省力,也可以在一个pass中进行,毕竟我们只是为了虚化边缘,制造投影的效果。

  renderTexture与multipass

  Bloom的操作我并没有在ppsspp模拟器中实施,主要原因是我不知道如何在ppsspp中实现真正的multi-pass shader,如果只是通过宏将pass折叠起来,由于bloom需要对周围采样,将会导致计算量指数式上涨。 但是这一切在unity中就很容易解决了,只需要在第一遍的pass中将bloom后的输出输出到render texture就可以被后面的shader所利用,两者加起来的时间测试下来大概只有single-pass的1/5,优化效果还是非常明显的。

  

  优化之前几乎所有的时间都耗在了最后一个drawIndexed上

  

  可以看出分割出两个pass之后开销一下平衡很多。另外,unity中在利用RenderTexture.GetTemporary时,内部会调用

  DiscardContents

  ,因而对CPU的效率也有所提升。详情可以参考

  官方文档

  。 增加了bloom之后的效果图。