从像素之间谈起:像素游戏的画面增强(上)

作者:转载小公举
2018-08-06
54 74 0

前言

本文转载自:从像素之间谈起:像素游戏的画面增强(上)

分类

随着分辨率的普遍提高,我们已经告别了依赖于简陋像素来表现游戏画面的年代。但还是有不少人像我一样沉迷于像素美术和游戏。如今到处可以都可以看到的各式像素作品,虽然大多被直接称呼为像素画,但实际上已经分化为很多分支,简单的将其归类为像素作品未免太含糊。在开始正文之前我先将他们粗粗的分个类。一些比较常见的代表如:

1、大颗粒像素,此类像素作品一般细节较少,人物符号化或者抽象化。同时还可能出现非像素元素,如光晕,渐变

单键 Bob,一个颇为爽快的 flash 游戏

2、粒度较小的像素画,主要还是色块化,边缘并没有强化。

HGSS 中的 Dragon Den

3、强化边缘和高光,细节丰富,但是普遍尺寸较小。

Drill Dozer 截图

另外,在一些 UI 图标的绘制过程中,由于图标较小,也同样采用像素点绘的方式。因为它平时也不会被称为像素画,所以这里也不讨论。

其中第3种是我在本文中将着重讨论的。

这类像素图可能和平时所提到的像素图差的最远,因为它并不是为了做出像素化效果而诞生的。相反它是游戏机在分辨率和色板支持加强之后的产物(光是从 GB 到 GBC,支持的色深就从2位变成了15位)。在这方面,任天堂算是是做到了极致(也可能因为任天堂的主机的屏幕天生小的缘故)这类像素画在抗锯齿(伪),光照,色彩的调和的方面很有特点(这篇文章中不细说)

再现像素画

就 GBA 而言,分辨率为240 *160,但我们现在再制作像素的游戏时,玩家一定不会接受在这么小的一个屏幕上去玩游戏。一个是因为眼睛看的太累(长大后眼睛都变差了…)。另一方面,考虑到像素画的成本,也不建议针对一个1080p 的屏幕进行逐像素绘画。为了满足一些玩家想要的像素的效果,一个最简单直接的方法就是将画面放大。

这幅图放大了3倍之后,也许会更容易于将它认为是像素画风

虽然这种方法省时省力,但是也会带来一个问题。在绘制像素画中的曲线时,由于一般不对线条使用反走样(会让画面变脏)来抗锯齿。在分辨率较低的时候,像素的边缘可以帮助人们识别且很难注意到异样,但当画面放大后,这些边缘就会显得粗糙不堪。这也是像素画风被一些人所诟病的原因。

为此,包括 ppsspp 在内的模拟器中,会内置不少 shader 来对图像进行后期处理。对于2D 图像来说,具体方法包括 xBRZ 等滤镜来平滑放大图像(xBRZ 对2D 像素放大会产生平滑而舒适的效果,但是这会损失像素的特征),增加 crt, 扫描线等后期特效将像素画做旧。当然,你也可以利用物理的手段将信号输出到 CRT 屏幕上,参考这里
另外,这篇文章 中讲述了一些 crt 效果的来源,也讨论了很多细节问题。一个简单的对比图:

常见的效果如下:

注意屏幕的扭曲,这其实是 crt 的物理性质决定的

注意像素的膨胀

虽然实现方法不同,但总的来说都是在像素之间增加了隔断,人们的大脑会趋向为这种断裂解释理由,自动为图像进行内部平滑处理。这就和我们凑近屏幕看游戏画面但是不会觉得画面模糊的原因类似。另一方面,因为扫描线的存在,画面的层次感也可以体现出来,使得画面更加可信。甚至连 Her Story 中都为了剧情的需要用些 crt 效果。
这篇文章里介绍了大量的 post processing shader,很有借鉴意义。

一个 shader 的实现思路

本文的后期特效将主要适用于前面所述的第三种情况,也即通过临近采样的方式放大图像而达到加强像素化的目的。更多的模拟 LCD 屏幕而不是 CRT 屏幕,所以一些包括屏幕扭曲,通道分离的效果在本文中将不会涉及。本文会利用 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:

o.pixel_no = float2(o.uv.x * _MainTex_TexelSize.z, o.uv.y * _MainTex_TexelSize.w) * 0.25;

_MainTex_TexelSize 是内置 uniform,记录输入纹理的相关信息,其中 zw 分量即为宽和高。对于 ppsspp 模拟器,可以通过 u_texelDelta 来计算屏幕的 resolution,后面会提到。

有了 pixel_no 的信息,我们就可以在片段着色器里进行插值了:


fixed4 Pass_Scanline(float2 uv) {
       float column = 4;
       float2 pixel_no =
           frac(float2(uv.x * 1024.0, uv.y * 768) * _ScreenScale / column);
       if(pixel_no.x < 1 / column || pixel_no.y < 1 / column)
           return PREVIOUS_PASS(uv) * 0.5;
       else
       return PREVIOUS_PASS(uv);
}

其中 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个纹素取样,权重为该像素到纹素距离倒数的负相关。本质上是一个非对称的低通滤波器。它的优势在于,针对每个纹素内的像素,所采样的纹素是一致的(保留了像素的质感)而在纹素内部,利用非对称的卷积核实现亮度的变化。

1个纹素被分为9个像素

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

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

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

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

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

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

从顶部看会更直观

一些细节

滤波器的构成

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

float color_bloom(float3 color)
{
    // const float3 gray_coeff = float3(0.30, 0.59, 0.11);
    const float shine = 0.25;
    float bright = Luminance(color);//dot(color, gray_coeff);
    return lerp(1.0 + shine * 0.5, 1.0 - shine, bright);
}

这里我们除了可以自己定义 gray_coeff 以外,我们也可以使用 unity 中的内置函数,它对应的 gray_coeff 为 fixed3(0.22, 0.707, 0.071)

另外,通过在 lerp 的时候增加一个系数,我将暗部的亮度稍微提高了下,弥补曝光不足的情况。

No.的偏移

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


float delta = dist(frac(pixel_no + float2(-0.1250.125)), offset + float2(0.50.5));


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

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

采样的偏移

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

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

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

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

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

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

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

增加 bloom

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

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

	fixed4 Pass_SimpleBloom(float2 uv)
{
    float4 sum = float4(0, 0, 0, 0);
    float4 bum = float4(0, 0, 0, 0);
    
    float2 glareSize = float2(1.0 / 512, 1.0 / 384) * 0.65;
    int height = 3;
    int width = 1;
    for(int i = -width; i < width; i++)
    {
        for(int j = -height; j < height; ++j)
        {
            sum += tex2D(_MainTex, uv + float2(i, j) * glareSize);
            bum += tex2D(_MainTex, uv + float2(j, i) * glareSize);
        }
    }
    fixed4 color = PREVIOUS_PASS(uv);
    color = (sum*sum*0.001 + bum*bum*0.0080) * _Amount / ((2* height +1) *(2* width +1)) + color*_Power;
    return color;
}

renderTexture 与 multipass

Bloom 的操作我并没有在 ppsspp 模拟器中实施,主要原因是我不知道如何在 ppsspp 中实现真正的 multi-pass shader,如果只是通过宏将 pass 折叠起来,由于 bloom 需要对周围采样,将会导致计算量指数式上涨。

但是这一切在 unity 中就很容易解决了,只需要在第一遍的 pass 中将 bloom 后的输出输出到 render texture 就可以被后面的 shader 所利用,两者加起来的时间测试下来大概只有 single-pass 的1/5,优化效果还是非常明显的。

RenderTexture rtTemp = RenderTexture.GetTemporary(src.width, src.height);
Graphics.Blit(src, rtTemp, _Material_1);
Graphics.Blit(rtTemp, dst, _Material_2);
RenderTexture.ReleaseTemporary(rtTemp);

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

可以看出分割出两个 pass 之后开销一下平衡很多。另外,unity 中在利用 RenderTexture.GetTemporary 时,内部会调用DiscardContents ,因而对 CPU 的效率也有所提升。详情可以参考官方文档

增加了 bloom 之后的效果图。