Loading... 第九节 多线程同步协调处理多区块思想和基本实现 ---  上一篇:<div class="preview"> <div class="post-inser post box-shadow-wrap-normal"> <a href="https://omo.moe/archives/403/" target="_blank" class="post_inser_a no-external-link"> <div class="inner-image bg" style="background-image: url(https://omo.moe/usr/uploads/2018/12/3492373399.jpg);background-size: cover;"></div> <div class="inner-content" > <p class="inser-title">【Unity 3D进阶教程分享】EndlessTerrain无限地形搭建 第八节:多区块地形生成与隐藏</p> <div class="inster-summary text-muted"> 第八节 多区块地形生成与隐藏上一篇:新建EndlessTerrain脚本public const float ma... </div> </div> </a> <!-- .inner-content #####--> </div> <!-- .post-inser ####--> </div> 这节我们将继续上节的无限地形生成功能完善,将上节制作出来的视野内三维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统筹分工,通过协作多线程完成多块地形同步生成, 下面的内容是我们整个无限地形模块重中之重,我会详细用图示解释清楚:  首先,我们的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(); ```````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````` 3. 开启新的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)); } } ``` 4. 然后在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细节划分等等,更多内容,我们下节再见了 下一篇:<div class="preview"> <div class="post-inser post box-shadow-wrap-normal"> <a href="https://omo.moe/archives/411/" target="_blank" class="post_inser_a no-external-link"> <div class="inner-image bg" style="background-image: url(https://omo.moe/usr/uploads/2018/12/3651289455.jpg);background-size: cover;"></div> <div class="inner-content" > <p class="inser-title">【Unity 3D进阶教程分享】EndlessTerrain无限地形搭建 第十节:LOD结合视距分区块mesh地形渲染</p> <div class="inster-summary text-muted"> 第十节 LOD结合视距分区块mesh地形渲染上一篇:这一节我们将对不同视距的区块地形进行不同精度LOD区分绘制me... </div> </div> </a> <!-- .inner-content #####--> </div> <!-- .post-inser ####--> </div> [1]: https://omo.moe/usr/uploads/2018/12/395102909.jpg [2]: https://omo.moe/usr/uploads/2018/12/3127104849.png Last modification:August 12th, 2020 at 06:21 pm © 允许规范转载 Support If you think my article is useful to you, please feel free to appreciate ×Close Appreciate the author Sweeping payments Pay by AliPay Pay by WeChat