Cg Programming/Unity/Computing Image Effects计算图像效果

本教程介绍了在Unity中为摄像机视图的图像后处理创建一个最小的计算着色器的基本步骤。如果你还不熟悉Unity中的图像效果,你应该先阅读一下章节“最小的图像效果”。
这里写图片描述

在Unity中计算着色器

计算着色器在某些方面跟片元着色器类似,而且有时把它们看成“改进的”片元着色器还是很有用的,因为计算着色器解决了片元着色器的一些问题:

  • 片元着色器是图形管线的一部分,这使得用它来做其它事情变得很麻烦,特别是在GPGPU编程中(图形处理单元上的通用计算)。
  • 片元着色器是为三角片元(以及其它几何图元)光栅化的易并行问题(译者注:原文为embarrassingly parallel problem,易并行是指每个运算是相互独立的,比如矩阵乘法等)来设计的。这样,它们就不能很好地适用于不是易并行的问题,比如,当着色器需要在它们之间共享或通信数据,或者它需要写入内存中的任意位置。
  • 运行着片元着色器的图形硬件为更高级的并行编程提供了功能,但是在片元着色器中提供这些功能并不很明智;一个不同的应用程序接口(API)就很有必要了。

从历史上看,解决这些片元着色器的缺点的第一个方法就是引入完整的新API,比如CUDAOpenCL等。虽然一些API对于GPGPU编程仍然是很有用的,但因为一些原因它们对图形任务就很少有用了(比如图像处理)。(一个原因在于在一些硬件上使用两个API的开销(计算和图形);另一个原因在于在计算API和图形API之间计算数据的难度。)

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

在本教程中,我们将会讨论如何在Unity中为图像处理使用计算着色器,以便引入计算着色器的基本概念以及使用计算着色器进行图像处理的具体问题,这是一个重要的应用领域。

创建计算着色器

在Unity中创建一个计算着色器并不复杂,就跟创建任意一个着色器一样:在Project Window,点击Create并且选择Shader > Compute Shader。一个命名为“NewComputeShader”应该会显示在Project Window中。双击打开它(或右击并选择open),在DirectX 11 HLSL中使用默认着色器的文本编辑器会出现。(DirectX 11 HLSL跟Cg是不是一样的,但它有许多共同的语法特征。)

以下的计算着色器有助于用用户指定的颜色着色图像。你可以把它复制并拷贝到一个着色器中:

#include "UnityCG.cginc"

#pragma kernel TintMain

float4 Color;
int SourceHeight;
int AntiAliasing;

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
{
#if !UNITY_UV_STARTS_AT_TOP
   Destination[id.xy] = Source[id.xy] * Color;
#else
   if (AntiAliasing == 0)
   {
      Destination[id.xy] = Source[id.xy] * Color;
   }
   else
   {
      Destination[id.xy] = 
         Source[uint2(id.x, SourceHeight - 1 - id.y)] * Color;
   }
#endif
}

让我们一行行地来看这个着色器:#include "UnityCG.cginc"这行是必须的,它使得Unity可以在一些第二个纹理坐标(V)自上而下而不是自下而上的硬件平台上定义标记UNITY_UV_STARTS_AT_TOP。正如在Unity手册中解释的,这是通常情况下在类似Direct3D平台上使用反走样。不幸地是,我们的计算着色器必须把这个考虑在内如果它要运行在所有平台上的话。

#pragma kernel TintMain这行(Unity指定的)定义了计算着色器函数;这跟片元着色器中的#pragma fragment ...非常类似。

float4 Color;int SourceHeight;,以及 int AntiAliasing;这三行定义了uniform变量,它们在以下的代码中被设置。这跟片元着色器中的uniform一样。在这种情况下,Color被用来对图像着色,而SourceHeightAntiAliasing被用来在一些平台上翻转V纹理坐标。

Texture2D<float4> Source;这行定义了有四个浮点数分量的2D纹理,这样计算着色器可以读取它(无需插值)。在一个片元着色器中,你可能会使用sampler2D Source;来采样一张2D纹理(使用插值)。(注意HLSL使用了独立纹理对象和采样对象;查看Unity手册来了解如何为一个指定的纹理对象定义采样对象,你可能会需要它,当你在计算着色器中使用函数SampleLevel()来采样一张2D纹理。)

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

