米娜桑, 我又回来更新了…. 嗯, 年更博主的地位保住了…

其实我在准备一个更长~篇的更新, 千真万确, 不过目前可公开的情报还不多. 那么在我们仍未知道的更新到来之前, 这次, 难得正经地, 来简单聊聊 Pixel Art 的缩放及抗锯齿问题吧…

さて, 为什么会谈及这个问题? 我在处理像素艺术(Pixel Art)相关渲染的过程中, 遇到了这个问题. 具体可以描述为, 当像素风格纹理的精灵图(Sprite)在缩放或移动时, 会出现像素抖动与闪烁的现象, 看上去像是像素的波浪, 非常影响观感. 如图1左上角的例子.

图1

顺带一提, 精灵图上的角色是GBA游戏《公主联盟》中的大天使マリエッタ.

这个问题其实非常常见, 稍作检索就能找到原因与解决方法. 一个简单的规则是, 像素图的放大需要是原尺寸的整数倍, 否则会出现畸变(Distorted). 这种畸变是规律性的, 在放大或移动过程中就会出现有规律抖动和闪烁. 这里, 通常我们默认像素原图是比较小的, 所以在屏幕显示时主要是进行图像放大操作, 即Upscaling.

以上, 虽然是简单的理解, 但从某种角度说确实是最优解, 即不做任何的妥协, 让像素艺术分毫不损地显示到屏幕上吧! 在大部分时候, 上述方法并不会增加什么负担, 不过是限制游戏分辨率而已. 也可以参考 unity 官方的 2d-pixel-perfect: Pixel Perfect Camera项目的方案.

但还是让人不禁想问, 是否有什么办法能让 Pixel Art 突破分辨率的桎梏, 同时不丢失太多表现力呢? 说到这里, 我回忆起之前3DS模拟器上玩到的结合3D场景与像素角色的游戏, 也有类似的观感.

相比之下, 尝试解决突破这个限制的分享和讨论则不是很多. 不过, 好在以我的检索能力依然能找到一些, 同时看到方法演进的思路. 我觉得更重要的是, 这些分享的内容都丰富且详尽, 是相当好的参考. 只是这样, 我也会认为这些技术值得分享.

这里自然不必把别人发明的东西再发明一遍, 我会在下面列出这些参考. 如果你也在解决类似问题, 可以参考看看(保佑你不是先看过了下面的内容后才看到这里).

上面的顺序正好是这几个作者相互引用的顺序, 其中第五个参考是 t3ssel8r 对于第四个参考(其所制作视频)的进一步解释. 关于 t3ssel8r 大佬还想多说几句, 很早之前看到过 t3ssel8r 关于3D像素艺术渲染的 Demo, 效果非常惊艳, 竟然能把像素画面调教地这么舒服. 至今还能在油管上看到其他人对 t3ssel8r demo 的 recreation. 不仅如此, t3ssel8r 其他游戏开发相关的分享, 质量也是高的可怕. 只是最近一年里频道貌似不怎么活动了, 不知进展是否顺利.

那么, 这篇文章到这里就结束了… 即使不理解原理, 只拿参考中简短的 shader 代码就能够得到图1下边一行的结果, 肉眼可见, 效果是相当明显的. 对了, 可能唯一容易漏掉的是, 如果使用Unity, 记得把对应纹理的滤波模式(Filter Mode)从最近邻插值(Point)调整为双线性插值(Bilinear).
.
.
.
.
.
虽然就想这样结束, 但出于笔记的完备, 我依然想稍微深入地谈谈对于这个问题的理解, 这些理解大体也不会超出参考的内容, 只算作是一个注解罢了.

问题引入

让我们试着重新理解为什么像素 Sprite 在放大后(非整数倍放大), 会出现像素畸变.

要理解这个问题, 需要先理解像素(pixel), 纹素(texel)与纹理采样(Texture Sampling)的概念. 像素通常意义下指的是屏幕显示的最小单元, 我们知道显示器分辨率即是用显示多少像素定义的. 而纹素指的是纹理贴图(Texture)最小色块单元. 在 Pixel Art 的语境下, 纹理贴图也通常带有明显的风格化特点. 纹理采样即是把贴图显示到屏幕上的过程. 某个像素的屏幕坐标$(x, y)$, 通过纹理坐标映射转换到归一化的纹理坐标$(u, v)$, 最终采样到纹理的颜色并显示在像素上.

