系列文章导航
本系列文章是关于本人的开源项目 URasterizer: A software rasterizer on top of Unity, accelerated by Job system & Compute Shader的总结和介绍,一共四篇。 第一篇:基于Unity的软光栅实现(1):框架搭建和矩阵构造 第二篇:基于Unity的软光栅实现(2):CPU单线程软光栅 第三篇:基于Unity的软光栅实现(3):基于Job system的多核加速光栅化 (本篇) 第四篇:基于Unity的软光栅实现(4):GPU Driven的光栅化流水线
拥抱CPU多核计算
在上一篇中,我们实现了CPU上的单线程软光栅,不出所料的是FPS很低。其实这已经是经过了一些优化,比如消除GC Alloc,使用快速的数组填充方法等。可惜代码本身的优化余地并不大,且被托管代码的性能所拖累。而现代CPU的发展方向早已经不是拼核心频率,多核计算已经不可忽视了。对于游戏引擎来说,经过10多年的发展,已经从单线程,到主线程+渲染线程+多线程加载,进化到主线程+渲染线程+多个工作线程并行完成任务。Unity也从2018.1开始支持Job System,作为其DOTS技术栈的基石。
Job System简介
使用Job System可以进行多线程编程,但Job System并不是简单的多线程。Job System通过创建Job来管理多线程代码,而不是直接使用线程。我们先看看直接使用线程有什么问题吧。当你写多线程程序时,往往会需要很多线程。尽管可以使用线程池优化线程的创建和回收,但仍然有多个线程处于激活状态。由于CPU核心少而线程多,线程之间会相互竞争核心资源。这会导致多个线程来回切换会产生开销。而Job System优化了这一点,Job System内部为每个CPU的逻辑核心创建一个工作线程,然后将不同的Job分配给这些工作线程去运行。Job被组织成队列,工作线程从队列中取出Job并执行,同时Job System可以管理Job之间的依赖,按照依赖顺序来执行不同的Job。每个核心一个工作线程一直进行,就不再需要来回切换线程(当然其他进程还是会占用核心),Job虽然多,但是大家都是排队执行的,没有切换线程的开销了。通过Job System,我们可以即方便又高效的写多线程代码。 多线程的另一个问题是Race Conditions,不同线程同时读写同一个变量,会造成非常隐晦的bug。Job System为了避免Race Conditions,做了两件事。首先,Job 中只能使用 blittable 数据类型。所谓blittable类型是指在托管代码中和非托管代码互相传递时,不需要转换的类型,具体可参考微软.net文档。Unity会向每个job传送数据的拷贝,这样这些数据就不会被多个job所影响。但是,我们往往需要Job操作大量数据,总不能全部copy吧,因此Unity提供了NativeContainer,顾名思义,这其实是将数据直接保存在Native内存中,可以直接在Job中访问而不需要copy进来。Job System的Safety system会跟踪对NativeContainer的读和写,因此可以检查出越界,内存泄漏以及race condition。NativeContainer即可以作为输入也可以作为输出,我们使用了其中一种类型:NativeArray。
ParallelFor Job
本文并不是Job System的教程(其实看官方文档足矣),只简要说一下URasterizer的JobRasterizer使用到的Job类型吧。很显然,我们有大量的顶点和三角形要处理,所以我们希望能并行计算它们,而Job System提供的 IJobParallelFor 接口,就可以很方便的并行计算很多数据:
public interface IJobParallelFor
{
void Execute(int index);
}
实现这个接口的Job被核心执行时,会在Execture方法中传入当前计算的数据索引,通过这个索引找到当前需要计算的数据进行计算。而索引的范围,由调度该Job时指定的数组的长度来决定,比如以顶点数作为数组长度去调度Job,那么每个Execute中得到的索引就是[0,VertexCount-1]。调度Job时具体指定什么样的数组长度,由具体的需求决定,需要注意的是,填入多大的长度数,就会有多少次Job被执行,这其实和数组没有什么关系,比如你输入的数据并不是数组,但是你就想重复执行Job N次,那么你就填N就行了,然后Exectue被调用时,你会得到[0,N-1]范围之内的某一个索引。当然了,我们正常的使用,是会输入一个或多个数组,然后调度时指定数组长度,Exectue中使用索引值来索引数组数据,URasterizer的JobRasterizer就是这么做的。
JobRasterizer
URasterizer项目中,基于Job system的光栅化渲染器类是JobRasterizer。它同样实现了IRasterizer接口,所以基本框架和CPURasterizer一样,只是具体计算的过程放到了Job中。且由于使用了Job,需要的数据也不太一样。这篇文章可以看做入门Job System的一个例子,其中也会总结遇到的一些问题和解决方案。主要参考了Unity的文档,以及Unity在GDC上的一个演讲。 下图是JobRasterizer在Unity Profiler中的表现,可以看到很多并行的Job在执行:
数据准备:JobRenderObjectData
public class JobRenderObjectData : IRenderObjectData
{
public NativeArray<Vector3> positionData;
public NativeArray<Vector3> normalData;
public NativeArray<Vector2> uvData;
public NativeArray<Vector3Int> trianglesData;
public JobRenderObjectData(Mesh mesh)
{
positionData = new NativeArray<Vector3>(mesh.vertexCount, Allocator.Persistent);
positionData.CopyFrom(mesh.vertices);
normalData = new NativeArray<Vector3>(mesh.vertexCount, Allocator.Persistent);
normalData.CopyFrom(mesh.normals);
uvData = new NativeArray<Vector2>(mesh.vertexCount, Allocator.Persistent);
uvData.CopyFrom(mesh.uv);
var mesh_triangles = mesh.triangles;
int triCnt = mesh_triangles.Length/3;
trianglesData = new NativeArray<Vector3Int>(triCnt, Allocator.Persistent);
for(int i=0; i < triCnt; ++i){
int j = i * 3;
trianglesData[i] = new Vector3Int(mesh_triangles[j+1], mesh_triangles[j], mesh_triangles[j+2]);
}
}
public void Release()
{
positionData.Dispose();
normalData.Dispose();
uvData.Dispose();
trianglesData.Dispose();
}
}
渲染数据方面,不能直接使用顶点属性的数组了,如上所述,Job中需要使用NativeContainer。这里使用了4个NativeArray,分别是顶点坐标数据,顶点法线数据,顶点uv数据和三角形索引数据。注意我们对于每个需要渲染的Mesh,其NativeArray数据需要在程序运行期间一直存在,因此NativeArray的Allocatro Type选择 Allocator.Persistent。这样我们必须在程序退出时调用NativeArray的Dispose()方法。否则Safety System就会检测到内存泄漏。NativeArray的初始化,还需要指定数据元素的类型和数量。对于顶点坐标法线和UV,使用Vector3或Vector2即可,数量自然是mesh的vertexCount。而对于三角形索引,我们使用Vector3Int类型,数量为三角形数量,即索引数除以3。另外,正如我们在上篇所说,需要调整三角形环绕方向,这里由于我们要准备三角形数据,因此就同时进行了调整。向NativeArray中传入数据,如果已经有现成的c#数据数组,可以使用CopyFrom方法,比如这儿的坐标、法线和UV数组。否则就必须在循环中设置数组元素值了,比如这儿的trianglesData。
缓冲区表示和Clear
由于我们使用Job进行计算,且在Job中写入缓冲区,因此缓冲区也要使用NativeArray:
NativeArray<Color> _frameBuffer;
NativeArray<float> _depthBuffer;
初始化如下:
int bufSize = w * h;
_frameBuffer = new NativeArray<Color>(bufSize, Allocator.Persistent);
_depthBuffer = new NativeArray<float>(bufSize, Allocator.Persistent);
然后对于缓冲区的清除,使用了清除一个临时数组,然后使用NativeArray的CopyFrom方法一次性copy:
public void Clear(BufferMask mask)
{
ProfileManager.BeginSample("JobRasterizer.Clear");
if ((mask & BufferMask.Color) == BufferMask.Color)
{
URUtils.FillArray<Color>(temp_buf, _config.ClearColor);
_frameBuffer.CopyFrom(temp_buf);
}
if((mask & BufferMask.Depth) == BufferMask.Depth)
{
_depthBuffer.CopyFrom(temp_depth_buf);
}
_trianglesAll = _trianglesRendered = 0;
_verticesAll = 0;
ProfileManager.EndSample();
}
这里没有尝试使用若干Job去同时清除缓冲区,毕竟CPU核心数量没那么多。
渲染流程
Job规划
我们使用两个Job类去进行整个渲染:顶点Job和三角形Job。即先变换顶点,然后光栅化和渲染三角形。为啥不直接处理三角形呢?因为三角形会共享顶点,直接处理三角形就会造成一些顶点被重复处理。虽然我们目前的顶点操作比较简单,开销不大,但如果顶点操作很复杂,就会浪费性能。
顶点Job调度
NativeArray<VSOutBuf> vsOutResult = new NativeArray<VSOutBuf>(mesh.vertexCount, Allocator.TempJob);
VertexShadingJob vsJob = new VertexShadingJob();
vsJob.positionData = ro.jobData.positionData;
vsJob.normalData = ro.jobData.normalData;
vsJob.mvpMat = mvp;
vsJob.modelMat = _matModel;
vsJob.normalMat = normalMat;
vsJob.result = vsOutResult;
JobHandle vsHandle = vsJob.Schedule(vsOutResult.Length, 1);
首先,我们要初始化输出的NativeArray,类型为 VSOutBuf,这和CPURasterizer的顶点输出类型一致。
public struct VSOutBuf
{
public Vector4 clipPos;
public Vector3 worldPos;
public Vector3 objectNormal;
public Vector3 worldNormal;
}
由于顶点处理输出的数据对于每帧每个draw call都是不同的,所以它其实是临时数据,我们制定其内存分配类型为Allocator.TempJob,这种分配类型比Persistent快很多,可以传递给Job,且是线程安全的。但是使用后需要Dispose它,至少在4帧之类Dispose。在这里我们是在渲染结束后Dispose的,所以没问题。 之后,我们实例化一个 VertexShadingJob。设置它的输入输出数据,然后使用vsJob.Schedule 去调度它。所谓调度就是让Job System开始执行这个Job,但是该Job在调度方法返回后并没有执行完毕,需要使用Complete保证其已经执行完毕,Complete会让当前线程等待Job执行完成。这儿我们并没有Complete这个vsJob,因为它会被三角形Job所依赖,所以我们等待三角形Job完成就行。注意顶点Job调度时指定的数组长度,是vsOutResult.Length ,也就是顶点数。这表示每个顶点都会执行一次该Job。
三角形Job调度
TriangleJob triJob = new TriangleJob();
triJob.trianglesData = ro.jobData.trianglesData;
triJob.uvData = ro.jobData.uvData;
triJob.vsOutput = vsOutResult;
triJob.frameBuffer = _frameBuffer;
triJob.depthBuffer = _depthBuffer;
triJob.screenWidth = _width;
triJob.screenHeight = _height;
triJob.TextureData = ro.texture.GetPixelData<URColor24>(0);
triJob.TextureWidth = ro.texture.width;
triJob.TextureHeight = ro.texture.height;
triJob.UseBilinear = _config.BilinearSample;
triJob.fsType = _config.FragmentShaderType;
triJob.Uniforms = Uniforms;
JobHandle triHandle = triJob.Schedule(ro.jobData.trianglesData.Length, 2, vsHandle);
triHandle.Complete();
vsOutResult.Dispose();
- 初始化和设置数据
TriangleJob同样很简单的实例化和设置输入输出,它需要的数据更多些,注意我们将上一步顶点Job输出的vsOutResult作为输入传入了TriangleJob。另外TriangleJob需要传入frameBuffer和depthBuffer,这两个也是NativeArray,但其长度是屏幕的像素数。 - 调度
Schedule方法传入的数组长度是三角形数量,因此Execute方法获取到的索引是三角形索引,Job每次执行处理一个三角形。光栅化时会获取到需要处理的像素的实际位置,因此输入的数组的长度并不一定要匹配调度时的数组长度,具体还是看怎么用。Schedule的第二个参数是内部循环batch的数量,即一次执行多次Job,这样需要调度执行的总Job实例数就会减少,但是调度的粒度会变大,有可能不能充分并行执行,这个数一般从1开始增大测试,看效果。这边使用2是测试后找到比较好的值。第三个参数则是依赖Job的handle, 这儿TriangleJob依赖于VertexShadingJob,因此填入vsHandle。Job System会在执行TriangleJob之前确保VertexShadingJob先执行完成。 - Complete
调度之后,由于我们没有其他的操作了,就只能调用Complete()等待Job执行完成。如果使用Unity Profiler就会发现,主线程一直在等待Job完成。其实如果有其他操作,可以先做其他操作,再一帧的最后再调用Complete。 - Schedule和Complete的时机
Unity在GDC上的一个视频讲了如何有效的安排Schedule和Complete,这是一个Job System实现粒子系统的例子,更新流程如下。 job的schedule越早越好,而complete越晚越好。因为complete是等待完成。这儿粒子系统的情况下,可以延迟一帧,每一帧开始时执行complete,获取job计算好的前一帧的粒子数据,然后渲染前一帧的粒子。然后schedule这一帧的模拟:
Job实现
VertexShadingJob
[BurstCompile]
public struct VertexShadingJob : IJobParallelFor
{
[ReadOnly]
public NativeArray<Vector3> positionData;
[ReadOnly]
public NativeArray<Vector3> normalData;
public Matrix4x4 mvpMat;
public Matrix4x4 modelMat;
public Matrix4x4 normalMat;
public NativeArray<VSOutBuf> result;
public void Execute(int index)
{
var vert = positionData[index];
var normal = normalData[index];
var output = result[index];
var objVert = new Vector4(vert.x, vert.y, -vert.z, 1);
output.clipPos = mvpMat * objVert;
output.worldPos = modelMat * objVert;
var objNormal = new Vector3(normal.x, normal.y, -normal.z);
output.objectNormal = objNormal;
output.worldNormal = normalMat * objNormal;
result[index] = output;
}
}
顶点Job输入所有的顶点属性(UV除外),然后在Job中计算并输出光栅化和渲染阶段需求使用的属性值:NativeArray<VSOutBuf> result 。 对于输入的NativeArray由于是只读的,因此可以使用[ReadOnly] 属性标记只读来提高性能。 计算需要的矩阵可直接使用Matrix4x4表示,因为Matrix4x4是blitable类型,所以每个Job实例都会copy一份进来使用。 顶点Job是一个ParallelFor Job,因此Execute方法会获取当前执行操作的索引值。注意计算时会将坐标和法线从左手系转换到右手系。最后计算结果保存到result中。前面说过,三角形Job会继续使用NativeArray result的内容,因此三角形Job是依赖于顶点Job的。
TriangleJob
代码较多,不贴了,请前往git查看。重点关注一下输入输出数据:
[ReadOnly]
public NativeArray<Vector3Int> trianglesData;
[ReadOnly]
public NativeArray<Vector2> uvData;
[ReadOnly]
public NativeArray<VSOutBuf> vsOutput;
[NativeDisableParallelForRestriction]
public NativeArray<Color> frameBuffer;
[NativeDisableParallelForRestriction]
public NativeArray<float> depthBuffer;
public int screenWidth;
public int screenHeight;
[ReadOnly]
public NativeArray<URColor24> TextureData;
public int TextureWidth;
public int TextureHeight;
public bool UseBilinear;
public ShaderType fsType;
public ShaderUniforms Uniforms;
输入数据中的 trianglesData和uvData都是获取自JobRenderObjectData的NativeArray,都是ReadOnly的。另外一个输入数组是前面顶点Job输出的 vsOutBuf 数据,在这个Job中,它也是ReadOnly的。然后看输出的缓冲区,其实就是上面JobRasterizer中定义的颜色缓冲区和深度缓冲区NativeArray,先忽略上面的NativeDisableParallelForRestriction属性,后面会说。另外,对于纹理数据,上篇中说过,使用NativeArray TextureData。其他的还有一些blittable类型的数据,如屏幕宽高,纹理宽高,shader类型,Uniform等。
Execute中的数据获取
Job中的计算方法和CPURasterizer一样,不同的只是数据的类型。由于我们这儿是针对三角形计算的,所以调度时传入的是三角形数组的长度,因此Execute的索引就是三角形的索引。使用这个index去trianglesData中获取三角形数据,即一个Vector3Int,其每个元素都指向一个顶点。然后使用这些顶点的索引去获取每个顶点的数据,之后组装出我们内部使用的Triangle结构,进行光栅化和渲染。
public void Execute(int index)
{
Vector3Int triangle = trianglesData[index];
int idx0 = triangle.x;
int idx1 = triangle.y;
int idx2 = triangle.z;
var v0 = vsOutput[idx0].clipPos;
var v1 = vsOutput[idx1].clipPos;
var v2 = vsOutput[idx2].clipPos;
if (Clipped(v0, v1, v2))
{
return;
}
v0.x /= v0.w;
v0.y /= v0.w;
v0.z /= v0.w;
v1.x /= v1.w;
v1.y /= v1.w;
v1.z /= v1.w;
v2.x /= v2.w;
v2.y /= v2.w;
v2.z /= v2.w;
{
Vector3 t0 = new Vector3(v0.x, v0.y, v0.z);
Vector3 t1 = new Vector3(v1.x, v1.y, v1.z);
Vector3 t2 = new Vector3(v2.x, v2.y, v2.z);
Vector3 e01 = t1 - t0;
Vector3 e02 = t2 - t0;
Vector3 cross = Vector3.Cross(e01, e02);
if (cross.z < 0)
{
return;
}
}
{
v0.x = 0.5f * screenWidth * (v0.x + 1.0f);
v0.y = 0.5f * screenHeight * (v0.y + 1.0f);
v0.z = v0.z * 0.5f + 0.5f;
v1.x = 0.5f * screenWidth * (v1.x + 1.0f);
v1.y = 0.5f * screenHeight * (v1.y + 1.0f);
v1.z = v1.z * 0.5f + 0.5f;
v2.x = 0.5f * screenWidth * (v2.x + 1.0f);
v2.y = 0.5f * screenHeight * (v2.y + 1.0f);
v2.z = v2.z * 0.5f + 0.5f;
}
Triangle t = new Triangle();
t.Vertex0.Position = v0;
t.Vertex1.Position = v1;
t.Vertex2.Position = v2;
t.Vertex0.Normal = vsOutput[idx0].objectNormal;
t.Vertex1.Normal = vsOutput[idx1].objectNormal;
t.Vertex2.Normal = vsOutput[idx2].objectNormal;
t.Vertex0.Texcoord = uvData[idx0];
t.Vertex1.Texcoord = uvData[idx1];
t.Vertex2.Texcoord = uvData[idx2];
t.Vertex0.Color = Color.white;
t.Vertex1.Color = Color.white;
t.Vertex2.Color = Color.white;
t.Vertex0.WorldPos = vsOutput[idx0].worldPos;
t.Vertex1.WorldPos = vsOutput[idx1].worldPos;
t.Vertex2.WorldPos = vsOutput[idx2].worldPos;
t.Vertex0.WorldNormal = vsOutput[idx0].worldNormal;
t.Vertex1.WorldNormal = vsOutput[idx1].worldNormal;
t.Vertex2.WorldNormal = vsOutput[idx2].worldNormal;
RasterizeTriangle(t);
}
Job中访问纹理
Job中不能访问Texture2D,
使用 NativeArray Texture2D.GetPixelData(int mipLevel);
可以获取到NativeArray。注意这儿的T ,是按照类型T 去解析获取到的数据,比如byte ,则返回的数组中每项都是一个byte ,Color32 则每项都是一个32位的颜色。具体T 是啥,要看贴图的像素格式了,比如RBGA32贴图就可以用Color32 。一开始我就是用Color32 ,但访问时总是数组越界,打印出来数组的长度,对于512的贴图,是32768,实际应该是262144。被坑了半天才发现,贴图默认是自动格式,这样在windows上是DXT1 DXT1一个像素可以压缩为原先的1/8,因此数组长度正好是原来的1/8 由于我使用的贴图都是24位的,因此直接设置为RGB 24bit。但是Unity没有 Color24的类型,那么就自己定义了一个。 另外由于这只是一个数组,需要使用 uv坐标去采样还得知道贴图的宽高,因此也要传进来。
关于[NativeDisableParallelForRestriction]
在Parallel Job里面进行光栅化三角形时,多个三角形有可能并行访问depth buffer/frame buffer的相同地方。这在多线程编程中属于race conditions,Job system内部会检测出来,会直接报错:
IndexOutOfRangeException: Index 219108 is out of restricted IJobParallelFor range [4392…4392] in ReadWriteBuffer. ReadWriteBuffers are restricted to only read & write the element at the job index. You can use double buffering strategies to avoid race conditions due to reading & writing in parallel to the same elements from a job.
报错的代码为:
if(zp >= depthBuffer[index])
{
depthBuffer[index] = zp;
frameBuffer[index] = xxx;
}
看到这个错误,我觉得几乎做不下去了。好在Unity提供了一个attribute[NativeDisableParallelForRestriction] 可以关闭这个检查:
[NativeDisableParallelForRestriction]
public NativeArray<Color> frameBuffer;
[NativeDisableParallelForRestriction]
public NativeArray<float> depthBuffer;
关闭后可以正常运行了,看起来挺正常。但是这样会有问题吗?
假设互相重叠的三角形A和B同时光栅化到某个像素的位置,A和B都通过了depth test,然后同时更新depth buffer,对于多线程来说这就是 race condition,有一半的几率写入错误的深度值,然后下面的frame buffer也有一半的几率写错。所以很遗憾,这么做确实有可能出问题。不过这种事情发生的机率还是很低的,首先我们有大量的三角形,然后CPU的核心数只有几个,所以两个重叠的三角形恰好同时在两个核心上运行线程的几率很低,并且两个三角形会光栅化大量的像素,因此正好重叠的像素同时写入的几率就更低了。而且重要的是,我们是在写渲染程序,而不是发射火箭。如果这里真的发生错误了,程序并不会因此crash,只是渲染结果偶然看上去有点瑕疵,然后很快就正常了,然后可能要等很久才能又发现一点点不对劲。所以极低几率的坏事发生并没什么大的影响,因此我认为可以这么使用。
Burst Compile
Burst is a compiler, it translates from IL/.NET bytecode to highly optimized native code using LLVM. It is released as a unity package and integrated into Unity using the Unity Package Manager.
仅仅是使用Job System,使用多核提升光栅化计算速度,性能就可以提升好几倍。但是我加上Burst Compile之后,性能直接起飞。
由于本文篇幅以及太长,所以不详细写Burst Compile了。实际我也仅仅是简单应用一下。给Job加上一个标签即可:
[BurstCompile]
public struct TriangleJob : IJobParallelFor
[BurstCompile]
public struct VertexShadingJob : IJobParallelFor
当然加上[BurstCompile]后如果发现编译错误,那说明你的Job代码不满足Burst的要求,需要修改。 具体可查看 Burst的文档
|