跳转到内容

Cg 编程/Unity/计算图像效果

来自维基教科书,开放的书籍,开放的世界
应用于视频图像的后处理效果。

本教程介绍了在 Unity 中为相机视图的图像后处理创建最小计算着色器的基本步骤。如果您不熟悉 Unity 中的图像效果,您应该先阅读“最小图像效果”部分。请注意,计算着色器在 macOS 上不受支持。

Unity 中的计算着色器

[编辑 | 编辑源代码]

从某些方面来说,计算着色器类似于片段着色器,有时将它们视为“改进”的片段着色器是有帮助的,因为计算着色器解决了片段着色器的一些问题

  • 片段着色器是图形管线的一部分,这使得将它们用于其他用途(尤其是GPGPU 编程(通用图形处理单元上的通用计算))变得很麻烦。
  • 片段着色器是为极度并行问题而设计的,该问题是对三角形(和其他几何基元)的片段进行光栅化。因此,它们不适合不那么极度并行的问题,例如,当着色器必须在它们之间共享或通信数据或需要写入内存中的任意位置时。
  • 运行片段着色器的图形硬件提供了更高级并行编程的功能,但认为在片段着色器中提供这些功能是不明智的;因此,认为需要不同的应用程序编程接口 (API)。

历史上,解决片段着色器这些缺点的第一种方法是引入全新的 API,例如 CUDAOpenCL 等。虽然其中一些 API 仍然非常流行用于 GPGPU 编程,但由于几个原因,它们在图形任务(例如图像处理)方面并不那么流行。(一个原因是使用两个 API(计算和图形)对同一硬件的开销;另一个原因是在计算 API 和图形 API 之间通信数据的困难。)

由于单独的计算 API 存在问题,计算着色器在图形 API(特别是 Direct3D 11、OpenGL 4.3 和 OpenGL ES 3.1)中作为另一类着色器被引入。这也是Unity 支持的内容。

在本教程中,我们研究如何在 Unity 中使用计算着色器进行图像处理,以介绍计算着色器的基本概念,以及将计算着色器用于图像处理的具体问题,这是一个重要的应用领域。后续教程将讨论计算着色器的更多高级功能以及图像处理以外的应用。

创建计算着色器

[编辑 | 编辑源代码]

在 Unity 中创建计算着色器并不复杂,与创建任何着色器非常相似:在“项目窗口”中,单击“创建”,然后选择“着色器 > 计算着色器”。一个名为“NewComputeShader”的新文件应该出现在项目窗口中。双击它打开它(或右键单击并选择“打开”)。一个使用默认着色器的文本编辑器将出现在DirectX 11 HLSL 中。(DirectX 11 HLSL 与 Cg 不同,但它共享许多常见的语法特征。)

以下计算着色器可用于使用用户指定的颜色为图像着色。您可以将其复制并粘贴到着色器文件中

#pragma kernel TintMain

float4 Color;

Texture2D<float4> Source;
RWTexture2D<float4> Destination;

[numthreads(8,8,1)]
void TintMain (uint3 groupID : SV_GroupID, 
      // ID of thread group; range depends on Dispatch call
   uint3 groupThreadID : SV_GroupThreadID, 
      // ID of thread in a thread group; range depends on numthreads
   uint groupIndex : SV_GroupIndex, 
      // flattened/linearized GroupThreadID between 0 and 
      // numthreads.x * numthreads.y * numthreadz.z - 1 
   uint3 id : SV_DispatchThreadID) 
      // = GroupID * numthreads + GroupThreadID
{
   Destination[id.xy] = Source[id.xy] * Color;
}

让我们逐行了解此着色器:#pragma kernel TintMain(特定于 Unity)行定义了计算着色器函数;这与片段着色器中的#pragma fragment ... 非常相似。

float4 Color; 行定义了一个在脚本中设置的统一变量,如下所述。这就像片段着色器中的统一变量一样。在这种情况下,Color 用于为图像着色。

Texture2D<float4> Source; 行定义了一个具有四个浮点分量的 2D 纹理,这样计算着色器可以读取它(无需插值)。在片段着色器中,您将使用sampler2D Source; 来采样 2D 纹理(具有插值)。(请注意,HLSL 使用单独的纹理对象和采样器对象;请参阅Unity 的手册,了解如何为给定纹理对象定义采样器对象,如果您想使用函数SampleLevel() 在计算着色器中使用插值采样 2D 纹理,则需要这样做。)

RWTexture2D<float4> Destination; 指定了一个读写 2D 纹理,计算着色器可以从中读取和写入。这对应于 Unity 中的渲染纹理。计算着色器可以写入RWTexture2D 中的任何位置,而片段着色器通常只能写入其片段的位置。但是请注意,计算着色器的多个线程(即计算着色器函数的调用)可能会以未定义的顺序写入RWTexture2D 中的同一位置,从而导致未定义的结果,除非采取特殊措施来避免这些问题。在本教程中,我们通过让每个线程仅写入RWTexture2D 中它自己的唯一位置来避免任何这些问题。

