U-n-i-t-y 提供了 [Compute Shader][link1] 来使得我们可以将大量的复杂重复的计算交给并行的 GPU 来处理,正是由于并行原因,这样就可以大大加快计算的速度,相比在 CPU 的线程中有着巨大的优势。类似 OpenglES 3.0 的 [Transform Feedback][link2] 和 Metal 的 [Data-Parallel Compute Processing][link3] 都是用来达到同样的目的的。但是很遗憾 Compute Shader 基本和移动设备无缘了,而 也未能提供给开发者直接驾驭图形接口的能力,([GL.IssuePluginEvent][link4] 似乎可以做到,但是这意味着需要自己处理很多跨平台跨设备的
问题
,感觉有点得不偿失)。在 [Unity Roadmap][link5] 中也未看到任何类似的迹象。
[link1]: https://docs. unity 3d.com/Manual/ComputeShaders.html
[link2]: https://www.opengl.org/wiki/Transform_Feedback
[link3]: https://developer.apple.com/library/content/documentation/Miscellaneous/Conceptual/MetalProgrammingGuide/Compute-Ctx/Compute-Ctx.html#//apple_ref/doc/uid/TP40014221-CH6-SW1
[link4]: https://docs.unity3d.com/ScriptReference/GL.IssuePluginEvent.html
[link5]: https://unity3d.com/cn/unity/roadmap
于是乎一种曲线救国的方式就产生了,当然这里介绍的方法并不能替代 Compute Shader,只是某些特定情况下解决问题的方法,下面我们来仔细看这种方法的实现细节。
其实说来也简单,唯一要用到的就是 RenderTexture。我们可以在 fragment shader 中输出一个颜色值,这个颜色值就是经过一系列复杂计算得到的结果,而这些计算本来只能在 CPU 中进行,再通过某种方式(Texture、Mesh、Uniform Value)传给 GPU(这个传递的过程意味着一次 overhead,在移动设备上虽然没有硬件的物理因素,即使是内存共享也意味着内存的拷贝以及图形对象消耗),这个颜色值最终会被写入到相机所绑定的 RenderTexture 上,这样就得到了一张包含了很多数据的 RenderTexture,只是这些数据表现为颜色。然后将这张 RenderTexture 传给真正用来渲染的着色器,着色器从 RenderTexture 对应位置取出已经计算好的结果值,使用即可。
这是一张尺寸为 128x128 的 RenderTexture,一共存储了 16384(128x128)个复杂运算的结果,如果将这个计算量交给 CPU 来做的话将会是很大一笔开销,这些值在每一帧中重新计算出新的结果,就形成了上图的效果。或许会有这样的疑问,为什么需要将结果存入 RenderTexture,而不是在 Shader 的需要用到的时候直接计算得到呢。这是因为如果是每次都在 Shader 中直接计算出新的结果,就必须有一个符合其数值变化规律的函数:
y = f(x)
或
(x,y) = f(t)
不管自变量和结果如何,必须要找到函数 f()。可是我们无法找到这个函数,因为渲染的结果是和场景有交互的,不可能一个公式搞定所有:
粒子碰撞
毛发模拟
于是就想到了是否能直接在 GPU 中计算,即加快了计算速度,又避免了传输数据的消耗。但又由于 Compute Shader 在移动平台的无效,所以就有了使用 RenderTexture 的这种方法。
大致的概念都说清楚了,下面来看一下实现的细节。也就是如何将数据编码到 RenderTexture 中的某个像素中,并且如何从对应的像素中读取数据。
首先当然是创建一个 RenderTexture:
对创建的 RenderTexture 进行初始化:
创建一个相机,用来向 RenderTexture 中填充数据:
当然要向 RenderTexture 中渲染些东西,并不一定要通过相机:
这里就要开始创建需要渲染 Mesh 了,Mesh 中的每个顶点上的数据都是非常关键的,我们会通过程序代码来创建 Mesh 而不是建模软件,因为顶点中的数据都有其独特的意义,比如说 normal 属性里存储的并不是真正的法线信息,而是我们自己定义的数据。当然如果 Mesh 中每个顶点属性中存储的数据类型完全确定好之后,在 Unity 中实现一个笔刷来让美术刷出这些数据也就并非是难事,这是后话先不说了。
在应用程序中所有需要的东西都创建好了,下面开始渲染:
至此,所有必要的准备都已经完成,然后就是 Shader,前面所做的一切都是为一步在做准备:
数据填充好后,如何在 MainCamera 渲染模型的时候将顶点对应的数据从 RenderTexture 中取出来呢,这就非常简答了:
以上就是要用到的所有关键代码和主要思路了。粒子碰撞和毛发模拟就是使用的这种方式实现的,让并行的 GPU 进行了大量的计算,使得 CPU 为零消耗,而 GPU 也只发挥了大概 25% 不到的能力,也就是说 GPU 还能处理很多其他的事情。FPS 也达到了满帧。 以上数据是在 Instruments 的 GPU Driver 中查看的,使用的测试设备是 Iphone6S。并且在 Metal、OpenGLES3.0、OpenGLES2.0 这三个 Graphics API 下都表现正常。注意实际开发时需要在更多设备上进行测试,并且做好设备不支持时的备选方案。
最后说一个容易被忽视的细节,而这个细节有可能会导致最终出现我们不想要的结果。使用以上方法就意味着对于一张 RenderTexture 的 Texel,同时既从中读取数据又向其写入数据,根据我的理解,这就是 [Sampling and Rendering to the Same Texture][link6] 和 [Feedback Loops][link7] 所指的情况。而我的测试中并没有遇到过,所以先忽略的,可能是测试设备使用了类似文中所提到的 texture_barrier 技术。当然如果真的出现了这种情况也是有方法解决的,我们可以使用两张 RenderTexture 交替使用,在第一帧中从 A 读取,写入 B,第二帧中从 B 读取写入 A,类似双缓冲一样就能解决这个问题了。
[link6]: https://www.opengl.org/wiki/GLSL_:_common_mistakes
[link7]: https://www.opengl.org/wiki/Framebuffer_Object#Feedback_Loops
[link1]: https://docs. unity 3d.com/Manual/ComputeShaders.html
[link2]: https://www.opengl.org/wiki/Transform_Feedback
[link3]: https://developer.apple.com/library/content/documentation/Miscellaneous/Conceptual/MetalProgrammingGuide/Compute-Ctx/Compute-Ctx.html#//apple_ref/doc/uid/TP40014221-CH6-SW1
[link4]: https://docs.unity3d.com/ScriptReference/GL.IssuePluginEvent.html
[link5]: https://unity3d.com/cn/unity/roadmap
于是乎一种曲线救国的方式就产生了,当然这里介绍的方法并不能替代 Compute Shader,只是某些特定情况下解决问题的方法,下面我们来仔细看这种方法的实现细节。
其实说来也简单,唯一要用到的就是 RenderTexture。我们可以在 fragment shader 中输出一个颜色值,这个颜色值就是经过一系列复杂计算得到的结果,而这些计算本来只能在 CPU 中进行,再通过某种方式(Texture、Mesh、Uniform Value)传给 GPU(这个传递的过程意味着一次 overhead,在移动设备上虽然没有硬件的物理因素,即使是内存共享也意味着内存的拷贝以及图形对象消耗),这个颜色值最终会被写入到相机所绑定的 RenderTexture 上,这样就得到了一张包含了很多数据的 RenderTexture,只是这些数据表现为颜色。然后将这张 RenderTexture 传给真正用来渲染的着色器,着色器从 RenderTexture 对应位置取出已经计算好的结果值,使用即可。
这是一张尺寸为 128x128 的 RenderTexture,一共存储了 16384(128x128)个复杂运算的结果,如果将这个计算量交给 CPU 来做的话将会是很大一笔开销,这些值在每一帧中重新计算出新的结果,就形成了上图的效果。或许会有这样的疑问,为什么需要将结果存入 RenderTexture,而不是在 Shader 的需要用到的时候直接计算得到呢。这是因为如果是每次都在 Shader 中直接计算出新的结果,就必须有一个符合其数值变化规律的函数:
y = f(x)
或
(x,y) = f(t)
不管自变量和结果如何,必须要找到函数 f()。可是我们无法找到这个函数,因为渲染的结果是和场景有交互的,不可能一个公式搞定所有:
粒子碰撞
毛发模拟
于是就想到了是否能直接在 GPU 中计算,即加快了计算速度,又避免了传输数据的消耗。但又由于 Compute Shader 在移动平台的无效,所以就有了使用 RenderTexture 的这种方法。
大致的概念都说清楚了,下面来看一下实现的细节。也就是如何将数据编码到 RenderTexture 中的某个像素中,并且如何从对应的像素中读取数据。
首先当然是创建一个 RenderTexture:
[C]
纯文本查看
复制代码
01
02
03
04
05
06
07
08
09
10
|
// C#
// 这里使用的是 HALF_FLOAT 格式,因为我需要在这张 RenderTexture 中存储一个三维空间的坐标
// 如果你不需要,可以设置为 RGB24 即可
rt =
new
RenderTexture(rtSize, rtSize, 0, RenderTextureFormat.ARGBHalf);
// 关闭 mipmap,使用点采样,这样纹理采样的时候不会受 filter 的影响
rt.useMipMap =
false
;
rt.generateMips =
false
;
rt.filterMode = FilterMode.Point;
rt.anisoLevel = 0;
rt.wrapMode = TextureWrapMode.Clamp;
rt.Create();
|
对创建的 RenderTexture 进行初始化:
[C]
纯文本查看
复制代码
01
02
03
04
05
06
07
08
09
10
11
12
|
// C#
// 新创建的 RenderTexture 中会包含显存中的垃圾数据,所以我们使用想要的数据对其初始化
initTex =
new
Texture2D(rtSize, rtSize, TextureFormat.ARGB32,
false
);
Color[] colors = initTex.GetPixels();
int
numColor = colors.Length;
for
(
int
i = 0; i < numColor; ++i)
{
colors[i] =
new
Color(0, 0, 0);
}
initTex.SetPixels(colors);
initTex.Apply();
Graphics.Blit(initTex, positionsRT);
|
创建一个相机,用来向 RenderTexture 中填充数据:
[C]
纯文本查看
复制代码
01
02
03
04
05
06
07
08
09
10
11
|
// C#
GameObject camGo =
new
GameObject();
camGo.hideFlags = HideFlags.HideAndDontSave;
Camera cam = camGo.AddComponent<Camera>();
// 相机默认处于关闭状态,我们会手动调用相机的渲染
cam.enabled =
false
;
// 相机的渲染目标为我们刚才创建的 RenderTexture
cam.targetTexture = rt;
// 不要让相机清除 RenderTexture 上数据,因为每一帧的数据对于下一帧都是有意义的
cam.clearFlags = CameraClearFlags.Nothing;
// 这个相机不需要渲染所有的东西,只渲染我们需要即可
cam.cullingMask = LayerMask.GetMask(
"MyCullingLayer"
);
|
当然要向 RenderTexture 中渲染些东西,并不一定要通过相机:
[C]
纯文本查看
复制代码
1
2
3
4
|
// C#
// 使用这种方式也是可以的,消耗要比使用相机渲染小一点
// 两种方法根据需要进行选择
RenderTexture.active = rt;
Graphics.DrawMeshNow
|
这里就要开始创建需要渲染 Mesh 了,Mesh 中的每个顶点上的数据都是非常关键的,我们会通过程序代码来创建 Mesh 而不是建模软件,因为顶点中的数据都有其独特的意义,比如说 normal 属性里存储的并不是真正的法线信息,而是我们自己定义的数据。当然如果 Mesh 中每个顶点属性中存储的数据类型完全确定好之后,在 Unity 中实现一个笔刷来让美术刷出这些数据也就并非是难事,这是后话先不说了。
[C]
纯文本查看
复制代码
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
|
// C#
List<Vector3> vertices =
new
List<Vector3>();
List<Color> colors =
new
List<Color>();
List<Vector2> uv =
new
List<Vector2>();
List<Vector4> tangents =
new
List<Vector4>();
List<
int
> triangles =
new
List<
int
>();
// 这里开始填充顶点数据
// 具体填充什么数据呢,这个根据要渲染成什么模型有关,每个人都会做出不同的选择
// 比如我们上文中的两个 Demo,一个是粒子碰撞,一个是毛发模拟,这两个 Demo 中在 Mesh 中填充的数据都不一样
// 所以这里暂时跳过,但是通过下面的说明,应该会让我们更清楚这里需要什么数据
mesh =
new
Mesh();
mesh.vertices = vertices.ToArray();
mesh.colors = colors.ToArray();
mesh.uv = uv.ToArray();
mesh.tangents = tangents.ToArray();
mesh.triangles = triangles.ToArray();
// 设定新的包围盒很重要,相机的视锥体裁切都靠它了,我偷了个懒,直接设置为一个很大的包围盒,实际情况需要根据模型的大小来设置
mesh.bounds =
new
Bounds(Vector3.zero,
new
Vector3(9999, 9999, 9999));
|
在应用程序中所有需要的东西都创建好了,下面开始渲染:
[C]
纯文本查看
复制代码
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
|
// C#
// 这是 MainCamera 的回调,在 MainCamera 渲染之前,Unity 会自动回调这个函数
void
OnPreRender()
{
// 上文说过,要渲染一个模型(向 RenderTexture 填充数据)有两种方法
// 方法1
// cam 是上文中有代码创建的相机
// rt 是上文中用代码创建的 RenderTexture
cam.targetTexture = rt;
cam.RenderWithShader(Shader.Find(
"FillRenderTextureShader"
), null);
// 方法2
if
(mat == null)
{
mat =
new
Material(Shader.Find(
"FillRenderTextureShader"
));
}
RenderTexture oldRT = RenderTexture.active;
RenderTexture.active = rt;
mat.SetPass(0);
Graphics.DrawMeshNow(mesh, Matrix.Identify);
// 这里的矩阵,因为我们在 Shader 中并没有用到,所以直接设置为单位(任意)矩阵
RenderTexture.active = oldRT;
}
|
至此,所有必要的准备都已经完成,然后就是 Shader,前面所做的一切都是为一步在做准备:
[C]
纯文本查看
复制代码
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
|
// FillRenderTextureShader
// vertex shader
v2f vert(appdata v)
{
// o.vertex 直接决定了将当前 fragment 输出到 RenderTexture 上的哪个像素中
// o.vertex.xy 经过投影变换后的值都是在 -1 到 1 之间
// 我们需要知道当前应该输出到 [-1,1] 之间的哪个值上,这就需要在上文中创建 Mesh 时填充顶点数据时指定好,这里直接读取即可
// o.vertex.z 这个值我们其实用不到,但是不能随便设置,因为 OpenGL 是 [-1,1],而 DirectX 是 [0,1],
// 超出这个范围会被裁切掉,所以要同时兼顾到,设置为 0
// o.vertex.w 这是用来做齐次坐标变换的,将顶点转换到 Canonical View Volume。简单来说最终的会将 o.vertex.xy 除以 w,来转换到齐次裁剪空间坐标系,
// 但是我们不希望进行这个操作,以免破坏了精心计算的 o.vertex.xy,所以设置为 1
o.vertex = ......;
// 这是用来解决平台差异的
// 因为 OpenGL 的纹理坐标 (0,0) 点在左下角,而 DirectX 的纹理坐标 (0,0) 点在左上角
#if UNITY_UV_STARTS_AT_TOP
float
scale = -1.0;
#else
float
scale = 1.0;
#endif
o.vertex.y *= scale;
// 在这里进行一系列的计算,将计算结果存放到 color 中
i.color = ......
}
// 注意这里的返回值类型,因为用它表示三维空间中的坐标,所以使用 float
// 同样 v2f 结构中 color 的类型也要注意
float4 frag(v2f i) : SV_Target
{
return
i.color;
}
|
数据填充好后,如何在 MainCamera 渲染模型的时候将顶点对应的数据从 RenderTexture 中取出来呢,这就非常简答了:
[C]
纯文本查看
复制代码
1
2
|
// Shader
// uv 也是在创建 Mesh 的时候就已经指定好了,直接从顶点数据中拿来用即可
tex2D(_RenderTexture, uv);
|
以上就是要用到的所有关键代码和主要思路了。粒子碰撞和毛发模拟就是使用的这种方式实现的,让并行的 GPU 进行了大量的计算,使得 CPU 为零消耗,而 GPU 也只发挥了大概 25% 不到的能力,也就是说 GPU 还能处理很多其他的事情。FPS 也达到了满帧。 以上数据是在 Instruments 的 GPU Driver 中查看的,使用的测试设备是 Iphone6S。并且在 Metal、OpenGLES3.0、OpenGLES2.0 这三个 Graphics API 下都表现正常。注意实际开发时需要在更多设备上进行测试,并且做好设备不支持时的备选方案。
最后说一个容易被忽视的细节,而这个细节有可能会导致最终出现我们不想要的结果。使用以上方法就意味着对于一张 RenderTexture 的 Texel,同时既从中读取数据又向其写入数据,根据我的理解,这就是 [Sampling and Rendering to the Same Texture][link6] 和 [Feedback Loops][link7] 所指的情况。而我的测试中并没有遇到过,所以先忽略的,可能是测试设备使用了类似文中所提到的 texture_barrier 技术。当然如果真的出现了这种情况也是有方法解决的,我们可以使用两张 RenderTexture 交替使用,在第一帧中从 A 读取,写入 B,第二帧中从 B 读取写入 A,类似双缓冲一样就能解决这个问题了。
[link6]: https://www.opengl.org/wiki/GLSL_:_common_mistakes
[link7]: https://www.opengl.org/wiki/Framebuffer_Object#Feedback_Loops