高效地访问纹理数据

NICO LEYMAN Software Development Consultant
May 25, 2023|15 Min
高效地访问纹理数据
为方便起见,此网页已进行机器翻译。我们无法保证翻译内容的准确性或可靠性。如果您对翻译内容的准确性有疑问,请参阅此网页的官方英文版本。

了解Unity项目里各种底层像素数据访问方式的优点和缺点。

Unity里的像素处理

像素数据描述了纹理上单个像素的颜色,像素是一张纹理最基本的组成单位,而Unity提供有多种用C#脚本读写像素数据的方法。

你可以用这些方法来复制或更新纹理(比如给玩家的头像加上细节),或将纹理数据用在特别的地方,比如读取世界地图的纹理来决定物体的摆放位置。

像素数据的读写有很多途径,你需要根据数据的处理方式和项目的性能要求来选择对应的方法。你需要根据数据的处理方式和项目的性能要求来选择对应的方法。

这篇博文及配套的示例项目旨在帮你熟悉现有的API和常见的性能陷阱。这两方面的知识能帮你写出高效的解决方案,或解决随时出现的性能瓶颈。

CPU与GPU的像素副本

对于大部分类型的纹理,Unity都会保存两份像素数据的副本:一份在GPU内存里,是渲染所需的数据;另一份则在CPU内存中,属于可选数据,能让你读取、写入和操纵CPU上的像素数据。属于可选数据,能让你读取、写入和操纵CPU上的像素数据。将像素数据副本存储在 CPU 内存中的纹理称为可读纹理。需要注意的一个细节是 渲染纹理只存在于 GPU 内存中。

CPU与GPU的不同
内存

大部分硬件的CPU内存都不同于GPU内存。有些设备带有部分共享内存,但本文只讨论传统的PC配置,即CPU只能直接访问插在主板上的RAM,GPU只依赖自己的VRAM。两个环境间的任何数据传输都需要经过PCI总线,速度会比同一内存里的相互传输慢很多。鉴于这些时间消耗,每帧传输的数据量不宜过多。

可视化 CPU 和 GPU 内存之间关系的流程图,以及应用程序接口的截面图
数据处理

在着色器里采集纹理样本是最常见的GPU处理操作。要想修改这段数据,你可以复制修改后的纹理,或用着色器把修改渲染到一张纹理上。所有这些操作都能由GPU快速执行。

有些时候,在CPU上操纵纹理数据可能更合适,访问数据的方式会更灵活。CPU的处理操作只会作用于CPU上的副本,所以纹理必须是可读的。如果要在着色器中对更新的像素数据进行采样,必须首先通过调用 应用.视纹理和操作复杂程度的不同,坚持用CPU操作应该会让整个过程更快、更轻松(比如把几张2D纹理复制到一个Texture2DArray资产)。

Unity API包含有几种方法来访问或处理纹理数据。倘若同时存在有GPU和CPU副本,部分操作会同时作用于两者。因此,纹理可读与否会影响这些方法的性能。同样的结果可以用不同的方法实现,但每种方法都有自己的性能和易用性特征。

回答以下问题,你就能找出最佳的解决方案:

  • GPU运算是否比CPU快?
  • 纹理缓存上的流程会造成什么样的压力?(比如,不用mipmap采集高分辨率纹理很可能会减缓GPU速度。)
  • 处理过程是否需要随机写入纹理,还是可以输出到颜色或深度附件?(写入纹理上的随机像素要求经常清理缓存,这会减缓整个流程。)
  • 项目是否受GPU瓶颈限制?就算GPU执行进程的速度远快于CPU,它能否继续承担更多作业且不超出帧预算?
  • 如果GPU和CPU主线程的帧耗时都接近了极限,那流程较慢的部分也许能由CPU工作线程执行。
  • 有多少数据需要上传到GPU或从GPU下载才能计算或处理结果?
  • 着色器或C#作业能否把数据打包成更小的格式来减少需要的带宽?
  • RenderTexture能否精简采样成分辨率更低的纹理用于下载?
  • 流程能否批量执行?(如果有大量数据需要同时处理,GPU有可能会内存不足。)
  • 结果是否急迫要用?运算或数据传输能否异步进行或随后处理?(如果单张帧需要执行过多的工作,GPU可能会没有足够的时间来渲染实际的帧图像。)
