智慧 + 毅力 = 无所不能

正确性、健壮性、可靠性、效率、易用性、可读性、可复用性、兼容性、可移植性...

导航

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

Posted on 2018-08-10 16:06  Bill Yuan  阅读(3327)  评论(0编辑  收藏  举报

转自:http://fushigi-hako.site/2017/07/02/from_pixel_to_screen_1/

无所不在的像素画

分类

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

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

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

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

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

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

再现像素画

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

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

为此,包括ppsspp在内的模拟器中,会内置不少shader来对图像进行后期处理。对于2D图像来说,具体方法包括xBRZ等滤镜来平滑放大图像(xBRZ对2D像素放大会产生平滑而舒适的效果,但是这会损失像素的特征),增加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个像素进行一次分割的图示。

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

一个纹素被分为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.125, 0.125)), offset + float2(0.5, 0.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之后的效果图。

其他可能的改进

投影增强

前面我们在进行扩散投影模拟的时候,是同时对周围八个点进行采样,但是事实上,有时为了控制投影的方向,可以只对一侧的点进行采样

如图所示,只需要对右下角的五个格子采样,就可以模拟出左上角的光照。

这样造成的效果是亮的部分会凸起,暗的部分会产生凹陷的效果

函数的拟合

前面在计算相邻点的加权颜色值时,用到了一个指数函数。指数函数的效果的确很好,考虑到在某些平台上exp的消耗可能有点大。另外,任意两个像素之间的欧几里得距离不会超过2.3个像素,所以我们尝试对函数进行一个拟合,如0.926+1.441x + 0.6578x^2 + 0.0417x^4

其实我们还可以将把它化成1/(7x^2 +1),效果也还可以,只是无论是哪种情况,在PC上测试差距并不明显(也有可能适得其反)

扫描线

考虑到有些游戏中,会出现一些因为曝光过度而无法显示扫描线的情况。于是,我们就需要对扫描线进行加强:

float limit = 1 - step(257.0, min(frac(i.pixel_no.x), 1- frac(i.pixel_no.y)) * _MainTex_TexelSize.z);
float bright = Luminance(out_color);
return fixed4(out_color *(1.8 - limit * bright * bright * 0.89), 1.0);

但是对于某些偏暗的游戏,如果为了提高整体亮度,而扫描线同时也强化的很厉害,那么就会导致“碳化”

中间的白色由于亮度过高,在补偿的时候会显得非常暗

虽然这样的扫描线加强在其他场合正是我们需要的,但在这里只会让画面变得很脏。关于这个问题并没有很好的解决方案,这需要根据不同游戏对参数做出调整。作为游戏开发,如果美术风格及早的确定,颜色的选择有所参照,将会对程序的优化有极大的帮助。而2D像素游戏由于很少受光照影响,再加上像素画本身也极其依赖于palette,所以如果palette控制的好,是可以根据其调试出一个很好的状态的。

由于目前只用针对一款游戏,所以上面的手工调整可以接受。如果我们需要大量的调整,我在想,可能还有一种思路是像tone mapping一样,将亮度映射在一个合理的区域内,这样既保留了细节又处理了边界状况。

Tactics Ogre的特殊处理

刚开始我为Taactic Ogre(中文译为:皇家骑士团)写shader的时候,出现了一个问题。由于很容易知道psp的分辨率是480*272,我就将其硬编码到shader中。但是却出现了一些意想不到的状况。在横坐标方向上,扫描线的分布不均匀。由于是周期性的,并且随着窗口的扩大问题更为严重,我最开始猜测是模拟器的精度出现了问题,我查了下changelog也的确提到了这个问题,只是我使用的版本应该已经修复了这个问题。另外,我测试了其他的游戏,发现一切都很正常,如果真的是精度问题,不该只出现在这一款游戏上。查看了整个render过程后发现Tactics Ogre中有些地方与其他游戏做的不同,比如

注意纹理右侧的黑边

Tactics Ogre在draw顶部的滚动文本时,并不会对其裁剪,而是放到了第二个color pass里才进行裁剪

不过ppsspp模拟器提供了u_texelDelta这样一个uniform,我们可以利用它得知当前输入纹理的resolution:

vec2 c_resolution = 1.0f / u_texelDelta;

这样,即使在某些场景中,屏幕的分辨率发生变化,我们也能够保证显示正确的扫描线。

最终PSP模拟器效果图

在这里给出自己制作的在PSP模拟器上的最终效果,请放大后观察

结语

本文主要讨论了针对细像素游戏的画质增强,但是这并不意味着像素游戏的增强方式只有一种,相反,光是这里 就提到多种后期特效。我们也无法说哪种效果比另一种更好。更多的时候,还是需要根据对游戏的定位来定制自己的后期特效,从而让画面为游戏核心服务。程序和美术之间的沟通是否充分也是能否有效的构建出成功的游戏画面中很重要的一个因素。

最后,你们觉得这是一篇讨论像素游戏中画面增强的文章吗?
不,不是的,我只是在安利Tactics Ogre :P