如此我们可以知道, 在一定条件下, 像素与纹素是可以相同大小的, 此时像素与纹素将一一对应, 每个像素显示一个纹素的颜色. 在使用 Pixel Art 的纹理贴图时, 大部分情况下, 都需要对贴图进行放大显示, 因为原始像素素材通常是很小的, 而现代显示器的分辨率又辣么大. 一旦进行贴图的放大操作, 可以想见, 此时纹素色块也被扩大了, 会出现多个像素对应同一个纹素的情况.

事实上在 Unity 中, 当 Sprite 放大时, 会使用设定的 Filter Mode 对纹理进行插值操作. 插值是为了让像素在纹理放大后依然采样到期望的颜色. 就像上面提到的, 即使此时多个像素对应同一个纹素, 在不同应用场景下(比如期望像素颜色平滑过渡), 这些像素也并非就期望取对应纹素的颜色, 而是交由插值方法来最终决定的. 可以说, 不同的插值方法定义了像素纹理采样时参考纹素颜色的方式.

特别地, 对于像素艺术风格的纹理, 通常 Filter Mode 会选择设置为 Point, 即最近邻插值(Nearest Neighbor Interpolation). 简单说, 就是直接使用距离最近纹素颜色作为采样像素的颜色. 这种插值方式不会造成像素的模糊, 保持了像素色块间颜色的锐利变化, 也即保持了所谓的”像素感”. 可以说是像素艺术纹理理想的插值方法.

唯一的问题是, 当放大倍数为非整数倍时, 会有部分落在插值参考纹素边缘的采样像素, 这些像素无论最终选择插值为边界哪一边的颜色, 都不可避免地让色块组成的形状产生畸变. 这里引用下 Cole 在 Scaling Pixel Art Without Destroying It 一文中的图, 非常直观地说明了这一点.

图2

从图2可以看到, 下边的纹理在放大$\frac{7}{3}$倍后, 采样后的像素色块的形状发生了扭曲. 但是又如上边的例子所示, 却可以无损地放大2倍. 观察一下最近邻插值的过程, 纹理放大后, 每个像素的颜色都被定义为距离最近的放大后原纹素的颜色.

解决思路

上述提到的参考, 核心的解决思路其实是相同的. 如果牺牲在所难免, 那就只能尽可能减少牺牲. 事实上, 我们对于最近邻插值在靠近参考的原纹素中心附近的插值结果还是很满意的. 而落在那些参考纹素边缘的, 模棱两可的像素采样点(如图2中被虚线横穿的那些采样点)择需要被进一步调教. 具体来说, 我们希望这些像素的颜色是作为边缘两侧颜色的过渡(平滑). 这样就避免在放大或移动过程中, 因为边界线的移动, 导致这些像素颜色的跳变. 也就是类似图3(依然引自 Cole 文章)中的效果.

图3

毫无疑问, 这会损失一些”像素感”, 但好在这些平滑只发生在边界处, 通常占像素数量的比例很少. 那么接下来的问题就剩两个了, 如何找到这些边界处的采样像素, 以及如何确定这些采样点的颜色. 当然, 一个足够好的解决方法, 最好能同时解决这两个问题.

在上面两个问题中, 相对容易确定的是后者. 对于采样点颜色的平滑, 依然可以借助插值. 作为应用最广泛的双线性插值(Bilinear Interpolation)正满足这种需求. 二维平面上, 在给定4个参考点的情况下, 双线性插值对于任意一个参考点范围内的采样点进行两个维度上共三次线性插值. 双线性插值得到的采样是平滑的, 因而非常不适合 Pixel Art 纹理, 而对于绝大部分其他类型的纹理来说, 则是兼具性能与效果的选择. 其也作为基础插值方法, 内置于各大引擎. 因为不算很复杂, 我在附录1中也简单解释了其插值原理及性质, 算是对SIFT特征提取算法理解与实现中提到的三线性插值做一个补充.