让纹理变为可读或不可读

默认时,导入到项目里的纹理资产是不可读的,而用脚本画出来的资产是可读的。

可读纹理所占用的内存是不可读纹理的两倍,因为CPU的RAM里也需要一份像素数据的副本。你应该只在必要时让纹理可读,在CPU上完成数据编辑后再把它变回不可读。

要查看项目中的纹理资产是否可读并进行编辑,可使用 "纹理导入设置 "中的 "已启用读/写"选项或 TextureImporter.isReadable应用程序接口。

要使纹理不可读,请调用其 Apply 方法,并将makeNoLongerReadable参数设置为 "true"(例如,Texture2D.Apply 或 Cubemap.Apply)。不可读的纹理没法重新变回可读状态。

所有纹理在编辑器的Edit和Play模式下都是可读的。调用 "应用 "使纹理不可读将更新isReadable 的值,从而阻止您访问 CPU 数据。然而,部分Unity进程仍会把纹理看作可读的,因为内部的CPU数据仍然存在。

GitHub上的Texture Access API示例
在CPU上每帧生成的纹理

不同的纹理数据访问方式有着极大的性能差距,特别是在CPU上(低分辨率时的差距会小一点)。GitHub 上的 Unity纹理访问 API 示例库包含大量示例,展示了允许访问或操作纹理数据的各种 API 之间的性能差异。项目的UI只会展示主线程的CPU时间。在某些情况下,DOTS 功能(如Burst作业系统)可用于最大限度地提高性能。

以下是GitHub仓库里的示例清单:

  • 简单复制:将一个纹理中的所有像素复制到另一个纹理中
  • 等离子纹理每帧 CPU 更新一个等离子纹理
  • TransferGPUTexture:将 GPU 上的所有像素从纹理转移(复制到不同尺寸或格式)到渲染纹理中

下方性能测量结果取自GitHub上的示例。这些数据是随后推荐方法的数据基础。这些结果源自一个搭载3.7 GHz 8核Xeon® W-2145 CPU和RTX 2080的系统。

SimpleCopy示例

这些是SimpleCopy.UpdateTestCase处理大小为2048的纹理所产生的CPU耗时中位数。

注意,Graphics方法只负责将工作推送给RenderThread,这一步由GPU稍后执行,所以在主线程上能瞬间完成。这些结果会在下一帧准备就绪时进行渲染。

测试结果

  • 1326毫秒——foreach(mip) for(x in width) for(y in height) SetPixel(x, y, GetPixel(x, y, mip), mip)
  • 32.14毫秒——foreach(mip) SetPixels(source.GetPixels(mip), mip)
  • 6.96毫秒——foreach(mip) SetPixels32(source.GetPixels32(mip), mip)
  • 6.74毫秒——LoadRawTextureData(source.GetRawTextureData())
  • 3.54毫秒——Graphics.CopyTexture(readableSource, readableTarget)
  • 2.87毫秒——foreach(mip) SetPixelData<byte>(mip, GetPixelData<byte>(mip))
  • 2.87毫秒——LoadRawTextureData(source.GetRawTextureData<byte>())
  • 0.00毫秒——Graphics.ConvertTexture(source, target)
  • 0.00毫秒——Graphics.CopyTexture(nonReadableSource, target)
PlasmaTexture示例

这些是PlasmaTexture.UpdateTest处理大小为512的纹理所产生的CPU耗时中位数。

可以看到SetPixels32出乎意料地要比SetPixels慢。这是因为系统需要获取运算得出的Color浮点值,将其转换成基于字节的Color32结构。SetPixels32NoConversion可以跳过这种转换,为Color32输出组指定一个默认值,其性能要强于SetPixels。为了克服SetPixels的性能问题及Unity底层的颜色转换,你必须改写运算方法,直接输出Color32值。采用SetPixelData的效果几乎一定会比仔细的SetPixels和XetPixels32方法来得更好。

