第九节 多线程同步协调处理多区块思想和基本实现


yande.re 378992 sample bell lolita_fashion pantsu tinkle.jpg
上一篇:


这节我们将继续上节的无限地形生成功能完善,将上节制作出来的视野内三维plane的显示和隐藏功能与我们的MapGenerator相结合,这里将会用到多线程技术,
之前我们第一节了解过协程的原理,这里多线程技术比协程更高级一些,这算是真正的多个线程都在一起跑,而不是协程的跳来跳去,适合处理一些短时间内计算量很大,任务数量较多,可以同步单独运算,同时又追求高效完成需要快速拿到多个运算结果的事情。
由于Unity系统限制,实际上我们unity本身是没有多线程的,我们的副线程是干不了前台的画面渲染,人物运动,物理碰撞,AI运算这些涉及到Unity SDK或者说继承于Monobehaviour的一些操作的,这些只能交给主线程,但是,这不代表多线程没有用,后台的数据运算,比如说伤害计算,MeshData数据构建,网络交互更新,一些复杂的I/O调取操作,都是可以通过分配给副线程去做的,多线程是c#里实现的,可以辅助unity达到多线程的效果,通过Action和Callback之类的指令,和unity主线程交互,决定下一步的运算或者释放。
多线程运算,涉及到的核心主要有两点,首先,unity主线程和其他C#副线程的交互,是一个重点,这个交互思想代码模板需要熟悉;
其次,对于任务和计算的分配,这个优先级和排序,也是一个重点,否则大家一起抢着干同一件事情,造成数据混乱,显然是帮倒忙,也是不可取的;
这一节,主要涉及到线程间交互方式,基本框架的构建和熟悉,这一块基本上都是标准模板,初学复杂,熟练后都是套路,代码看起来多,实际都是固定格式,不用害怕。
对于任务优先级排序,我们稍微了解下入门级的五个优先级排序,和Lock指令锁定分配任务和分派线程,今后有时间的话再聊聊Loom指令委托到主线程上进行调度,差不多就可以实现一些基础的多线程操作了,更复杂的,慢慢来,多线程就像一把封印的神器,需要不断积累经验,解封用法,才会用的更加强大。好了,我们正式开始:
在多线程编写前,我们需要打扫下之前的代码,
MapGenerator脚本,我们GenerateMap方法里MapDisplay display三种drawMode方式判断和执行,算是一个单独的方法,我们为了今后不断扩充的系统考虑,将方法细分独立出来,目前考虑将这三种生成渲染模式放置到Inspector面板方便调试,我们可以在此脚本新建一个方法:

public void DrawMapInEditor(){
复制所有Display代码到这里

接下来,我们需要从GenerateMap方法中调用相应的Map数据,之前我们display这块代码和地图信息数组在同一个方法里,是可以直接调取GenerateNoiseMap里面这些地图数据的,现在分家了,在其他方法里调用另一个方法里的数据,是需要一个载体存储对应数据,再调用载体,读取数据,这也是基本的编程思想
于是我们在脚本最后新建地图数据结构体:

public struct MapData{
    public float[,] heightMap;//灰阶高度图是二维数据,需要二维数组存储宽度和高度数据
    public Color[] colourMap;//彩色图是整个色彩像素点数据,一维数组就可以存储

Ctrl+.或者 Alt+Enter,快捷键构建结构体内部数据,勾选刚才新建的两个数据类型,然后确定
就可以快速初始化了,//(如果后期不需要修改地图数据,可以将结构体内部所有元素加入readonly关键词)
接下来我们在GenerateMap方法中返回MapData类型数据,void改成MapData
同时别忘记在方法里,最后返回对应数据类型:

return new MapData(noiseMap,colourMap);

这样,我们就可以通过MapData载体,在我们的DrawMapInEditor方法中调取需要的地图类型和对应数据了,在DrawMapInEditor方法里,开头申明赋值,准备调用:

MapData mapData=GenerateMap();

然后我们将noiseMap,colourMap修正为现在的 mapData.heightMap,mapData.colourMap,结构体内对应map类型,就可以进行绘制渲染了
如果之前没有手动换行的话,一个快捷键组合:Ctrl+E+W,让系统自动换行,避免页面外的信息遗漏,忘记修改
这么修改后,我们的GenerateMap就单独负责地图信息生成了,而与外部交流沟通数据,就交给我们新建的DrawMapInEditor方法了,于是我们可以考虑将 public MapData GenerateMap方法改为private,因为暂时不需要用来外部类调用它了,去掉public关键词即可,默认就是private了
为了更精准描述方法定位,修改方法名为:GenerateMapData(),快捷键 ctrl+r+r
保存后回到unity,应该会出现一些报错,涉及到其他脚本调用,我们需要进去修改对应的调用,对接到DrawMapInEditor方法
原来是MapGeneratorEditor脚本,修改 mapGen.GenerateMap()方法为:
mapGen.DrawMapInEditor(),就ok了
好了,折腾这么多,我们终于将MapGenerator内部方法具体拆分,以便接下来将我们的MapGenerator和EndlessTerrain统筹分工,通过协作多线程完成多块地形同步生成,
下面的内容是我们整个无限地形模块重中之重,我会详细用图示解释清楚:
地图生成器和无限地形生成脚本间多线程协作基本原理.png
首先,我们的EndlessTerrain脚本,需要获取MapGenerator里面的MapData,meshData各种数据,然而我们的MapGenerator里面是需要通过多个帧数运算才会生成相应的数据,那么我们从EndlessTerrain脚本发生请求,肯定不能立马收到MapGenerator传回的地图数据,于是,我们在调用MapGenerator脚本中请求地图数据方法的时候,为该方法自带传入一个Action请求处理完毕后传回callback数据:

void RequestMapData(Action<MapData>callback){
//在此方法中,开始启动MapData处理线程
}

意味着MapGenerator外部EndlessTerrain之类的脚本调用请求后,我执行完毕数据生成后,会callback发送返回地图数据,这个Action传递MapData信息给外部脚本
好的,我们回到EndlessTerrain脚本,在申请调用MapGenerator地图生成方法的时候,应该传入一个接收地图数据时候需要执行的方法,方法名可以叫做OnMapDataReceived,当RequestMapData方法执行完毕后,收到Callback的数据后,也就是接收地图数据后,调用执行此OnMapDataReceived方法,对数据进行处理:

void start(){
    RequestMapData(OnMapDataReceived);
    }
    void OnMapDataReceived(mapData mapData){
    //在这里处理mapData地图信息
    }

这样,我们传回MapData行为,callback回到我们EndlessTerrain后,就可以调用咱们的OnMapDataReceived方法,开始对传出来的地图数据在此方法里进行处理了
即MapGenerator中RequestMapData方法自带callback 行为,一旦被外部请求调用,必定会自动传出数据,配合EndlessTerrain外部脚本调用该方法的时候指定传入OnMapDataReceived方法,以便对传回数据进行处理,这样就做到了脚本间线程间基本交互实现。
接下来,我们需要关注下MapGenerator这个副线程处理逻辑,当我们void RequestMapdata方法启动我们的MapData处理线程:

void MapDataThread(Action<MapData.callback){
//在此线程先运算得到mapData
//这里是重点,我们知道,直接进行mesh网格渲染,需要用到unity内部组件,而unity本质上没有多线程处理这些unity内部操作的,我们不能在c#这些副线程里直接处理渲染,所以需要添加存储mapData,然后传回到等待队列,因为有多个副线程同时在处理各个区块地形数据,EndlessTerrain脚本不可能随时同时接收完毕所有副线程处理出来的地图数据,所以我们先存储到传回队列,排好队。

接下来,副线程在Update方法里,执行队列位置计算,在队列中按顺序一个个发送callback传回mapData:

void  Update(){
    if(queue.Count>0{
    //调用callback指令,传出mapData数据
    }
}

这就是我们的多线程相互协作的基本原理,接下来我们开始逐步实现,这些基本框架第一次接触可能觉得代码量很大,不过没关系,所有多线程基础框架都可以按类似的逻辑构建
来到MapGenerator脚本,我们通用的多线程调用基础,先引入对应命名空间下方法类型:

using System;//获得Actions各种行为操作调用功能
using System.Threading;//使用多线程功能

接下来,按着我们刚才的分析,开始第一步,构建我们的RequestMapData方法,附带Action,callback此Action,附带传回Action参数为MapData类型数据:

public void RequestMapData(Action<MapData> callback){
//请求生成对应副线程执行地图数据处理,我们稍后书写。
}

方法内部功能稍后构建,我们接下来构建此方法将调用的启动MapData运算副线程:

void MapDataThread(Action<MapData> callback)
    {

    }

这样,我们就可以在RequestMapData方法开始构建启动计算副线程功能了:

ThreadStart threadStart = delegate
            {
                MapDataThread(callback);
            };
    //启动线程,委托给带参函数MapDataThread,记住这是一个语句,不是什么方法也不是类,一定要在语句结束后加上分号,新手最容易以为大括号代表结构和类,后面漏掉分号

咱们的方法是外面这个大括号,RequestMapData才是我们的方法结构,第一句话委托给指定线程处理指定方法,接下来写第二句别忘了启动对应线程:

new Thread (threadStart).Start();//调用线程类型下的Start()方法,

ok,现在我们MapDataThread方法就可以运行在另一个线程里了,我们实现下 void MapDataThread方法里具体的线程内部功能:
MapData mapData=GenerateMapData();//请务必记住,副线程里直接调用的方法,只能是独立于Unity功能的方法,例如:
FindObjectOfType,GameObject.Find,SendMessage,StartCoroutine等等方法,是Unity内部组件API等接口所含的功能,这些方法不能直接调用,否则会报错
我们再回顾下刚才的【地图生成器和无限地形生成脚本间多线程协作基本原理】图示,地图数据处理,我们直接调用对应方法,一句话就轻松实现了,接下来需要创建一个结构体,存储我们的地图数据,方便等会扔到队列里,存进去按顺序一个个传回去
我们在MapGenerator类里面,最后添加一个结构体:

struct MapThreadInfo<T> {
    public Action<T> callback;
    public T parameter;

Ctrl+.或者 Alt+Enter快速重构勾选两个变量,快速构建结构体初始化
由于这个结构体我们不需要对其进行改变,作为存储容器,保持其固定不变,建议对所有元素加入readonly关键字,类似于刚才构建的MapData结构体,内部元素都可以加上readonly关键词
接下来,为了调用队列Queue方法,我们需要引用新的命名空间:

using Systemcollections.Generic;

List ,Array,Dictionary这些数据集合也是在这个破容器空间里面的
我们在当前MapGenerator类里面,开头申明一个Queue并初始化:

Queue<MapThreadInfo<MapData>> mapDataThreadInfoQueue= new Queue<MapThreadInfo<MapData>>();

接下来,我们就可以在MapDataThread里面安排上了

mapDataThreadInfoQueue.Enqueue(new MapThreadInfo<MapData>(callback,mapData));

使用enqueue方法将新运算出来的地图数据和callback指令都传入队列,方便之后从队列调取传回mapData数据到外部脚本EndlessTerrain。
这里由于是在副线程运行的,地图数据可以被多个线程同时访问,为了保证单张地图数据完整性,这里加上lock锁定此队列

lock (MapDataThreadInfoQueue){
    mapDataThreadInfoQueue.Enqueue(new MapTheradInfo<MapData>(callback,mapData));
    }

lock代表一旦有一条线程获取到此代码,其他线程就无法访问该代码了,直到代码运算完成释放
接下来我们实现最后一步,在Update方法里,判断queue队列里面的排队信息,如果存在队列元素,遍历内部存储的所有队员,一个个callback传送出去,同时将该队员元素从等待队列中移除:

void Update() {
        if (mapDataThreadInfoQueue.Count > 0)
        {
            for (int i = 0; i < mapDataThreadInfoQueue.Count;i ++)
            {
                MapThreadInfo<MapData> threadInfo = mapDataThreadInfoQueue.Dequeue();
                threadInfo.callback(threadInfo.parameter);
            }
        }
    }

我们MapGenerator这边请求地图数据方法,启动多线程地图数据运算线程功能,计算好后存储数据到结构体,并且加入队列,然后Update方法将队列元素一个个传出,这些都已经实现,现在来到EndlessTerrain脚本看看如何接收数据:
首先我们要调用MapGenerator脚本下的方法,自然要先申明一个对应的MapGenerator:

static MapGenerator mapGenerator;

Start()方法中实例化对应地图生成器:

MapGenerator=FindObjectOfType<MapGenerator>();

好了,开始构建我们EndlessTerrain脚本多线程配合方法:TerrainBlock类里面,新增方法

void OnMapDataReceived(MapData mapData){}

对了,别忘记先调用下MapGenerator脚本里面的请求地图数据脚本,先得请求那边执行下,我们在TerrainBlock类里,构造TerrainBlock内容里,最后加上一个获取地图数据请求:

mapGenerator.RequestMapData(OnMapDataReceived);

嗯,这样就可以让那边多线程处理数据了,再收到那边队列传过来的mapData,我们就可以在OnMapDataReceived方法进行接收处理了,我们继续编辑OnMapDataReceived方法,先做个测试输出:

print("地图数据已接收");

先打印下,检测传递是否起效
果然,出现25条信息,说明ok了,我们中间9宫格9块,加上外围16块地图,总共25块都已经运算完毕并且通信传回了,查看MapGenerator物体,下方对应生成了25个plane,说明ok,非常不错
这里为啥只传回地图数据而不是同时传回mesh数据呢?因为我们接下来要在这里做LOD系统,得先对地图分块进行筛选,等会单独构建OnMeshDataReceived方法,对远处的地图直接绘制低精度mesh,这样节省资源占用,最后将不同精度mesh单独处理匹配生成完毕,再传入后续脚本进行渲染mesh
好了,我们继续

- 新目标:Mesh网格数据生成

由于已经拿到地图数据了,之前做为演示用的 meshObject=GameObject.CreatPrimitive初始模型plane已经失去测试意义了,修改为:

meshObject = new GameObject("Terrain Block");//创建mesh网格模型Terrain Block

同时修改下注释
我们还需要对其赋予mesh Filter和mesh Renderer组件,构建mesh网格需要:
TerrainBlock类里面,开头加上两个变量:

MeshRenderer meshRenderer;
MeshFilter meshFilter;

接下来为刚才的新物体添加对应mesh存储器和渲染器:

meshRenderer =meshObject.AddComponent<MeshRenderer>();
meshFilter = meshObject.AddComponent<MeshFilter>();
mesh组件都构建完毕,我们可以开始实现OnMeshDataReceived方法第一个功能了,为我们的物体构建基本mesh信息:
void OnMeshDataReceived(MeshData meshData){
    meshFilter.mesh=meshData.CreateMesh();
    }

我们再次回到MapGenerator脚本,这里面我们需要按着RequestMapData方法,做一个类似的请求meshData的方法,偷懒的话全部可以复制MapData的流程改改字母:

  1. 一,申明新队列
   Queue<MapThreadInfo<MeshData>> meshDataTheradInfoQueue= new Queue<MapThreadInfo<MeshData>>();

2.创建新的请求meshData方法

public void RequestMeshData(MapData mapData, Action<MeshData>callback){
    ThreadStart threadStart = delegate {
        MeshDataThread(mapData, callback);
    };
    new Thread(threadStart).Start();
  1. 开启新的meshData计算线程
   void MeshDataThread(MapData mapData, Action<MeshData> callback)
   {
   MeshData meshData = MeshGenerator.GenerateTerrainMesh(mapData.heightMap, meshHeightMultiplier, meshHeightCurve, levelOfDetail);
   lock (meshDataThreadInfoQueue)
   {
   meshDataThreadInfoQueue.Enqueue(new MapThreadInfo<MeshData>(callback, meshData));
   }
}
  1. 然后在UpDate方法里也跟着抄一遍MapDataQueue传出队列遍历:
   if (meshDataThreadInfoQueue.Count > 0){
   for (int i = 0; i < meshDataThreadInfoQueue.Count; ++)
   {
   MapThreadInfo<MeshData> threadInfo = meshDataThreadInfoQueue.Dequeue();
   threadInfo.callback(threadInfo.parameter);
   }

好了,终于复制粘贴完毕了,
我们回到Endless Terrain脚本,接收到MapData后,我们再次请求对应的meshData
对我们的OnMapDataReceived方法,之前的print测试语句删掉,改成:

mapGenerator.RequestMeshData(mapData,OnMeshDataReceived);

由于我们meshData也拿到了,之前TerrainBlock类下的plane对应的mesh localScale尺寸设定也可以去掉了:

 meshObject.transform.localScale = Vector3.one * size / 10f;删除此句。

然后我们需要对TerrainBlock构造体传入新参Material materal,终于可以对咱们的地图数据和mesh数据进行材质渲染了

- 新目标:texture材质渲染

TerrainBlock构造传入新参:

Material material

结构内部添加材质信息:

meshRenderer.material=material;

然后
同时别忘了在EndlessTerrain类里面,开头申明对应材质变量:

public Material mapMaterial;

再找找下面的UpdateVisibleBlocks方法,别忘了循环遍历判断生成符合要求的新block时候,也需要传入地图材质,对新创建的block添加材质信息:

else{
    terrainBlockDictionary.Add(ViewdBlockCoordinate,new TerrainBlock(viewedBlockCoordinate,blockSize,transfrom,mapMaterial));//在这里添加材质参数

保存,回到unity,点击MapGenerator物体,然后查看我们Inspector面板,Endless脚本上需要挂载下我们的Map Material,点击assets文件夹下的Materials文件夹,里面之前创建的MeshMat就是了,拖拽上去,ok,运行
查看生成的地形,似乎比较诡异
导致这些诡异的地形生成的原因,应该是在MapGenerator脚本,执行RequestMeshData的时候,调用多线程:MapDataThread获取 MeshData meshData = MeshGenerator.GenerateTerrainMesh时,我们MeshGenerator脚本里,查看这个GenerateTerrainMesh静态方法里面,heightCurve在顶点生成的时候,heightCurve.Evaluate方法,被多个线程同时访问处理,最后evaluate方法运算数据发生异常
我们可以通过Lock指令,对meshData.vertices生成语句的heightCurve曲线进行锁定改为:

lock (heightCurve){
meshData.vertices[vertexIndex]=…..}

保存后unity运行,移动一下viewer,发现生成的地形正常了
但是我们认真考虑后没有采用此方法,为什么?
因为一旦我们锁定顶点运算方法,其他线程就得乖乖躺着等一个个线程运算顶点数据,这意味着咱们二十五块mesh差不多变成单线程处理mesh信息了,辛辛苦苦弄的多线程失去了意义,地形生成速度会大大降低
于是我们思考一下,现在是大家都抢着访问传入的一个曲线,研究他,解析它,读取曲线的数值,利用数值来进行顶点运算,我们完全可以实例化一个曲线,让该曲线 的值等于传入的曲线参数的值,有了一个实例化的载体,大家就可以愉悦地同时查阅该曲线值了,而不是把传入的参数来回抢夺,造成这种问题的原理,目前我的能力无法解决,希望有大佬可以指点下,好了,我们继续修复:
MeshGenerator脚本的GenerateTerrainMesh方法里,我们在开头新建一个曲线:

AnimationCurve heightCurve =new AnimationCurve(basedHeightCurve.keys);

我们考虑命名重复问题,将之前GenerateTerrainMesh方法传入的heightCurve和这里要赋值的传入参数全改成basedHeightCurve,这样后面生成顶点时读取的曲线数值,就从这个实例化的curve中获取了,可以支持多线程同时访问,保存后执行,发现获取速度快了很多,效果也ok
目前来说,我们生成的这些破玩意,都是一模一样的mesh地形,接下来,我们将添加材质,生成连续不同地形,以及LOD细节划分等等,更多内容,我们下节再见了
下一篇:

Last modification:August 12, 2020
If you think my article is useful to you, please feel free to appreciate