下一行是[numthreads(8,8,1)]。这是计算着色器的专用行,它定义了线程组的尺寸。线程组是一组并行执行的计算着色器函数调用,因此它们的执行可以同步,即可以指定屏障(使用诸如GroupMemoryBarrierWithGroupSync() 之类的函数),所有线程都必须到达屏障,然后任何线程才能继续执行。线程组的另一个特点是,一个线程组中的所有线程都可以共享一些特别快的(“groupshared”)内存,而不同组中的线程可能共享的内存通常比较慢。

线程按线程组的 3D 数组组织,每个线程组本身也是一个 3D 数组,其三个维度由numthreads 的三个参数指定。对于图像处理任务,第三个 (z) 维度通常为 1,如我们的示例[numthreads(8,8,1)] 中所示。维度 (8,8,1) 指定每个线程组包含 8 × 8 × 1 = 64 个线程。(有关说明,请参阅Microsoft 对 numthreads 的文档。)这些数字存在一定的平台特定限制,例如,对于 Direct3D 11,x 和 y 维度必须小于或等于 1024,z 维度必须小于或等于 64,三个维度的乘积(即线程组的大小)必须小于或等于 1024。另一方面,为了获得最佳效率,线程组应该具有约 32 的最小大小(取决于硬件)。

如下所述,计算着色器在脚本中使用函数ComputeShader.Dispatch(int kernelIndex, int threadGroupsX, int threadGroupsY, int threadGroupsZ) 调用,其中kernelIndex 指定计算着色器函数,其他参数指定线程组 3D 数组的尺寸。对于我们的[numthreads(8,8,1)] 示例,每个组中有 64 个线程,因此线程总数将为64 * threadGroupsX * threadGroupsY * threadGroupsZ

代码的其余部分指定了计算着色器函数void TintMain()。通常,对于计算着色器函数来说,了解它是为 3D 线程数组中的哪个位置调用的是很重要的。了解线程组在线程组 3D 数组中的位置以及线程在线程组中的位置也很重要。HLSL 提供了以下语义来提供此信息

  • SV_GroupID:一个uint3 向量,它指定线程组的 3D ID;ID 的每个坐标从 0 开始,到(但不包括)ComputeShader.Dispatch() 调用中指定的维度为止。
  • SV_GroupThreadID:一个uint3 向量,它指定线程组内线程的 3D ID;ID 的每个坐标从 0 开始,到(但不包括)numthreads 行中指定的维度为止。
  • SV_GroupIndex:一个uint,它指定 0 到 numthreads.x * numthreads.y * numthreadz.z - 1 之间的扁平化/线性化SV_GroupThreadID
  • SV_DispatchThreadID:一个uint3 向量,它指定线程在整个线程组数组中的 3D ID。它等于SV_GroupID * numthreads + SV_GroupThreadID

计算着色器函数可以接收这些值中的任何值,如示例所示:void TintMain (uint3 groupID : SV_GroupID, uint3 groupThreadID : SV_GroupThreadID, uint groupIndex : SV_GroupIndex, uint3 id : SV_DispatchThreadID)

特定的函数TintMain 实际上只使用了带有SV_DispatchThreadID 语义的变量id。函数调用按 2D 数组组织,该数组的尺寸至少与DestinationSource 纹理的尺寸相同;因此,id.xid.y 可用于访问这些纹素Destination[id.xy]Source[id.xy]。基本操作只是将Source 纹理的颜色乘以Color,然后将其写入Destination 渲染纹理

Destination[id.xy] = Source[id.xy] * Color;

将计算着色器应用于相机视图

[编辑 | 编辑源代码]

为了将计算着色器应用于相机视图的所有像素,我们需要定义函数 `OnRenderImage(RenderTexture source, RenderTexture destination)` 并在计算着色器中使用这些渲染纹理。然而,存在一些问题,特别是在较新的 Unity 版本中,我们需要将源像素复制到临时纹理中,才能在计算着色器中使用它们。此外,如果 Unity 直接渲染到帧缓冲区,`destination` 会被设置为 `null`,我们就没有渲染纹理可用于计算着色器。另外,我们需要在创建渲染纹理之前启用它以进行随机写入访问,而我们无法使用在 `OnRenderImage()` 中获得的渲染纹理来执行此操作。我们可以通过创建一个与 `source` 渲染纹理尺寸相同的临时渲染纹理,并让计算着色器写入该临时渲染纹理,来处理这些情况(以及 `source` 和 `destination` 渲染纹理尺寸不同的情况)。然后可以将结果复制到 `destination` 渲染纹理中,如果 `destination` 为 `null`,则将结果复制到帧缓冲区。

以下 C# 脚本使用临时渲染纹理 `tempDestination` 来实现此想法。

using System;
using UnityEngine;

[RequireComponent(typeof(Camera))]
[ExecuteInEditMode]

public class tintComputeScript : MonoBehaviour {

   public ComputeShader shader;
   public Color color = new Color(1.0f, 1.0f, 1.0f, 1.0f);
   