下一行是[numthreads(8, 8, 1)]。这是计算着色器中特殊的一行,它定义了线程组的维数。线程组是对并行计算的计算着色器函数的一组调用,并且因此它们的执行可以被同步,也就是可以指定所有线程必须到达的分界线,然后才能进一步执行任何线程。线程组的另一个特征是一个线程组中的所有线程能够共享特殊的快速(“groupshared”)内存,而在不同组之间被线程共享的内存通常比较慢。

线程被组织在线程组的3D数组中,并且每个线程组本身是一个3D数组,它的三维由numthreads的三个参数指定。对于图像处理任务,第三个(z)维度通常1,就跟我们的例子[numthreads(8, 8, 1)]一样。维度(8, 8, 1)指定了每个线程组包含了8 × 8 × 1 = 64个线程。对这些数字有某种平台特定的限制,例如,对于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,指定了扁平批/ 线性化的SV_GroupThreadID,它的值介于0和numthreads.x* numthreads.y* numthreadz.z - 1之间。
  • 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。该函数调用是在有DestinationSource纹理的二维数组中组织的;这样,id.xid.y可以被用来访问这些纹素Destination[id.xy]Source[id.xy]。基本的操作就是Source纹理的颜色乘以Color,然后把它写入Destination渲染纹理中:

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

但是,事情会变得有点麻烦,因为我们有时需要改变id.y到纹理的高度减去1减去id.y。特别是我们需要这样做,如果Unity指定了标记UNITY_UV_STARTS_AT_TOP并激活了反走样,即变量AntiAliasing大于等于0的情况。

把计算着色器应用到摄像机视图上

为了把计算着色器应用到摄像机视图的所有像素上,我们必须定义函数OnRenderImage(RenderTexture source, RenderTexture destination),并且在计算着色器中使用这些渲染纹理。但是,这里有两个问题:如果Unity直接渲染到帧缓冲中,destination被设置为null,我们没有渲染纹理使用到我们的计算着色器中。在我们创建渲染纹理之前,我们需要使得它可以随机写入,我们不能用OnRenderImage()中的渲染纹理做这件事。我们可以通过创建一张跟source渲染纹理相同维度的临时渲染纹理,并且计算着色器可以写入那个临时渲染纹理。随后结果会被复制到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 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 != 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 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", source);
      shader.SetTexture(handleTintMain, "Destination", tempDestination);
      shader.SetVector("Color", (Vector4)color);
      shader.SetInt("SourceHeight", source.height);
      shader.SetInt("AntiAliasing", QualitySettings.antiAliasing);
      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")获取计算着色器函数的数量,以及把它赋值给Update()函数中要用到的handleTintMain。

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

Update()函数做了一些错误检查,随后–如果有必要的话–它创建了一张新的渲染纹理tempDestination,随后在调用计算着色器函数Dispatch()之前,用函数SetTexture()SetVector()SetInt()设置计算着色器的所有uniform变量。在这种情况下,我们使用(tempDestination.width + 7) / 8乘以(tempDestination.height + 7) / 8的线程组(两个数字隐式向下取整)。在两个维度中我们除以8,因为我们要指定线程组的数量以且每个线程组的长度为8乘以8,就像在计算着色器中[numthreads(8, 8, 1)]指定的一样。如果渲染纹理的尺寸不能被8整除,则需要添加7以确保数组不会很短。在调用计算着色器后,通过调用Graphics.Blit()结果会从tempDestination拷贝到OnRenderImage()中的destination

图像效果中跟片元着色器的比较

这个计算着色器和C#脚本实现了章节“最小的图像效果”中片元着色器相同的效果。显然,使用计算着色器的图像效果比使用片段着色器的图像效果要多一些代码。但是,你应该记住两件事:1)对于额外的代码,主要的原因是Unity的OnRenderImage()函数和Graphics.Blit()函数被设计为能够使用片元着色器顺利工作,而当这些函数被定义时计算着色器并没有考虑到;2)计算着色器能够做片元着色器不能做的事,比如写入目的渲染纹理的任意位置,在线程中共享数据,同步线程的执行等。一些特征会在其它教程中讨论。

总结

恭喜,你学完了Unity中计算着色器的基础部分,以及如何在图像效果中使用它们。

  • 如何为一个图像效果创建一个计算着色器。
  • 如何在C#脚本中设置计算着色器的uniform变量。
  • 如何用ComputeShader.Dispatch()函数调用计算着色器函数。
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值