测试结果

  • 126.95毫秒——SetPixel
  • 113.16毫秒——SetPixels32
  • 88.96毫秒——SetPixels
  • 86.30毫秒——SetPixels32NoConversion
  • 16.91毫秒——SetPixelDataBurst
  • 4.27毫秒——SetPixelDataBurstParallel
TransferGPUTexture示例

这些是TransferGPUTexture.UpdateTestCase在处理大小为8196的纹理时所产生的编辑器GPU耗时:

  • Blit——1.584毫秒
  • CopyTexture——0.882毫秒
像素数据API推荐

你能以多种方式访问像素数据。然而,并非所有方法都只是每种格式、纹理类型或用法,部分的执行时间要更长。本节将介绍我们推荐的方法,下一节介绍了需要小心使用的API。

CopyTexture

复制纹理是将 GPU 数据从一个纹理传输到另一个纹理的最快方法。它不会执行任何格式转换。你可以具体指明源文件和目标位置,以及复制区域的宽高来复制一部分数据。如果两个纹理都是可读的,则复制操作也将在 CPU 数据上执行,从而使该方法的总成本更接近于使用SetPixelData和源纹理GetPixelData的结果进行仅 CPU 复制的成本。

Blit(位块传输)

Blit是一种使用着色器将 GPU 数据传输到渲染纹理的快速而强大的方法。实际使用中,Blit必须设立图形管线API的状态才能渲染到目标RenderTexture。相比于CopyTexture,它有一小段与分辨率无关的准备开支。方法默认的Blit着色器会接受一张输入纹理,将其渲染到目标RenderTexture上。如若制定自己的材质或着色器,你就能定义复杂的“纹理到纹理”渲染流程。

GetPixelData与SetPixelData

GetPixelDataSetPixelData(以及 获取原始纹理数据) 是只接触 CPU 数据时最快的方法。两种方法都接收一个结构(struct)类作为解读数据的参数模板。方法本身只需要这个结构来派生出正确的尺寸,倘若你不想定义一个自定义结构来表示纹理的格式,可以直接用byte。

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

未知块类型 "codeBlock",请在 "serializers.type "道具中为其指定一个序列化器

以上代码以R5G5B5A5A1格式表示了一个像素数据;此处省略了相应的属性设定字段以显简洁。

SetPixelData可以把一整个mip级别的数据复制到一张目标纹理上。GetPixelData所返回的NativeArray会指向Unity内部CPU纹理数据的一个mip级别,让你不必复制任何像素就能直接读写数据。让你不必复制任何像素就能直接读写数据。缺点在于,GetPixelData返回的NativeArray只会在代码用该方法将控制交还Unity时生效,比如MonoBehaviour.Update返回时。你不能隔几帧储存GetPixelData的结果,而必须在访问数据的每一帧上获取正确的NativeArray。

Apply

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

RequestIntoNativeArray和RequestIntoNativeSlice

请求 数组方法方法会异步地将指定纹理中的 GPU 数据下载到用户提供的 NativeArray(原生数组的一个片段)中。

这些方法会返回一个请求握把用于检查数据是否完成了下载。支持的格式有限,因此请使用 SystemInfo.IsFormatSupportedFormatUsage.ReadPixels来检查格式支持。返回 AsyncGPUReadback类也有一个 请求方法,它会为您分配一个 NativeArray。如果需要重复操作,你可以重复使用NativeArray来提高整体性能。

需要小心使用的方法

这里还有几种方法由于对性能有较大的冲击,所以需要小心使用。我们来详细了解下它们。

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

这些方法能在不同程度上执行像素格式转换。Pixels32衍生方法是这里边性能最好的,但是如果纹理的底层格式不能完美匹配Color32结构,这些方法也会执行格式转换。在使用以下方法时,最好记住像素数量的增长会带来不同程度的显著性能冲击:

带小缺陷的快速数据访问方法

