关于图片采样

前言

        在游戏开发中,我们经常会对图片进行采样。比如物体的纹理信息,阴影贴图的信息等等。我们都需要对图片进行采样,以丰富游戏的表现。

        我们时常会遇到这样的一个问题,我们需要将一个图片渲染到屏幕上,而图片所覆盖的屏幕像素远大于图片自身的像素。这时候我们就需要对图片进行放大处理。又或是反过来,我们需要对图片进行缩小处理。这些都会涉及到如何对图像本身进行采样。

图像采样的方法和规则

        我们假设现在我们有一张256x256的图片。我们需要分别将其渲染成64x64和1024x1024的图片。对于64x64的处理,你或许会觉得很简单,因为256刚好是64的4倍,我们直接按照他们的比例进行像素的选取就好了。但是对于1024x1024的处理,你是否有想法呢?如果我需要的是100x100的图片,那么你又该如何处理呢?

        无论是放大(上采样)还是缩小(下采样),我们都可以去做一个映射操作。比如我想知道64x64中坐标为(31,31)(这里我依照程序里面下标从0开始计算)的像素点是什么颜色。我先计算出此坐标在原图中的比例,显然它这里xy比例是(0.5,0.5)。然后我们在用这个计算出256x256图中对应的像素点坐标为(127,127)。那么(31,31)对应的颜色就可以是(127,127)位置的颜色。同理1024x1024的图片也可以这么操作。

        这时候,你或许会问,我这边定的坐标过于刚好。如果是在1024x1024的图片中找(1,1),对应的颜色呢?同样用上面的方法,我们可以计算出(1,1),在256x256图中对应的坐标为(0.5,0.5)。但是我们知道像素点的坐标都是正整数。这(0.5,0.5)根本没有对应的像素点。所以,我们需要对这样的坐标进行一些处理。最简单的方法是最近邻插值(Nearest Neighbor Interpolation)。所谓的最近邻插值就是我们判断计算得到的小数部分,如果小于0.5,我们就向下取值,大于则向上取值。而0.5这样的分界位置视具体的需求而定,我后面实现的方法是直接算向下取值。当然你也可以按照你需求更改分界值。核心代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
private Color NearestNeighborInterpolation(Texture2D texture2D,Vector2 pos)
{
// 下标从0开始计算
pos.x *= texture2D.width - 1;
pos.y *= texture2D.height - 1;
int x = Mathf.FloorToInt(pos.x);
int y = Mathf.FloorToInt(pos.y);
if (pos.x - x > 0.5f)
x++;
if(pos.y - y > 0.5f)
y++;
Debug.Log(texture2D.GetPixel(x, y));
return texture2D.GetPixel(x, y);
}

        项目中更多的情况是使用双线性插值法(Bilinear Interpolation)。双线性插值法则是考虑到周围的四个像素点,计算出一个值。我们还是以在256*256图中对应的坐标为(0.5,0.5)为例子,我们对其x和y值分别向上和向下取值。则我们可以得到这四个坐标点:(0,0),(0,1),(1,0),(1,1)。并且分别得到这四个点对应的颜色值。接下来我们先对y值相同坐标点对应的颜值进行插值计算,插入的值为我们想要(0.5,0.5)坐标的x值的小数部分。则四个颜色就变成了两个颜色,最后我们再将这两个以我们想要(0.5,0.5)坐标的y值的小数部分进行插值计算得到我们最终的颜色值。核心代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
private Color BilinearInterpolation(Texture2D texture2D, Vector2 pos)
{
pos.x *= texture2D.width - 1;
pos.y *= texture2D.height - 1;
int x1 = Mathf.FloorToInt(pos.x);
int y1 = Mathf.FloorToInt(pos.y);
int x2 = Mathf.CeilToInt(pos.x);
int y2 = Mathf.CeilToInt(pos.y);
var color1 = texture2D.GetPixel(x1, y1);
var color2 = texture2D.GetPixel(x1, y2);
var color3 = texture2D.GetPixel(x2, y1);
var color4 = texture2D.GetPixel(x2, y2);
pos.x -= x1;
pos.y -= y1;
color1 = Color.Lerp(color1, color3, pos.x);
color2 = Color.Lerp(color2, color4, pos.x);
return Color.Lerp(color1,color2,pos.y);
}

上面所述的是双线性插值法的一种实现方法,实际上还有其他的做法,但是原理都是一致的。在图片进行缩小的时候,这两种方法的区别并不会很大,但是在图片进行放大的时候,最近邻插值会出现锯齿状的边缘,而双线性插值法的图片看起来会糊一些(有些人觉得会更细腻一些,但是我就觉得是糊了)。所以,在具体的项目中,我们需要根据需求选择合适的采样方法。实际上还存在一个三线性插值法,但是这个方法要结合mipmap来实现,这里就不再展开了。大多数情况下,项目都是使用双线性插值,这个效果已经足够了。Unity本身也直接提供了相关的API,我们可以直接调用。上面的代码并不是unity内部的实现,而是我自己写的,所以在表现上和数值传递上所有区别。具体请看官方的文档Texture2D.GetPixelBilinear

        在我们进行图片缩小的时候,最近邻插值过于暴力而双线性插值法覆盖的区域有所限制,当我们缩小的程度到达一定数值时,这两个方法都会照成非常严重的锯齿。我们仍然以256x256图为例子,现在我们要将其缩小到32x32。此时32x32其中的一个像素代表着256x256中一个8x8范围的像素。当我们使用最近邻插值,则我们只会在这8x8范围内选择一个像素来表示,而因为此时计算出来的小数点是不存在小数的(256被32整除),所以双线性插值法就退化为最近邻插值了。一个8x8范围的像素集合,能被一个像素代表吗?显然不能。所以,当我们做缩小的时候,这样两个方法就存在了一定的问题。一般而言,我们要选出一个范围内区域的代表,我们就是将这个范围内的像素进行累加后平均。我们以这个平均值来作为这个范围内的像素的颜色。当然这是最简单的做法,实际上还有其他的方法,这就看你们的需求了。当然这个区域也不一定是正方形,所以我们还需要考虑到区域的大小。核心代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
private Color AvgAreaColor(Texture2D texture2D,Vector2 pos,Vector2Int area)
{
pos.x *= texture2D.width;
pos.y *= texture2D.height;
int x = Mathf.FloorToInt(pos.x) - area.x;
int y = Mathf.FloorToInt(pos.y) - area.y;
int count = 0;
Vector4 sum = Vector4.zero;
for(int i = Mathf.Max(0, -x);i < area.x; ++i)
{
int curX = x + i;
for (int j = Mathf.Max(0, -y); j < area.y; ++j)
{
int curY = y + j;
var color = texture2D.GetPixel(curX, curY);
sum += new Vector4(color.r, color.g, color.b, color.a);
count++;
}
}
sum /= count;
return new Color(sum.x, sum.y, sum.z, sum.w);
}

在实际项目中,大多数情况下我们都很难得到这么正好的比例。我们时常会得到一些小数。其实我们可以忽略这个小数值。比如我们要得到一个30x30的图片。\(256/30 \approx 8.5333\),我们直接取8,得到\(256/8=32\)。那我们先得到这32x32的图片,然后再使用线性插值或是最近邻插值来得到最终30x30的图片。

闲言碎语

        我突然想写一篇关于图片采样的文章,然后我写着写着发现我也没啥可写的。网上也有很多类似的文章,我这篇就像是水文。sorry。