《乡村铁路》开发日志4-新地块

要点

地块渲染主要使用了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);
}

// 每种Mesh/Material的组合为1个渲染批次
// 返回值字典的Key为组合的标识,Value则用于收集地块的坐标
Dictionary<string, BatchIdCollection> meshes = CollectMeshes(out var meshCnt);

// meshCnt是所有Mesh的总数量,用于计算裁剪框
_bounds = new NativeArray<AABB>(meshCnt, Allocator.Persistent);
var boundIdx = 0;
var jobDep = new JobHandle();

// ResourceModule是用于加载模型资源的模块,和本文无关
var resModule = GetModule<ResourceModule>();

foreach (var kvp in meshes)
{
var mesh = resModule.GetMesh(kvp.Key);
var mat = resModule.GetMaterial(kvp.Key);
// 依次给BatchRendererGroup添加渲染批次
// 根据Unity的限制,每个Batch最多只能渲染1023个对象,超出会出现奇怪的问题(比如编辑器直接崩溃。。)
// 所以对于数量太多的Mesh/Material组合,我们也需要将其分成多个Batch依次渲染
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
);
// 其中上面的_mapBounds是整个地图的范围,当该范围不在摄像机的裁剪视锥内时,整个Batch将会被自动裁剪

// 从刚刚添加的Batch中获取表示世界变换矩阵以及叠加颜色属性的数组
var transforms = _batchGroup.GetBatchMatrices(batchId);
var colors = _batchGroup.GetBatchVectorArray(batchId, TintShaderPropId);
// 这个是该批次的地块位置ID数组,我们可以通过ID来计算出对应的坐标
var tileIds = kvp.Value.Get(i);
// 该批次所渲染的网格的包围框,以本地坐标表示
var localBounds = mesh.bounds.ToAABB();

// 接下来要给每个要对象都设置坐标以及颜色,而这个操作对于每个对象之间是相互独立的
// 所以我们安排一个Job来执行这些操作
// 这个Job还兼容使用Burst编译器,优化后的运行时消耗几乎可忽略不计
var job = new SetBatchDataJob
{
// 前两个是常量,为了兼容Burst,需要每次手动传入
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;
}
}
// 最后等待完成前面安排的所有Jobs
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个属性:

  1. batchVisibility,用于获取每个Batch的对象数量和索引偏移,以及指定可见对象的数量(通过visibleCount字段)
  2. visibleIndices,用于指定哪些对象是可见的
  3. cullingPlanes,只读,当前摄像机的裁剪平面,是一个长度为6的数组
  4. lodParameters,只读,用于计算LOD,包括摄像机的位置,FoV信息等

其中visibleIndices很有迷惑性,多次尝试过后,应当按下图中的方式进行填充:

fig01

如果不按这个方式填充的话。。渲染会正常进行,只不过渲染结果大概率是完全错乱的(比如模型顶点各自不停地在屏幕上到处鬼畜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
{
// FrustumPlanes来自于HybridRenderer包,需要额外导入进项目
CullingPlanes = FrustumPlanes.BuildSOAPlanePackets(context.cullingPlanes, Allocator.TempJob),
Boundses = _bounds,
Batches = context.batchVisibility,
Indices = context.visibleIndices
};
// _cullingDep存储当前作业的依赖,为了在析构时保证所有的作业都已完成
var handle = job.Schedule(cnt, 4, _cullingDep);
_cullingDep = JobHandle.CombineDependencies(handle, _cullingDep);
return handle;
}

// 一定要启用Burst编译,性能可以提升10倍
[BurstCompile]
private struct CullingJob : IJobParallelFor
{
// 下面的字段有一大堆花里胡哨的属性,一一解释下

// 标记为只读,此时Unity的Job调度器就允许多个线程同时访问这个容器
[ReadOnly]
// Job完成时自动释放内存,这个容器是我们在创建Job时临时分配的
// 而Job并不会立即完成,我们并不知道什么时候完成
// 加上这个标签之后,Job完成时就会自动释放这个容器了
[DeallocateOnJobCompletion]
public NativeArray<FrustumPlanes.PlanePacket4> CullingPlanes;

[ReadOnly]
public NativeArray<AABB> Boundses;

public NativeArray<BatchVisibility> Batches;

// Unity默认不允许向Native容器并行写入(除非写入的索引等于Excute中的参数,比如上边的Batches)
// 于是我们需要告诉Unity,虽然这个容器是需要写入的,但是我们保证不会从不同的线程向相同索引的元素中写入
// 加上这个标签可以解除并行写入限制
[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这个方法的热点分布,如下图:

fig02

发现添加和Batch和设置每个对象的数据都还是比较快的,唯一的性能瓶颈在于CollectMeshes()方法(占比接近80%)。由于现在每次计算都会遍历全部10000个(默认设置)地块,每个微小的操作都要重复执行一万次,这里浪费了许多不必要的计算。之后的优化目标是按需更新,因为只有地块变化时才会触发渲染批次更新,所以我们只要封装好操作地块的接口,每次更新时只重新计算其中一部分,应该可以好很多。

LOD

目前所有的地块模型(室外,室内,墙壁)都只有一个LOD,如果到后边模型变得复杂起来,就可能需要用到LOD来减轻GPU压力。方案也十分简单,每次AddBatch的时候同时把其他LOD的批次都添加进去,别忘了裁剪函数里有个参数lodParameter,我们可以根据这个参数提供的摄像机位置来判断选用哪个细节等级的模型。