获取原始纹理数据加载原始纹理数据是仅适用于 Texture2D 的方法,可处理包含所有 mip 级别原始像素数据的数组,一个接一个。mip按从大到小的顺序排序,每个mip带有“高度”数量的“宽度”像素值。这些函数能快速让CPU访问数据。GetRawTextureData确实有个“缺陷”,不按模板写的派生方法会返回数据的副本。这种方式不仅更慢,还不能直接操纵Unity管理的底层缓冲区。GetPixelData没有这种特点,它只会返回指向底层缓冲区的NativeArray,这块缓冲在用户代码将控制返还Unity时就会失效。

ConvertTexture

转换纹理是一种将 GPU 数据从一个纹理传输到另一个纹理的方法,在这种情况下,源纹理和目标纹理的大小或格式并不相同。这种转换流程在各个情况下都会发挥最大效率,但它并不便宜。整个内部流程是:

分配一张匹配目标纹理的临时RenderTexture。

将源纹理Blit(位块转移)到临时RenderTexture。

复制临时RenderTexture上的转移结果到目标纹理。

以下问题能帮你决定该方法是否适合你的用例:

  • 我需要进行转换吗?
  • 我能否保证在导入时能以目标平台需要的大小/格式创建源纹理?
  • 我能否在流程里一直使用同一张纹理,把结果直接用作其他流程的输入?
  • 我能否把RenderTexture作为目标纹理?这样转换流程只需一次Blit就能产出目标RenderTexture了。
ReadPixels

阅后即焚 读取像素方法会将活动渲染纹理 (RenderTexture.active) 中的 GPU 数据同步下载到 Texture2D 的 CPU 数据中。你可以用它来保存或处理某次渲染运算的输出。它支支持少数几种格式,请用SystemInfo.IsFormatSupported和FormatUsage.ReadPixels来检查格式支持。

从GPU取回数据是一个缓慢的流程。在下载开始前,ReadPixels必须等待GPU完成前边的工作。建议不要使用该方法,它只会在请求的数据可用后返回,从而拖累性能。从可用性的角度看,你需要复制GPU数据到RenderTexture,这张纹理必须与当前激活的纹理有同样的配置。如果使用前面讨论过的AsyncGPUReadback方法,可用性和性能都会更好。

转换图片文件格式的方法

图像转换 图像转换类拥有在 Texture2D 和多种图像文件格式之间进行转换的方法。加载图像能将 JPG、PNG 或 EXR(自 2023.1 起)数据加载到纹理 2D 中,并将其上传到 GPU。加载好的像素数据视Texture2D原格式可以在运行期间进行压缩。其他方法可以把Texture2D或像素数据组转换成一组JPG、PNG、TGA或EXR数据。

这些方法并不是很快,但可用于在项目里以常见图片格式传输像素数据。常见用法包括从磁盘加载用户头像,与网络上的其他玩家分享。

关键知识点及更多高级资源

Unity图形优化、相关主题及最佳做法有很多的学习资源。文档中的图形性能和剖析部分是一个很好的起点。

您还可以查看几本面向高级用户的技术电子书籍,包括Unity 游戏剖析终极指南》、《 优化移动游戏性能 和《优化控制台和 PC游戏 性能 优化控制台和 PC 游戏性能.

您可以在Unity 操作中心找到更多先进的最佳实践。

下边我们来总结下需要记住的关键点:

  • 在操纵纹理时,第一步是判断出哪种GPU运算能带来最优的性能。关键的考虑因素包括已有CPU/GPU工作负荷以及输入/输出数据的大小。
  • 用GetRawTextureData等底层函数在必要时实施特定的转换流程,相比于那些复制(经常没这个必要)并转换数据的便利方法来说要更为高效。
  • 更复杂的运算,比如大量读取返回和像素计算,在异步或并行执行时只能在CPU上运行。Burst及作业系统能让C#执行一些原本只能在GPU上高效运行的运算。
  • 经常查看个人资料:在开发过程中,您可能会遇到许多陷阱,从意外和不必要的转换,到等待另一个进程的停滞。有些性能问题只会在游戏逐渐变大、代码任务变得繁重时显现。在示例项目里,看似细微的纹理分辨率增加就让部分API变成了性能障碍。

脚本通用图形论坛与我们分享您对纹理数据的反馈。作为 Tech from the Trenches系列的一部分,请务必关注其他 Unity 开发人员的新技术博客