要点 地块渲染主要使用了RenderBatchGroup
来避免过多的GameObject,并辅助以有BurstCompiler
加持的裁剪函数以加速渲染。
细节 需求分析 目前有个需求是,我们可以在地图上绘制若干片不同类型的区域,对于每种区域,地块需要有不同的颜色叠加,我们需要给每个地块设置一个颜色属性。
所以目前就无法使用SRP Batcher
来辅助合批了,因为这玩意目前并不支持通过MaterialPropertyBlock
来设置PerObject
的属性(给同一批次的每个渲染对象设置不同的属性)。当然SRP Batcher
支持设置PerMaterial
的属性,因此遇到使用同一个Shader的不同材质时,就可以对其进行合批,我会在之后其他元素的渲染时利用到这个特性。
我最终使用的还是传统的GPU Instancing
的方案,这样就可以满足目前的需求了。
具体实现 在初始化地块渲染模块的方法里,加入:
1 _batchGroup = new BatchRendererGroup(OnCulling);
构造函数里有一个参数,是我们自定义的裁剪方法,这个后面再说。
BatchRendererGroup
的用法是,使用AddBatch()
方法添加一个渲染批次,于是这个group就会根据我们设置的方式在每帧自动渲染这些网格。于是核心的方法是UpdateBatch()
,当地块数据发生变化时(包括第一次初始化),我们就需要重新规划渲染批次。当然对于地块这种静态物体,数据变化时更新一下Batch信息就行了,如果是动态物体的话,还需要在每帧都更新每个网格的世界坐标矩阵。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 public void UpdateBatch (){ while (_batchGroup.GetNumBatches() > 0 ) { _batchGroup.RemoveBatch(0 ); } Dictionary<string , BatchIdCollection> meshes = CollectMeshes(out var meshCnt); _bounds = new NativeArray<AABB>(meshCnt, Allocator.Persistent); var boundIdx = 0 ; var jobDep = new JobHandle(); var resModule = GetModule<ResourceModule>(); foreach (var kvp in meshes) { var mesh = resModule.GetMesh(kvp.Key); var mat = resModule.GetMaterial(kvp.Key); for (var i = 0 ; i < kvp.Value.Batches; ++i) { var count = kvp.Value.GetCount(i); var batchId = _batchGroup.AddBatch( mesh, 0 , mat, 0 , ShadowCastingMode.On, true , false , _mapBounds, count, null , null ); var transforms = _batchGroup.GetBatchMatrices(batchId); var colors = _batchGroup.GetBatchVectorArray(batchId, TintShaderPropId); var tileIds = kvp.Value.Get(i); var localBounds = mesh.bounds.ToAABB(); var job = new SetBatchDataJob { MapHeight = GameUtils.MapHeight, TintColorLut = _tintColorLut, TileIds = tileIds, LocalBounds = localBounds, TintColors = _tintColors, BoundOffset = boundIdx, Transforms = transforms, Colors = colors, AllBounds = _bounds }; jobDep = job.Schedule(count, 64 , jobDep); boundIdx += count; } } jobDep.Complete(); }
必要的细节在上述代码种已经有比较详细的说明了。SetBatchDataJob
的代码就不贴了做的事情非常简单,就是把地块坐标填到transforms数组里面,然后把坐标加上本地裁剪框计算出世界裁剪框。
裁剪函数 同样是基于GPU Instancing
来渲染,使用BatchRendererGroup
相比于Graphics.DrawMeshInstanced()
的优势在于:
前者可以通过使用自定义的裁剪函数来对每一个要渲染的物体进行裁剪计算 而后者只能作为一个整体,要么就都参与渲染,要么就整体被裁剪,都不渲染 前者使用的数据结构都是NativeContainer,兼容Burst编译器 考虑到实际的需求,渲染的整体范围是非常大的(整个地图),所以为了做裁剪,我们选用BatchRendererGroup
的自定义裁剪函数。裁剪函数的委托格式如下:
1 2 3 public delegate JobHandle OnPerformCulling ( BatchRendererGroup rendererGroup, BatchCullingContext cullingContext ) ;
首先发现,这个函数的返回值是一个JobHandle
,显然Unity是推荐我们把裁剪工作Job化,交给Unity调度,而不用非得在这个方法里全部计算完。第一个参数是调用该裁剪函数的Group,如果你有多个Group使用了同样的裁剪函数,可以用这个参数来区分。
重点看下第二个参数:BatchCullingContext。这里面有4个属性:
batchVisibility,用于获取每个Batch的对象数量和索引偏移,以及指定可见对象的数量(通过visibleCount
字段) visibleIndices,用于指定哪些对象是可见的 cullingPlanes,只读,当前摄像机的裁剪平面,是一个长度为6的数组 lodParameters,只读,用于计算LOD,包括摄像机的位置,FoV信息等 其中visibleIndices
很有迷惑性,多次尝试过后,应当按下图中的方式进行填充:
如果不按这个方式填充的话。。渲染会正常进行,只不过渲染结果大概率是完全错乱的(比如模型顶点各自不停地在屏幕上到处鬼畜orz)
裁剪算法非常简单,就是依次判断每个批次里的对象的裁剪框有没有在视锥体外面,如果不是完全在外面的话,就把这个对象的索引记录到visibleIndices
里面。每个批次需要统计可见对象的数量,而各个批次之间是完全独立的,所以我们以批次为单位来并行执行。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 private JobHandle OnCulling (BatchRendererGroup group , BatchCullingContext context ){ var cnt = context.batchVisibility.Length; var job = new CullingJob { CullingPlanes = FrustumPlanes.BuildSOAPlanePackets(context.cullingPlanes, Allocator.TempJob), Boundses = _bounds, Batches = context.batchVisibility, Indices = context.visibleIndices }; var handle = job.Schedule(cnt, 4 , _cullingDep); _cullingDep = JobHandle.CombineDependencies(handle, _cullingDep); return handle; } [BurstCompile ] private struct CullingJob : IJobParallelFor{ [ReadOnly ] [DeallocateOnJobCompletion ] public NativeArray<FrustumPlanes.PlanePacket4> CullingPlanes; [ReadOnly ] public NativeArray<AABB> Boundses; public NativeArray<BatchVisibility> Batches; [NativeDisableParallelForRestriction ] public NativeArray<int > Indices; public void Execute (int batchIdx ) { var bv = Batches[batchIdx]; var count = 0 ; for (var i = 0 ; i < bv.instancesCount; ++i) { var result = FrustumPlanes.Intersect2(CullingPlanes, Boundses[bv.offset + i]); if (result != FrustumPlanes.IntersectResult.Out) { Indices[bv.offset + count] = i; count++; } } bv.visibleCount = count; Batches[batchIdx] = bv; } }
性能分析和优化方向 性能瓶颈 只分析UpdateBatch这个方法的热点分布,如下图:
发现添加和Batch和设置每个对象的数据都还是比较快的,唯一的性能瓶颈在于CollectMeshes()
方法(占比接近80%)。由于现在每次计算都会遍历全部10000个(默认设置)地块,每个微小的操作都要重复执行一万次,这里浪费了许多不必要的计算。之后的优化目标是按需更新,因为只有地块变化时才会触发渲染批次更新,所以我们只要封装好操作地块的接口,每次更新时只重新计算其中一部分,应该可以好很多。
LOD 目前所有的地块模型(室外,室内,墙壁)都只有一个LOD,如果到后边模型变得复杂起来,就可能需要用到LOD来减轻GPU压力。方案也十分简单,每次AddBatch的时候同时把其他LOD的批次都添加进去,别忘了裁剪函数里有个参数lodParameter
,我们可以根据这个参数提供的摄像机位置来判断选用哪个细节等级的模型。