[译] 了解纹理 Filter | Know Your Texture Filters!

原文链接:Know Your Texture Filters!

在论坛上,Gamefish 和 BurningHand 担心 TextureAtlas 的糟糕性能。他们在 G1/G2/Hero 级别的设备上做了一些基准测试,惊讶地发现使用 TextureAtlas 时只能达到6fps,但直接使用 Texture 时可以达到完美的60fps。Nate 和我坚持认为 TextureAtlasTexture 的渲染路径没有任何区别。事实上他们确实一模一样,遗憾的是一小时前我才找出问题所在,现在来分析一下我的发现。

要搞清楚发生了什么,我们需要两个东西:一个测试用例,以及关于纹理如何工作的知识储备。现在我们从前者入手。Gamefish 为我们提供了一个 TextureAtlas 文件和一个不错的测试用例。以下是原始代码:

public class AtlasIssueTest extends GdxTest {
    SpriteBatch batch;
    Sprite sprite;
    TextureAtlas atlas;
    BitmapFont font;

    public void create () {
        batch = new SpriteBatch();
        atlas = new TextureAtlas(Gdx.files.internal("data/issue_pack"), Gdx.files.internal("data/"));
        sprite  = atlas.getSprite("map");
        font = new BitmapFont(Gdx.files.internal("data/font.fnt"), Gdx.files.internal("data/font.png"), false);
        Gdx.gl.glClearColor(0, 1, 0, 1);
    }

    public void render () {
        Gdx.gl.glClear(GL10.GL_COLOR_BUFFER_BIT);
        batch.begin();
        sprite.draw(batch);
        font.draw(batch, "fps:"+Gdx.graphics.getFramesPerSecond(), 26, 65);
        batch.end();
    }

    public boolean needsGL20 () {
        return false;
    }
}

非常直截了当,它只是创建了一个用于渲染的 SpriteBatch,加载了一个 TextureAtlas 并基于它当中的一张图片创建一个精灵。字体用于显示当前的帧率。render() 方法也非常普通,只是清空了屏幕,告诉 SpriteBatch 我们想要渲染的东西,然后执行它。

下一个问题:精灵的大小是多少,以及这个 texture atlas 看起来是什么样子?这就是他的纹理页面:

`

一些精美的像素素材(对,底部有较大的空白)。这里是 pack 文件的描述部分:

resource1.png
format: RGBA8888
filter: Linear,Linear
repeat: none

map
rotate: false
xy: 0, 0
size: 855, 480
orig: 855, 480
offset: 0, 0
index: -1

jeep
rotate: false
xy: 855, 0
size: 139, 97
orig: 140, 97
offset: 0, 0
index: -1

npc
rotate: false
xy: 0, 480
size: 287, 76
orig: 288, 78
offset: 0, 1
index: 91

boo
rotate: false
xy: 994, 0
size: 24, 35
orig: 24, 35
offset: 0, 0
index: -1

我们把 map 区域加载到精灵实例中,也就是说我们的精灵实际上对应了那张背景图片。因为我们没有指定精灵的大小,所以它就采用纹理区域的像素大小,也就是 855×480。注意,精灵的尺寸与位置的单位不一定是像素。我们把精灵宽高设置为 4×2,它依然对应一个 855×480 像素大小的纹理区域。

现在,报告说这个性能问题出现在 480×320 G1 等级的设备上。幸运的是,我的 Hero 正好具有相同的配置(MSM7500, 480×320)。因此,当我们渲染上面代码中定义的精灵时,我们实际上只看到背景图左下角的 480×320 像素。SpriteBatch 将设置正交投影以便其视图在我们的世界中从(0,0) 跨越到 (Gdx.raphics.getWidth(),Gdx.raphics.getHeight())。因此我们在 Hero 上可以看到 (0,0)-(480,320),在 Nexus One 上可以看到 (0,0)-(800,480),在 Droid 上我们可以看到 (0,0)-(855,480) 以此类推。Hero 上显示的看起来像这样:

Omg,只有6fps!libgdx 糟透了!TextureAtlas 糟透了!可真的是这样吗?

让我们想一想这里发生了什么。我们有一个完美像素的投影,这意味着每一个纹理元(纹理中的像素)正好对应屏幕中的一个像素。何况我们没有在屏幕上看到整个图像,所以这应该更快才对。Hero 只需要扫描 480×320 像素,然后直接把它画在屏幕上就好了。傻英雄!(谐音梗

然而没有那么简单。回看一下原始的纹理,会发现它其实是 1024×1024 大小。尽快 Hero 只需要左下角的 480×320 像素,程序实际上有更多的工作得做。纹理元是线性地存在内存中,纹理元 (0,0) ~ (479,0) 在内存地址 0~479,但是 (0,1) 就在 1024 地址!(译者注:480~1023 地址保存着第一行剩余的空白像素)当 GPU 尝试获取 (480,0) 的下一个纹理元时,它必须跳过544个纹理元(当然不是字面上跳过,想象一下这种行为)。这会扰乱缓存机制严重影响性能。我们可以让背景图本身具有更小的纹理来解决这个问题。然而背景图大小 (855×480) 迫使我们必须创建一个 1024×512 的纹理,因为我们需要2的整数次幂像素大小。所以这不是可行的方案。

还有第二个更微妙的问题更严重地影响了性能。我刚才说 GPU 只需要获取 480×320 的纹理元因为这正是在 Hero 的屏幕上可以看到的数量。看看这篇文章的标题,然后再看看 pack 文件定义,你能发现问题所在吗?

filter: Linear,Linear

这就是关键。这行代码指定了缩小与放大纹理所使用的 filter,这是 OpenGL 里的东西,这么工作:

  • 如果我们绘制一个三角形/四边形/精灵,并且像素与纹理的比率大于1,则纹理会被放大。
    例如你有一个 16×16 的纹理,而四边形在屏幕上占据了 32×32 像素 -> bam,放大
  • 如果我们绘制一个三角形/四边形/精灵,并且像素与纹理的比率小于1,则纹理会被缩小。
    例如你有一个 32×32 的纹理,而四边形在屏幕上占据了 16×16 像素 -> bam,缩小

一个 filter 用于缩小,一个 filter 用于放大。在 pack 文件中第一个用于缩小,第二个用于放大。那么 linear 指什么?这里我们有两大类选项:

  • GL_NEAREST:获取最近接近的纹理元。
  • GL_LINEAR :获取若干最接近的纹理元,进行加权计算。

你可能好奇为什么这会有影响,因为我们实际上像素与纹理元是1:1对应的。但是,现在 OpenGL 会使用放大 filter。对,这很傻,但就是这样。因此,即使我们渲染的是完美像素,OpenGL 也会在每个纹理元上提取若干个计算,使全屏渲染所需的内存带宽翻了两番。

让我们更改 pack 文件中的定义:

filter: Linear, Nearest

下面是将放大 filter 设置为 Nearest后的输出,(在完美像素渲染的情况下,每像素提取1纹理元):

Omg,libgdx 太棒了!TextureAtlas 太棒了!现在我们实际上只采样了480×320像素,而不是原来的4倍。我们甚至启用了混合(SpriteBatch自动启用)。太好了,工作了37个小时,终于可以上床睡觉了。可事实真的如此吗?

并不。你看,我非常确定这个测试的原作者除了 855×480 的设备,也想在 Hero 上显示完整的背景。为此需要告诉 SpriteBatch 我们想看到 855×480 单位(它会映射到背景纹理元),我们只需要在创建 SpriteBatch 后添加几行代码:

public void create () {
    batch = new SpriteBatch();
    batch.setProjectionMatrix(new Matrix4().setToOrtho2D(0, 0, 855, 480));
  // .... same as before ....

这将让 SpriteBatch 显示 (0,0)-(855,480) 的范围,也就是说我们将看到完整的背景,而不是左下角的一部分,即便是在分辨率比较小的 Hero 上。让我们检查下输出:

从中我们学到了两件事:OMG libgdx 又变得糟透了!以及,缩放字体是个糟糕的主意,永远都是。字体一定到像素完美渲染,或者使用非常大的字体,否则事情会一团糟。稍微眯一下眼睛,我们可以看到又回到了6fps。我们再次采样了 480x320x4 纹理元,因为我们缩小 filter 依然是 linear(我们把。855×480 纹理映射到 480×320 像素 -> 缩小)。所以同样地更改 filter:

filter: Nearest,Nearest

这样我们就回到了60fps:

超棒!然而,当你看看平坦起伏的山丘时,你会发现它们不再那么平坦了。我们看到丑陋的边缘和台阶,GL_LINEAR 可以平滑它们,GL_NEAREST 则完美展现它们。对于一些游戏来说,这种像素化的外观完全可以接受,那么这个解决方案也没问题。对于其他游戏,我们还是希望有平滑的效果。但是我们不能使用 GL_LINEAR,因为它严重影响性能对吧。嗯,我们也可以将纹理缩减到更适合 480×320 屏幕的大小,比如降到512×512。PGU 将不再需要采样那么多像素,可以继续使用 GL_LINEAR。但这意味着我们必须为不同分辨率的设备分别提供素材。很难受是吧?

对,这个就是我们使用 mip 映射的原因。什么是 mip 映射?让我展示一张漂亮的小图片来解释我们1024×1024 源图像的所谓MIP映射链:

发生了什么?正如你所见,我将原始图像下采样到其大小的四分之一(1024×1024到512×512)并将其应用于链中的所有图像,直到达到1×1。这构成了我们的 MIP 映射链(有时也称为金字塔,取决于其呈现方式)。

“好的兄弟,这但有什么用?我的意思是,我当然可以在运行时生成这些图像,但它仍然需要知道在什么设备上使用什么图像,对吗?” 不,OpenGL 在这方面的支持性很好。它通过一种称为 MIP 映射的机制来实现这一点。它的工作原理是这样的:

确定像素与纹理像素的比率,并从 MIP 链中选择最佳图像并从中提取纹理元。
determine the pixel to texel ratio and select the best fitting image from the mip map chain to fetch the texels from.

就是这样 🙂 我们所要做的就是不仅向 OpenGL 发送原始图像数据,还包括 MIP 链中的其他图像。当你在 Graphics.newTexture()/newUnmanagedTexture() 中指定TextureFilter.MipMapXXX 枚举值作为缩小 filter 时,libgdx 会自动执行此操作。然后,如果我们将缩小 filyer 设置为 mip map filter,OpenGL 将自动选用适合当前像素与纹理比例的一个。是的,这类 filter 不止一个。

  • GL_NEAREST_MIPMAP_NEAREST:这将基于像素/纹理比率从 mip 链中选取最佳图像,然后使用NEAREST filter 进行采样。
  • GL_LINEAR_MIPMAP_NEAREST:这将基于像素/纹理比率从 mip 链中选取最佳图像,然后使用LINEAR filter 进行采样。是的,不是你最后指定的 filter 类型,而采用首先指定的。
  • GL_NEAREST_MIPMAP_LINEAR:这将基于像素/纹理比率从 mip 链中选取两个最佳图像,然后使用NEAREST filter 分别进行采样,然后组合成最终结果。
  • GL_LINEAR_MIPMAP_LINEAR:这将基于像素/纹理比率从 mip 链中选取两个最佳图像,然后使用LINEAR filter 分别进行采样,然后组合成最终结果。

如你所见,这个列表是从最简单到最花哨排列的。我的建议是:使用GL_LINEAR_MIPMAP_NEAREST,它在当前(2010.11.23)我所知的所有硬件上都提供了最好的结果。对应的 TextureFilterTextureFilter.MipMapLinearNeadest。让我们在 pack 文件中指定它,并查看效果:

filter: MipMapLinearNearest,Nearest

酷!这样在 Hero 设备上它会使用 mip 链中 512×512 的图像,也意味着背景图是 423×240 像素。这比 Hero 的 480×320 小一点,所以实际上它被放大了一点(来使用)。取决于素材,有时候这可能不理想。

如果需要在所有屏幕分辨率上完美呈现,那么除了专门为每个分辨率设计素材,没有其他办法了。在几乎所有的情况下都可以使用高分辨率的纹理,并在 Hero 等设备上缩小它的比例,如果你想要平滑一点,也可以选择使用 mipmap。

发表评论

邮箱地址不会被公开。 必填项已用*标注