   private RenderTexture tempSource = null;
      // we need this intermediate render texture to access the data   
   private RenderTexture tempDestination = null;  
      // we need this intermediate render texture for two reasons:
      // 1. destination of OnRenderImage might be null 
      // 2. we cannot set enableRandomWrite on destination
   private int handleTintMain;

   void Start() 
   {
      if (null == shader) 
      {
         Debug.Log("Shader missing.");
         enabled = false;
         return;
      }
      
      handleTintMain = shader.FindKernel("TintMain");
      
      if (handleTintMain < 0)
      {
         Debug.Log("Initialization failed.");
         enabled = false;
         return;
      }  
   }

   void OnDestroy() 
   {
      if (null != tempSource)
      {
         tempSource.Release();
         tempSource = null;
      }
      if (null != tempDestination) {
         tempDestination.Release();
         tempDestination = null;
      }
   }

   void OnRenderImage(RenderTexture source, RenderTexture destination)
   {      
      if (null == shader || handleTintMain < 0 || null == source) 
      {
         Graphics.Blit(source, destination); // just copy
         return;
      }

      // do we need to create a new temporary source texture?
      if (null == tempSource || source.width != tempSource.width
         || source.height != tempSource.height)
      {
         if (null != tempSource)
         {
            tempSource.Release();
         }
         tempSource = new RenderTexture(source.width, source.height,
           source.depth);
         tempSource.Create();
      }

      // copy source pixels
      Graphics.Blit(source, tempSource);
      
      // do we need to create a new temporary destination render texture?
      if (null == tempDestination || source.width != tempDestination.width 
         || source.height != tempDestination.height) 
      {
         if (null != tempDestination)
         {
            tempDestination.Release();
         }
         tempDestination = new RenderTexture(source.width, source.height, 
            source.depth);
         tempDestination.enableRandomWrite = true;
         tempDestination.Create();
      }

      // call the compute shader
      shader.SetTexture(handleTintMain, "Source", tempSource); 
      shader.SetTexture(handleTintMain, "Destination", tempDestination);
      shader.SetVector("Color", (Vector4)color);
      shader.Dispatch(handleTintMain, (tempDestination.width + 7) / 8, 
         (tempDestination.height + 7) / 8, 1);
      
      // copy the result
      Graphics.Blit(tempDestination, destination);
   }
}

该脚本应保存为 "tintComputeScript.cs"。为了使用它,必须将其附加到相机,并且公共变量 `shader` 必须设置为计算着色器,例如上面定义的计算着色器。

脚本的 `Start()` 函数只执行一些错误检查,使用 `shader.FindKernel("TintMain")` 获取计算着色器函数的编号,并将其写入 `handleTintMain` 以便在 `Update()` 函数中使用。

`OnDestroy()` 函数释放临时渲染纹理,因为垃圾收集器不会自动释放渲染纹理所需的硬件资源。

`Update()` 函数执行一些错误检查,然后(如果需要)在 `tempSource` 和 `tempDestination` 中创建新的渲染纹理,并将像素复制到 `tempSource`,之后使用函数 `SetTexture()`、`SetVector()` 和 `SetInt()` 设置计算着色器的所有统一变量,然后使用对 `Dispatch()` 的调用调用计算着色器函数。在本例中,我们使用 `(tempDestination.width + 7) / 8` 乘以 `(tempDestination.height + 7) / 8` 线程组(这两个数字都隐式向下取整)。我们在两个维度上除以 8,因为我们指定了线程组的数量,每个线程组的大小为 8 乘以 8,如计算着色器中的 `[numthreads(8,8,1)]` 所指定。需要加上 7 以确保如果渲染纹理的尺寸不能被 8 整除,我们不会少一个。在调度计算着色器之后,结果使用对 `Graphics.Blit()` 的调用从 `tempDestination` 复制到 `OnRenderImage()` 的实际 `destination`。

与用于图像效果的片段着色器比较

[edit | edit source]

这个计算着色器和 C# 脚本实现了与“最小图像效果”部分中的片段着色器相同的效果。显然,使用计算着色器实现图像效果比使用片段着色器需要更多的代码。但是,您应该记住两点:1) 额外代码的原因主要是 Unity 的 `OnRenderImage()` 函数和 `Graphics.Blit()` 函数的设计初衷是为了与片段着色器顺利协作,而在定义这些函数时没有考虑计算着色器,2) 计算着色器能够完成片段着色器无法完成的事情,例如,写入目标渲染纹理中的任意位置、在线程之间共享数据、同步线程的执行等。其他教程中将讨论这些功能中的一部分。

总结

[edit | edit source]

恭喜您,您已经学习了有关 Unity 中的计算着色器的基础知识,以及如何将它们用于图像效果。您已经看到了一些内容,包括:

  • 如何为图像效果创建计算着色器。
  • 如何在 C# 脚本中设置计算着色器的统一变量。
  • 如何使用 `ComputeShader.Dispatch()` 函数调用计算着色器函数。

进一步阅读

[edit | edit source]

如果您想了解更多

< Cg 编程/Unity

除非另有说明,否则本页上的所有示例源代码均归属于公共领域。
华夏公益教科书