走进纹理科学,看穿底层逻辑,耗时的背后竟然是......

来源:Unity官方平台


像素是纹理最基本的组成单位,而 Unity 有多种用 C# 脚本读写像素数据的方法。这样就可以实现复制、更新纹理等多种功能,比如给玩家的头像加上装饰、读取地图的纹理来决定物体的摆放位置等。

像素数据的读写需要根据数据处理方式和项目性能要求来选择最佳的方法。这篇文章及配套的示例项目旨在帮助开发者熟悉现有的 API 和常见的纹理相关性能优化方式。


CPU 与 GPU 的像素数据存储

对于大部分纹理,Unity 会保存两份像素数据的副本:一份在 GPU 内存里,是渲染所需的数据;另一份则在 CPU 内存中,属于可选数据,用于读取、写入和控制 CPU 上的像素数据。在 CPU 内存里存有像素数据副本的纹理被称为可读纹理。需要注意的是 RenderTexture 仅存于 GPU 内存中。

大部分硬件的 CPU 内存都不同于 GPU 内存。当然现在有些设备具有部分共享内存的形式,但本文只讨论传统的 PC 配置。 CPU 只能直接访问插在主板上的 RAM,GPU 只能依赖于 VRAM,两个环境间任何数据传输都需要经过 PCI 总线,速度会比同一内存里的相互传输慢很多,所以会对每帧传输的数据量造成限制。

走进纹理科学,看穿底层逻辑
CPU 和 GPU 的关系,以及纹理相关的 API

在着色器里采集纹理样本是最常见的 GPU 像素数据处理操作。要想修改这段数据,可以复制修改后的纹理,或用着色器把修改渲染到一张纹理上。

有些情况下,在 CPU 上调控纹理数据更合适,访问数据的方式会更灵活。CPU 的处理操作只会作用于 CPU 上的副本,并且纹理必须是可读的。如想在着色器里采集更新后的像素数据,必须先调用 Apply 把数据从 CPU 复制到 GPU。


可读与不可读纹理

默认导入到项目里的纹理资产是不可读的,而从脚本创建的纹理是可读的。可读纹理所占用的内存是不可读纹理的两倍,因为 CPU 的 RAM 里也需要一份像素数据的副本。所以建议只在必要时让纹理可读,在 CPU 上完成数据编辑后再把它变回不可读。

要查看项目里的纹理资产是否可读,或进行编辑,请使用 Texture Import Settings 的 Read/Write Enabled 选项或 TextureImporter.isReadable API。

要将纹理设为不可读,可调用 Apply 方法,把 makeNoLongerReable 参数设为“true”(比如 Texture2D.Apply 或 Cubemap.Apply)。不可读的纹理没法反向变回可读状态。

所有纹理在编辑器的 Edit 和 Play 模式下都是可读的。调用 Apply 将纹理设为不可读会更新 isReadable 的值,并阻止访问 CPU 数据。然而也有例外情况,有时在此进程仍会把纹理看作可读的,因为内部 CPU 数据仍然存在。

走进纹理科学,看穿底层逻辑

GitHub 上的官方 Texture Access API 示例包含了一系列例子来展示不同 API 在访问或更改纹理数据上的性能差异。本项目的 UI 会展示主线程的 CPU 耗时。部分例子还使用了 Burst 和 Job System 等 DOTS 特性来优化性能。

访问下方链接,前往 Texture Access API 示例:
https://github.com/Unity-Technologies/UnityTextureAccessApiExamples/tree...


方法及 API 推荐

访问像素数据的方法繁多。同一方法针对每种格式、纹理类型或用法都会有不同的执行成本。本节将介绍其中部分方法。

CopyTexture

CopyTexture 是把 GPU 数据转移到另一张纹理的最快方法,它不会执行任何格式转换。如果两张纹理都是可读的,则复制操作会在 CPU 数据上完成,此时该方法的总体成本会非常接近 SePixelData 在 CPU 上的复制,结果则等同于用 GetPixelData 复制源纹理。

Blit

Blit 可以用着色器把 GPU 数据快速转移到一张 RenderTexture 上。实际使用中,Blit 必须设立图形管线 API 的状态才能渲染到目标 RenderTexture。相比于 CopyTexture,它会有一些与分辨率无关的启动成本。默认方法的 Blit 着色器会接受一张输入纹理,将其渲染到目标 RenderTexture 上,可以实现纹理到纹理的渲染过程。

GetPixelData 与 SetPixelData

如果只涉及 CPU 数据,GetPixelData 与 SetPixelData(以及 GetRawTextureData)就是最快的方法。两种方法都接收一个结构(struct)类用于重新解释数据的模板参数。方法本身只需要这个结构来派生出正确的尺寸,倘若不想采用自定义结构来表示纹理的格式,可以直接用 byte。

访问单个像素时,可以定义一套自定义结构与方法。比如,用 ushort 数据类和 get/set 方法获取单条通道上的字节数据,形成 R5G5B5A1 格式的结构。