解决第一个问题的思路最早在Manual texture filtering for pixelated games in WebGL – Algorithmic Pensieve这篇博客中被分享. 其提出的解决问题的思路可用下图来说明,

图4

我们首先定义一个 tpp(texel per pixel) 值, 这个值描述了像素与纹素的大小比例. 如 tpp = $\frac{1}{2}$, 表示一个纹素有两个像素长或宽. 当纹理放大时, 纹素对于像素就越大, tpp 的值就会相应变小. 接着, 我们可以把纹理上的纹素视作紧密排布的方形色块, 如图4中较大正方形所示. 同时像素也可以视为方形色块. 采样时像素方块映射到纹理坐标下, 如图四中较小正方形所示. 图四中 tpp 为 $\frac{1}{2}$, 此时可见, 像素是纹素方块的$\frac{1}{4}$大小.

图四上下两图中可以看到, 经过纹理映射的采样像素在水平方向上穿越一个纹素的过程. 当采样像素的区域完全落在同一纹素区域内时(如紫色方块之间的部分), 像素应该采样该纹素的颜色, 即之前认为的像素离这个纹素中心足够近的情况. 而采样区域如果落在多个纹素之间(如绿色方块附近), 此时就需要进行平滑插值计算采样值. 可以想象, 当区域落在4个纹素的交界中心时, 此时插值效果最平滑, 即插值需要平均地参考4个覆盖纹素的颜色.

如果这个纹理插值方法直接使用双线性插值, 而非最近邻插值, 那么此时在纹素内部采样得到的颜色是已经经过线性插值平滑的(如图四紫色采样区域, 虽然区域完全在单纹素区域内, 但采样结果也是经过附近纹素颜色线性插值过的). 只有当采样区域落在纹素正中心时, 采样值才与纹素颜色完全一致(线性插值完全参考某个参考值的情况).

这篇文章中提出, 可以在纹理采样时手动调整纹理坐标$(u,v)$, 调整的结果是使得上图两紫色区域之间位置的纹理坐标的$u$修正为0.5, 即此时采样的是原本纹素颜色. 而其余位置的$u$则不做处理, 直接采样纹理平滑插值后的值. 同时在另一个维度$v$也进行同样的修正操作.

图5

上述的$(u,v)$坐标的修正可以用图5所示的分段函数实现. 注意, 图5中横坐标为$lu$, 含义为采样点(采样区域中心)落在纹素上的局部$u$坐标(如右侧图示). 当$lu=0.5$, $lv=0.5$时, 采样点将位于纹素的中心. 我们可以通过微调$(lu,lv)$间接调整纹理坐标$(u,v)$. 可以发现, 图5中从$lu$调整为$lu’$的映射是一个分段函数, $lv$的映射也是类似.

关注下这个映射函数的分段点$\alpha$. 这个$\alpha$的位置即图4中上方一图的紫色采样区域, 此时正处于采样平滑插值与采样纹素中心颜色的分界点. 如果我们定义像素采样区域的宽高为$[pw, ph]$, 纹素宽高为$[tw,th]$, 那么有,

可见这个$\alpha$与 tpp 的值有关, 也即与纹理放大倍数有关. 以上就是该方法的核心思路, 接下来是针对效果及实现上的优化, 在上述参考中也都有提及.

首先, 图5的分段映射函数存在两个问题. 其一是分段点的突变. 当采样点在边界采样经过临界点$\alpha$时, 该处的平滑插值与纹素颜色存在一个突变的色差. 为了有一个平滑过渡, 可以调整从0~$\alpha$的采样点分布, 如图6所示,

图6

调整后的映射使得平滑插值处采样点向远离原点的方向偏移, 从而消除了颜色的突变.

第二个问题是如何在 shader 中实现这个分段函数, 为避免使用条件逻辑, 文章 中使用两个 clamp 函数组合的形式来实现. 如图7中红色与蓝色两个 clamp 函数所示, 可知两个函数之和即为上述分段函数.

图7

这两个 clamp 和可以表达为,

实现

Cole 在 Scaling Pixel Art Without Destroying It 中将上述思路在 unity 中进行了实现, 其核心的 shader 代码如下,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
float tpp;

