最初发表于 colececil.io 。
当我开始在游戏开发中使用像素画法时,我认为它可以在任何屏幕分辨率下轻松工作,因为现代的屏幕分辨率要比像素画法游戏的原始分辨率高得多。 但是,我很快就意识到并非如此-在将像素艺术按任意比例放大时,要使其看起来正确是非常困难的。 按整数倍(2x,3x等)进行缩放时,它可以正常工作,但是按非整数倍进行缩放时会出现问题。 这会引起问题,因为纹理像素(换句话说,艺术品中的像素,也称为texels )被缩放为屏幕上的小数像素。 由于屏幕无法显示小数像素,因此渲染必须四舍五入到最接近的整个像素,或者必须将不同的纹理像素混合到同一屏幕像素中。 选择两种标准纹理缩放模式之一将导致像素画中的某些像素大于其他像素,或者使它们全部变得模糊。 如下面的示例所示,这两个选项看起来都不是很好。
- 在Steam发行前在Itch.Io上对游戏进行软启动的好处
- 爱上Lua Gujarat的第一个Lua + Love2d工作坊
- PWiC和全球游戏Jam'19
- 开发人员日志#0.0.0.1 — ETHAN CONRAD
- 为射击者设计一个简单的地图
这导致我进行了大量的搜索,试图找到解决问题的方法。 我发现的大多数资源都声称,如果要使其看起来不错,就必须坚持以整数倍进行缩放,但是我知道我玩过很多像素艺术游戏,它们可以缩放到我的屏幕尺寸并且看起来还不错。 因此,我一直在搜索,最后我找到了一个使用着色器的出色解决方案(在上面的示例图像中为“ Good”版本),该着色器在一个名为A Personal Wonderland的博客中进行了介绍。 作者以非常数学的方式很好地解释和说明了该解决方案。 我仍然花了一段时间才了解它是如何工作的,但是我发现了它并能够在Unity着色器中实现它。 由于很难找到有关如何解决此问题的信息,因此我决定编写此教程来缩放像素艺术,以一种对我的大脑(并希望其他人)更有意义的方式进行解释,并给出一个示例Unity中的解决方案。
像素艺术缩放着色器是两种标准缩放方法之间的混合体:最近邻滤波和双线性滤波。 由于完全了解着色器的工作方式取决于了解这两种过滤方法,因此我将首先花一些时间来解释它们。
最近邻居过滤是缩放图像的最简单方法。 使用这种方法,您基本上只是从纹理中获取像素,然后将它们放大以形成缩放图像。 这是通过为缩放后的图像中的像素提供与最接近的纹理像素相同的颜色来实现的。
下图说明了执行最近邻居过滤的算法。 首先,获取纹理,以每个纹理像素中心的点表示。 然后将其拉伸到最终缩放图像的大小并覆盖在其上。 缩放图像中的每个像素也由其中心的一个点表示。 接下来,对于缩放图像中的每个点,找到纹理中最接近的点。 缩放图像点表示的像素与纹理点表示的纹理像素具有相同的颜色。
在上图中,缩放后的图像完美地呈现出来,因为它是纹理的整数倍(在这种情况下为2x,因为纹理为3×3,缩放后的图像为6×6)。 但是,如果缩放后的图像不是纹理的整数倍,则最终的结果将不太正确。 有关此示例,请参见下图。 此处,纹理仍为3×3,但缩放后的图像为7×7。 在缩放的图像中,您可以看到某些纹理像素以不同的大小和形状显示,因为它比纹理的整数倍(2.333x)稍大。
使用双线性过滤,而不是使纹理像素更大以形成缩放图像,而是在它们之间的空间中混合纹理像素的颜色。 术语双线性是指在x和y方向上的颜色混合。
与最近邻居滤波一样,双线性滤波算法通过以下步骤开始:获取代表纹理像素的点集,将其拉伸到缩放图像的大小,然后将其覆盖到代表缩放图像中像素的点集上。 然后,对于缩放图像中的每个点,找到四个周围的纹理像素点。 首先在一个方向(x或y方向)上对两对texel点的颜色进行插值,以便使两个新点与所查看像素的点共线。 最后,通过在像素点的位置内插两个新点的颜色来确定像素的颜色。 请参见下图以对此进行说明。
这里要注意的另一件事是,边缘周围的某些像素可能不会被四个纹理像素围绕。 通常处理此问题的方法是通过创建超出纹理边缘的虚构纹理,并使用与边缘纹理相同的颜色。
在这里,我将3×3相同的纹理缩放为6×6,就像使用最近邻居过滤一样。 但是,如您所见,双线性过滤会产生非常模糊的结果。 显然,这对于像素艺术来说不是很好。 但是,双线性滤波有一个优势,如下图所示(将3×3纹理缩放为7×7,就像我对最近邻滤波所做的那样)。 即使纹理没有缩放到整数倍,它仍然看起来均匀,而不是使纹理像素以不同的大小和形状显示。
现在,我们已经研究了最近邻居和双线性过滤的工作原理,我们可以继续进行像素艺术缩放着色器。 正如我之前提到的,此着色器通过组合最近邻和双线性过滤来工作。 在大多数情况下,着色器使用最近邻居过滤,因为这最适合像素图。 但是,在纹素之间的边界处,使用了双线性滤波。 这是因为,如果屏幕像素位于这些边界之一处,它将包含一个以上纹理像素的部分。 请记住,如果要使用最近的邻居,则该像素将只能选择纹理像素之一的颜色,从而使像素图看起来失真。 在边界处使用双线性过滤可混合共享同一像素的纹理像素的颜色,使其看起来更加自然。 这不会像纯双线性滤波那样引起明显的模糊,因为我们仅在这些边界使用它。 下图显示了一个示例。
现在,我已经解释了着色器在概念上的工作原理,我将遍历代码。 请注意,我显示的代码是针对Unity的,但是将其转换为可与其他游戏引擎或图形库一起使用的代码应该相对简单。
我将尽力清楚地解释代码,但是如果您没有使用着色器的经验,那么最好阅读一下着色器。 当我学习如何编写着色器(特别是在Unity中)时,我发现这本Cg编程Wikibook是非常有用的资源。 Unity在其文档中也提供了一些有用的示例。
变量和结构
首先,这里是为着色器定义的变量和structs
:
_MainTex
是要绘制的纹理,该纹理通过Properties
块传递到着色器中。 _MainTex_TexelSize
表示主纹理的像素大小。 如此处所述,这是Unity设置的预定义属性。 texelsPerPixel
是我们将使用Shader.SetGlobalFloat从Unity脚本中设置的值。 可以从名称中看出,该变量表示每个屏幕像素的纹理像素数。 我们可以通过将屏幕的宽度/高度除以游戏原始分辨率的宽度/高度来计算该值。 如果屏幕的长宽比和游戏的原始分辨率相同,则可以使用宽度或高度,但是如果长宽比不同,则需要选择不带有字母框或立柱框的尺寸。
此处定义的两个structs
代表顶点着色器的输入和输出。 vertexInput
包含顶点位置(在局部空间中),顶点颜色(来自Unity中设置的材质颜色)和纹理坐标(代表与顶点匹配的纹理的x和y位置)。 vertexOutput
包含顶点位置(在剪辑空间中),顶点颜色和纹理坐标。
顶点着色器
继续,让我们讨论顶点着色器的代码。 对于着色器正在渲染的对象的每个顶点执行顶点着色器。 在这里,我们只是以片段着色器所需的格式获取数据。
注意:矢量着色器和片段着色器的代码基于 A Personal Wonderland的 逻辑 。
在第4行中,我们将顶点位置乘以模型视图投影矩阵,将顶点位置从局部空间转换为裁剪空间。 这是在顶点着色器中执行的相当标准的操作。 在第5行中,我们将纹理坐标从范围[0,1]转换为范围[0,纹理大小]。 我们这样做是因为,在片段着色器中,我们需要根据纹理像素位置知道纹理坐标。 第6行只是将输入顶点颜色传递到输出。
片段着色器
现在到片段着色器,对每个像素执行该着色器以计算其颜色。 这是着色器的主要逻辑所在。
这是我们在此函数中实现的逻辑的简化版本:
- 对于当前屏幕像素,找到其相对于最近的纹理像素的位置(第3行)。
- 使用此信息,确定需要多少(如果有的话)在最接近的纹理像素的颜色之间进行插值(第4-5行)。 这是算法决定对当前像素使用最近邻滤波还是双线性滤波的地方。 没有插值意味着它正在使用最近邻居过滤。 插值意味着它正在使用双线性滤波。
- 给定内插量,计算并返回像素的颜色(第6-8行)。
在深入探讨细节之前,我想谈谈如何对颜色值进行插值。 实际上,我们将在Unity中设置纹理以使用双线性过滤。 这将导致图形卡根据指定给它的纹理坐标给出双线性插值颜色值,我们将使用着色器通过稍微更改纹理坐标来选择所需的插值颜色值。 如果我们不想为像素插值颜色,则只需选择纹理像素中心的纹理坐标即可。 否则,我们将根据所需的插值量选择纹理坐标。
好的,既然我已经概述了一般逻辑,那么让我们更详细些。 第3行获取纹理坐标的小数部分。 请记住,在顶点着色器中,我们将纹理坐标的范围从[0,1]转换为[0,texture size],因此此处的纹理坐标以纹理像素为单位。 因此,在获取纹理坐标的小数部分时,我们将获取屏幕像素所在位置的texel分数。 例如,如果纹理坐标为(34.4,98.7),则将值(.4,.7)分配给locationWithinTexel
。 值(.5,.5)表示屏幕像素的中心直接位于纹理像素的中心。
在第4-5行中,我们使用locationWithinTexel
值和texelsPerPixel
值来确定对此像素使用多少插值。 在大多数情况下,我们最终选择的插值量为(.5,.5),它不表示插值,因为它位于纹理像素的中心。 但是,如果屏幕像素位于部分位于不同纹理像素中的位置,则需要进行一些插值。 插值量(0,0),(0,1),(1、0)或(1,1)将表示最大插值量,因为这些点位于纹理像素之间的角上。 第一个clamp
函数处理位于纹素像素一侧的像素,第二个clamp
函数处理位于纹素像素另一侧的像素。 例如,假设每个像素的比例尺为2个屏幕像素,则texelsPerPixel
值为0.5。 如果locationWithinTexel
的x值为0,则应该在x轴上具有最大的插值量,因为恰好一半的像素位于一个texel上,而另一半则位于另一个texel上。 另一方面,如果locationWithinTexel
的x值为.25(等于texelsPerPixel
一半),则像素的左边缘将与texel的左边缘对齐。 这意味着在x轴上不应进行插值,因为像素水平完全包含在纹理像素中。 如您所见,第一个clamp
函数旨在在locationWithinTexel
为0时给定值0,在locationWithinTexel
至少为texelsPerPixel
一半时给定值0.5。 介于两者之间的任何值都插在0到.5之间。 第二个clamp
功能也针对纹理像素的另一侧进行了类似设计。 如果您想查看此公式的更多详细信息(和图表),请参阅A Personal Wonderland上的博客文章。
在第6-7行中,我们正在计算从图形卡中检索双线性过滤后的颜色的纹理坐标(请记住,这是因为我们在Unity中将纹理设置为使用双线性过滤)。 首先,我们采用输入纹理坐标的非小数部分。 如果要在此位置对纹理进行采样,我们将获得一种纹理,其纹理像素之间的插值最大。 接下来,我们添加在第4-5行中计算出的interpolationAmount
值。 如果轴上的interpolationAmount
为.5,则不会在该轴上插入颜色。 如果它是0或1,则它将具有最大插值,0在纹理像素的一侧,而1在另一侧。 这些值之间的任何值都会导致在最大值和最小值之间进行内插。 此时,我们的纹理坐标仍在[0,纹理大小]范围内,但是我们需要先使它们回到[0,1]范围内,然后才能使用它们来获取颜色。 为此,我们只需除以纹理的宽度和高度,即_MainTex_TexelSize.zw
。
最后,在第8行,我们在计算出的纹理坐标处对纹理进行采样,以获得所需的颜色值。 我们还将其乘以输入颜色,这将具有在Unity中设置的材质颜色为输出着色的效果。 如果将材质颜色设置为白色,则该效果将无效,因为它将乘以1。
完整的着色器代码
好吧,这总结了像素艺术缩放着色器的说明! 那是很多信息,但我希望您能走到这一步,并了解它们的工作原理。 作为参考,下面是完整的着色器代码:
最初发表于 colececil.io 。