publicstruct FormatR5G5B5A1
{
publicushort data;

constushort redOffset = 11;
        constushort greenOffset = 6;
        constushort blueOffset = 1;
        constushort alphaOffset = 0;

constushort redMask = 31 << redOffset;
        constushort greenMask = 31 << greenOffset;
        constushort blueMask = 31 << blueOffset;
        constushort alphaMask = 1;

publicbyte red { get { return (byte)((data & redMask) >> redOffset); } }
        publicbyte green { get { return (byte)((data & greenMask) >> greenOffset); } }
        publicbyte blue { get { return (byte)((data & blueMask) >> blueOffset); } }
        publicbyte alpha { get { return (byte)((data & alphaMask) >> alphaOffset); } }
}

以上代码以 R5G5B5A5A1 格式表示了一个像素数据(省略了相应的属性设定字段)。

SetPixelData 可以把整个 mip 级别的数据复制到一张目标纹理上。GetPixelData 所返回的 NativeArray 会指向 Unity 内部 CPU 纹理数据的一个 mip 级别,实现不必复制任何像素就能直接读写数据。

缺点在于,GetPixelData 返回的 NativeArray 只能保证在调用 GetPixelData 的用户代码将控制权返回给 Unity 之前有效,比如 MonoBehaviour.Update 返回时,无法在帧之间存储 GetPixelData 的结果,而必须从 GetPixelData 为要从中访问此数据的每个帧获取正确的 NativeArray。

Apply

Apply 方法会在数据上传至 GPU 后返回结果。makeNoLongerReadable 参数应当尽可能保留为“true”,以方便结束上传后释放出 CPU 的内存。

RequestIntoNativeArray 和 RequestIntoNativeSlice

这两种方法可以异步下载某张纹理的 GPU 数据到用户指定的 NativeArray 中。

这些方法会返回一个 request handle 用于检查数据是否完成了下载。它们仅支持有限几种格式,请使用 SystemInfo.IsFormatSupported 和 FormatUsage.ReadPixels 来查看支持的格式。AsyncGPUReadback 类同样有一个 Request 方法,可以分配 NativeArray。

还有几种特殊方法由于可能会对性能产生重大影响,所以需要谨慎使用:

带底层数据转换的像素访问方法

这类方法能在不同程度上执行像素格式转换。Pixels32 变体是这里边性能最好的,但是如果纹理的底层格式不能完美匹配 Color32 结构,这些方法也会执行格式转换。在使用以下方法时,需要注意像素数量的增长,它们的性能影响会以不同程度显着增加:

GetPixel
GetPixelBilinear
SetPixel
GetPixels
SetPixels
GetPixels32
SetPixels32

快速数据访问方法

GetRawTextureData 和 LoadRawTextureData 是两种只用于 Texure2D 的方法,可处理包含所有 mip 等级原始像素数据的数据组,并将 mip 按从大到小的顺序排序,每个 mip 带有“高度”数量的“宽度”像素值。

虽然能快速让 CPU 访问数据。GetRawTextureData 有一个操作难点,就是不按模板的变体会返回数据的副本。这种方式不仅更慢,还不能直接操纵 Unity 管理的底层缓冲区。GetPixelData 只会返回一个指向底层缓冲区的 NativeArray,该缓冲区在用户代码将控制权返回给 Unity 之前一直有效。

ConvertTexture

ConvertTexture 是一种将纹理的 GPU 数据转移至另一张纹理的途径,源纹理和目标纹理不一定需要有同样的大小或格式。整个内部转换流程是:

分配一张匹配目标纹理的临时 RenderTexture。
将源纹理 Blit 到临时 RenderTexture。
复制临时 RenderTexture 上的转移结果到目标纹理。
如果 RenderTexture 作为目标纹理,转换流程只需一次 Blit 就能获取目标 RenderTexture 了。

ReadPixels

ReadPixels 方法会从激活的 RenderTexture (RenderTexture.active) 同步下载 GPU 数据到 CPU 上的 Texture2D。可以用它来保存或处理某次渲染运算的输出。它支持少数几种格式,请用 SystemInfo.IsFormatSupported 和 FormatUsage.ReadPixels 来检查格式支持。

从 GPU 下载数据是一个繁琐的流程。在下载开始前,ReadPixels 必须等待 GPU 完成之前的工作,并只会在请求的数据可用后返回,从而拖累性能。不论从可用性还是性能上说,前边的 AsynGPUReadback 方法都要更好。

转换图片文件格式的方法

ImageConversion 类包含了几种转换图片格式的方法。LoadImage 可以将JPG、PNG、EXR(从 2023.1 开始)加载成 Texture2D 并把数据上传至 GPU。加载好的像素数据视 Texture2D 原格式,可以在运行期间进行压缩。其他方法可以把 Texture2D 或像素数据组转换成一组 JPG、PNG、TGA 或 EXR 数据。

这些方法并不是很快,但可用于以常见图片格式传输像素数据。

总结一下,在选择最优方法时关键的考虑因素包括 CPU/GPU 工作负荷以及输入/输出数据的大小。复杂运算时,例如大型 Readback 和像素计算,只有在异步或并行执行时才在 CPU 上可行。Burst 及Job System 能让 C# 高效执行一些原本只能在 GPU 上运行的运算。

*本文中有部分删减,全文可以访问如下链接:
https://blog.unity.com/engine-platform/accessing-texture-data-efficiently

点击 此处 前往 Texture Access API 示例。该示例项目演示了纹理分辨率变化与 API 性能问题的联系。

最新文章