struct a2v
{
float4 vertex : POSITION;
float2 texcoord : TEXCOORD0;
};

struct v2f
{
float4 vertex : SV_POSITION;
float2 texcoord : TEXCOORD0;
};

v2f vert(a2v input)
{
v2f output;
output.vertex = UnityObjectToClipPos(input.vertex);
output.texcoord = input.texcoord * _MainTex_TexelSize.zw; // 乘以纹理尺寸
return output;
}

fixed4 frag(v2f input) : SV_Target
{
float2 luv = frac(input.texcoord);
float2 txOffset = clamp(luv / tpp, 0, 0.5) + clamp((luv - 1) / tpp + 0.5, 0, 0.5);
float2 uv = (floor(input.texcoord) + txOffset) * _MainTex_TexelSize.xy;
return tex2D(_MainTex, uv);
}

简化 clamp 计算

Cole 在评论中说明了关于 tpp 值的设定问题, 其做法是在相机的脚本中根据相机的 size 进行设置. 后续我们可以看到这个流程可以进一步优化. 不过在此之前, 我们先进一步简化上面的 clamp 计算.

上述讨论中, 我们默认把$(lu, lv)$设定为采样区域的中心. 为了简化, 我们也可以定义$(lu’’, lv’’)$设定为左上角, 用$lu’’$和$lv’’$来描述偏移. 此时上述分段映射函数可以调整为图8所示,

图8

其中,

此时映射函数可以用 saturate 函数来描述,

这里稍微再多解释下图8. 同样是为了保证平滑过渡, 当$lu’’$取1时, 需映射为1.5而不是1, 也即此时需采样右侧纹素的中心. 因为, 当采样点在这个临界值继续向右移动时, 将完全地进入右侧纹素.

由此, 片元着色器的代码可以进一步简化. 这也是 t3ssel8r 在视频 Crafting a Better Shader for Pixel Art Upscaling - YouTube 中所进行简化的由来.

1
2
3
4
5
6
7
8
fixed4 frag(v2f input) : SV_Target
{
float2 tx = input.texcoord - 0.5 * tpp;
float2 txOffset = saturate((frac(tx) + tpp - 1)) / tpp) + 0.5; // luv = frac(tx)
float2 uv = (floor(tx) + txOffset) * _MainTex_TexelSize.xy;

return tex2D(_MainTex, uv);
}

优化 tpp 变量

前面提到, tpp(texel per pixel) 变量描述了纹素与像素的大小关系. Cole 曾提到可以通过相机参数计算该值并传入 shader. 我们希望能更合理地设置这个值, 以便在任何缩放尺度下达到最佳的效果. 相关技术最早在 Superior texture filter for dynamic pixel art upscaling 中被提及, t3ssel8r 在其视频中做了更深入的阐释.

在比较少见的 Sprite 三维旋转的情况下, 前述方法会出现新的问题, 如图9所示,

图9

图9左侧为不做任何处理的效果, 最右侧为使用前述方法和固定 tpp 后的效果, 中间的在右侧基础上使用动态计算的 tpp. 从上图可以看到, 使用固定 tpp 值在旋转角度较大时, 依然会出现像素抖动.

原因可以理解, 发生旋转后, 纹理上的纹素不再与像素轴对齐, 此时上述讨论方法就会失效. t3ssel8r 在视频中非常直观地展示了这个过程, 并重新对这个问题进行了建模. 其核心思路是用最小轴对称包围盒替代像素经过纹理映射后被旋转的采样区域进行采样, 如图10所示.

图10

重新回到纹理映射, 该过程把像素坐标$(x, y)$映射为归一化纹理坐标$(u,v)$, 可以用矩阵乘法进行描述,

代入归一化后的像素方格四个顶点坐标$(0,0)$, $(0,1)$, $(1, 0)$和$(1, 1)$, 得到经过对应纹理坐标为$(0, 0)$, $(\frac{\partial u}{\partial y}, \frac{\partial v}{\partial y})$, $(\frac{\partial u}{\partial x}, \frac{\partial v}{\partial x})$和$(\frac{\partial u}{\partial x}+\frac{\partial u}{\partial y}, \frac{\partial v}{\partial x}+\frac{\partial v}{\partial y})$.

分别取四个坐标$u$, $v$两方向上的最大值与最小值相减, 就得到了包围盒的长与宽.

上式中的 w 与 h 即前面讨论中使用的 pw 与 ph. 其值可借助 fwidth 函数计算得到. 于是我们可以调整 shader 为,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
struct a2v
{
float4 vertex : POSITION;
float2 texcoord : TEXCOORD0;
};

struct v2f
{
float4 vertex : SV_POSITION;
float2 uv : TEXCOORD0;
};

v2f vert(a2v input)
{
v2f output;
output.vertex = UnityObjectToClipPos(input.vertex);
output.uv = input.texcoord;
return output;
}

fixed4 frag(v2f input) : SV_Target
{
float2 tpp = clamp(fwidth(input.uv) * _MainTex_TexelSize.zw, 1e-5, 1);
float2 tx = input.uv * _MainTex_TexelSize.zw - 0.5 * tpp;
float2 txOffset = saturate((frac(tx) + tpp - 1)) / tpp) + 0.5;
float2 uv = (floor(tx) + txOffset) * _MainTex_TexelSize.xy;

return tex2D(_MainTex, uv);
}

以上就是关于这个话题我想说明的所有内容. 前文提到的参考中(如 t3ssel8r 的视频)解决了更多关联问题, 这里就不再展开了. 最终的效果即图1左下所示, 而右下为 Cole 的效果, tpp 根据相机参数设置.

关于像素 Tilemap 中的抖动

上述方法应用于 Tilemap 中也可以很大改善 像素Tile 抖动问题, 但是如果仔细观察, 会发现 Tile 的边缘依然会出现抖动现象. 这是因为 Tile 的纹理是从 Tileset 纹理中截取的, Tile 与 Tile 的边界并非通过直接插值得到, 可以看到这些边缘依然是锐利的. 我目前没看到有比较好处理这个问题的办法, 不过通过适当增加相机移动速度倒可以一定程度掩盖这个问题. 关于这个问题如果有后续进展, 会继续更新到这里.

附录

1 关于双线性插值(Bilinear Interpolation)

让我们先从基础的一维线性插值开始. 线性插值简单来说, 是用直线连接两个参考点, 采样点居于参考点之间, 根据其与两个参考点距离的比例关系作为权重, 对两个参考值线性加权后计算得到采样值. 如下图,

其中, $x_0$与$x_1$为两个参考点, $x$为插值采样点, 目的是求得插值$f(x)$. 利用简单的相似三角形即可求得.

如果把 $x$ 归一化到 $[0, 1]$, $x’=\frac{x-x_0}{x_1-x_0}$则有,

线性插值中, $f(x_0)$与$f(x_1)$的权值即$x$分别到$x_0$与到$x_1$距离的反比. 也就是距离越远的参考点, 其权重就越小, 非常符合插值的直觉.

要把线性插值应用到二维平面会稍微麻烦些, 不过这些麻烦只是形式上的. 具体来说, 可以分维度依次进行线性插值, 如下图,

A, B, C, D为二维平面四个参考点, 其构成的四边形为轴对齐矩形. 先选择一个方向进行两次线性插值. 这里选择x还是y轴方向不影响最终结果, 如上图为先进行x轴方向插值, 得到$f(x, y_0)$与$f(x,y_1)$. 直接套用线性插值的结果有,

接着进行y方向线性插值,

代入前面的插值结果, 化简得,

若对x, y进行归一化, 令$x’=\frac{x-x_0}{x_1-x_0}$, $y’=\frac{y-y_0}{y_1-y_0}$,

与线性插值时相同, 这里各参考值线性加权的权重比例按面积划分. 采样点越靠近参考点, 此时对角的参考点与采样点围成的面积越大, 即权重越大. 各面积区域作为权重与对应参考点关系如下图.

同样原理, 可以类推三线性插值(Trilinear interpolation), 其分别在三个维度方向上依次进行线性插值. 并且也具有体积比权重性质.
.
.
.
.
.
.
.
終わり