1. 为什么大地图必须做分块加载做Unity项目的人迟早会撞上同一个问题场景越铺越大帧率越跑越低内存占用一路飙升最后编辑器里点一下Play都要等半分钟。我最早做开放世界地形的时候图省事把整张地图的Terrain、建筑、植被、NPC全塞进一个Scene里结果打包出来在移动端跑加载界面卡了将近四十秒进游戏之后手机烫得能煎鸡蛋。后来痛定思痛把地图拆成一块一块按需加载同样的内容加载时间压到了三秒以内内存峰值也降了一半多。这就是大地图分块加载要解决的核心问题不是让地图变小而是让引擎在任意时刻只处理玩家看得见、够得着的那部分内容。它的本质是一种空间换时间的资源调度策略——把一整块巨大的世界切成网格每个网格独立管理自己的资源玩家走到哪里就加载哪里的块离开的块该卸载就卸载。这套机制适合谁如果你在做开放世界、SLG大地图、沙盒生存、或者任何单场景面积超过几百米见方的项目分块加载基本是绕不过去的基础设施。哪怕你现在地图还小提前把分块框架搭好后期扩展会轻松很多。反过来说如果你的项目就是几个房间来回切那用SceneManager直接切场景就够了没必要上这套。需要先明确一个概念分块加载和LODLevel of Detail、**遮挡剔除Occlusion Culling**是三个不同层面的优化。LOD管的是同一个物体远处用低模遮挡剔除管的是被挡住的东西不渲染而分块加载管的是不在附近的东西根本不加载进内存。三者可以叠加使用但分块加载是唯一能真正把内存占用压下来的手段因为前两者只是减少渲染开销资源该占的内存一点没少。我见过不少团队把这三者搞混以为开了遮挡剔除就不用分块了结果内存该爆还是爆。记住一句话渲染优化解决的是GPU和DrawCall分块加载解决的是内存和加载时间两者不能互相替代。2. 分块粒度的选择多大一块才合适分块加载第一个要拍板的参数就是块的大小。这个值没有标准答案但有一套推导逻辑我把它拆开讲。块太大比如512米见方那分块的意义就被削弱了——玩家站在块边缘整块都得加载等于没分。块太小比如16米见方块的数量爆炸管理开销、加载频率、GC压力全上来了反而更卡。所以块大小的选择本质是在加载粒度和管理开销之间找平衡点。我的经验公式是这样的先看玩家在游戏里的典型移动速度和视野距离。假设玩家跑速是8米/秒视野距离150米那么玩家从进入一个块的加载范围到离开大概有150 / 8 ≈ 18秒的窗口期。块的大小最好让这个窗口期足够完成一次异步加载同时块内内容量不要超过单帧能消化的上限。具体到数值我实测下来比较稳的区间是地图类型推荐块大小说明移动端开放世界64m - 128m内存敏感块要小配合异步加载PC端开放世界128m - 256m内存宽裕块可以大一点减少管理开销SLG大地图俯视256m - 512m相机拉得高视野大块可以粗室内/箱庭式32m - 64m空间紧凑块小反而更灵活这里有个容易被忽略的点块的大小最好和地形高度图的分辨率对齐。比如你的Terrain是每米2个采样点块大小取128米那正好是256个采样点切分的时候不会出现半像素错位。如果块大小是个奇怪的数字切出来的地形接缝处容易出现高度不连续玩家走到边界会看到一条明显的裂缝。还有一个坑块的大小不要和Unity的浮点精度临界值撞车。Unity在坐标绝对值超过大概5000米之后浮点精度开始明显下降物体会抖动。所以如果你的地图很大要么把整个世界原点放在地图中心要么用**浮动原点Floating Origin**技术让玩家永远在原点附近。分块加载配合浮动原点是大地图的标准组合拳。我个人的习惯是块大小取2的幂次比如64、128、256。原因很简单2的幂次在做坐标换算、哈希索引、位运算的时候特别方便blockX Mathf.FloorToInt(worldPos.x / blockSize)这种计算不会有精度残留而且用位运算就能替代除法性能更好。3. 资源切分把什么放进块里什么不放块的大小定了接下来是更关键的问题每个块里到底装什么。这一步做不好分块加载就是白搭。我的做法是把场景里的所有资源分成三类分别用不同的策略处理第一类静态地形与建筑。这类东西位置固定、不会移动是分块加载的主力。做法是把它们按块切分每个块导出成独立的Prefab或者AssetBundle。切分的时候要注意跨块的物体要归到它中心点所在的块而不是按包围盒切否则一个横跨两个块的大建筑会被切成两半加载时出现半截楼。第二类动态物体NPC、怪物、载具。这类东西会移动不能简单按块归属。我的做法是给每个动态物体一个逻辑归属块但实际加载由激活半径控制。也就是说NPC的数据可以放在块里但它的实例化由管理器根据玩家距离动态决定。这样NPC可以自由跨块移动不会因为块切换而消失。第三类全局资源天空盒、光照贴图、全局音效、UI。这类东西不属于任何块应该常驻内存放在一个单独的常驻场景或者DontDestroyOnLoad的对象里。千万别把全局资源塞进块里否则每次切块都要重新加载浪费且容易出错。这里有个实操细节光照贴图Lightmap的切分。Unity的烘焙光照贴图默认是整场景一张如果你把场景切成块光照贴图也得跟着切。做法是在烘焙前就把场景按块组织好用多个Scene分别烘焙每个块对应自己的Lightmap。如果偷懒用一张大Lightmap切块后会出现光照错乱远处块的物体带着错误的阴影。还有一个我踩过的坑Terrain的邻居连接SetNeighbors。Unity的Terrain如果做了分块块与块之间需要调用Terrain.SetNeighbors()来保证LOD过渡和接缝平滑。如果忘了这一步玩家走到块边界会看到地形突然跳变或者接缝处出现明显的缝隙。这个调用必须在块加载完成后立刻执行而且要把上下左右四个邻居都设对。// 块加载完成后设置Terrain邻居 void SetupTerrainNeighbors(Terrain[,] terrains, int x, int y) { Terrain left x 0 ? terrains[x - 1, y] : null; Terrain right x terrains.GetLength(0) - 1 ? terrains[x 1, y] : null; Terrain bottom y 0 ? terrains[x, y - 1] : null; Terrain top y terrains.GetLength(1) - 1 ? terrains[x, y 1] : null; terrains[x, y].SetNeighbors(left, top, right, bottom); }这段代码看着简单但顺序不能错。SetNeighbors的参数顺序是左、上、右、下我第一次写的时候把上下搞反了结果地形接缝的LOD过渡完全错乱排查了半天才找到。4. 加载策略同步、异步与预加载的取舍分块加载的触发时机和加载方式直接决定了玩家会不会感觉到卡顿。这里有三套策略我分别讲适用场景。同步加载最简单玩家走到块边界直接Instantiate或者LoadAsset一帧内完成。优点是逻辑简单、不会出现加载中的空窗期。缺点是必然卡帧块越大卡得越狠。同步加载只适合块非常小、内容非常轻的场景比如纯地形没有建筑的SLG地图。但凡块里有建筑、植被、特效同步加载都会造成明显的顿挫。异步加载是主流做法。用Addressables.LoadAssetAsync或者AssetBundle.LoadAssetAsync在后台线程把资源读进来主线程只负责实例化。这样加载过程不会阻塞渲染玩家感觉不到卡顿。但异步加载有个问题加载完成的时间不确定。如果玩家跑得快可能出现人到了块还没加载好的情况眼前一片空白。解决这个问题的办法是预加载。不要等玩家走到块边界才开始加载而是提前一到两个块的距离就开始异步加载。具体提前多少取决于你的加载耗时和玩家速度。假设加载一个块需要0.5秒玩家速度8米/秒那至少要提前4米开始加载实际我会留出2到3倍的余量提前10到20米。我的标准配置是这样的参数推荐值作用加载半径视野距离 1个块决定哪些块需要加载卸载半径加载半径 1个块决定哪些块可以卸载预加载提前量1.5 - 2个块提前触发异步加载每帧最大实例化数2 - 4个块防止单帧实例化过多卡顿注意卸载半径一定要大于加载半径中间留一个缓冲带。如果两个半径相等玩家在边界来回走动时块会反复加载卸载产生抖动。这个缓冲带就是防抖用的我一般留一个块的宽度。还有一个高级技巧分帧实例化。异步加载完成后资源已经在内存里了但Instantiate本身还是要花时间的。如果一个块里有几百个物体一次性实例化会卡帧。做法是把实例化任务放进一个队列每帧只处理固定数量比如每帧实例化20个物体。这样虽然块完全就绪的时间变长了但帧率是平滑的玩家体验更好。// 分帧实例化队列 QueueGameObject instantiateQueue new QueueGameObject(); const int MaxPerFrame 20; void Update() { int count 0; while (instantiateQueue.Count 0 count MaxPerFrame) { var prefab instantiateQueue.Dequeue(); Instantiate(prefab, prefab.transform.position, prefab.transform.rotation); count; } }这段代码是我从实际项目里抠出来的简化版核心思想就是把集中开销摊到多帧。实测下来每帧20个实例化在移动端也能稳住60帧。5. 内存管理卸载不是Destroy那么简单很多人以为分块加载的卸载就是Destroy掉GameObject其实远不止。Destroy只是把对象从场景里移除资源本身还留在内存里除非你显式地释放。Unity的资源释放有两套机制Resources.UnloadUnusedAssets和Addressables.Release / AssetBundle.Unload。前者是全局扫描把所有没有引用的资源释放掉但它很慢而且会触发GC不能频繁调用。后者是精确释放你加载了哪个资源就释放哪个效率高。我的做法是每个块维护一个资源引用计数。块加载时把用到的所有Asset引用记录下来块卸载时先Destroy掉GameObject然后逐个Release掉资源引用。等所有块都处理完再在合适的时机比如加载界面调用一次Resources.UnloadUnusedAssets做兜底清理。这里有个大坑AssetBundle的依赖关系。如果块A和块B都引用了同一个共享资源比如一个通用的树木模型你卸载块A的时候不能直接把这个共享资源也卸了否则块B会出问题。解决办法是用引用计数共享资源被引用一次计数加一释放一次计数减一减到零才真正卸载。Addressables内部已经帮你处理了这套逻辑所以我现在基本都用Addressables很少手写AssetBundle管理。另一个坑是纹理和Mesh的内存。一个块卸载了但它的纹理可能还被材质引用着材质又被某个常驻对象引用着结果纹理释放不掉。排查这种问题要用Memory Profiler看哪些资源在块卸载后还活着然后顺着引用链找到罪魁祸首。我遇到过最离谱的一次是一个全局的材质实例缓存把几百个块的材质都存着导致内存一直下不来最后把缓存改成弱引用才解决。还有一点卸载的时机。不要在玩家还在块里的时候卸载也不要在帧率紧张的时候卸载。我的做法是把卸载任务也放进队列每帧处理一两个和实例化错开。这样内存释放是渐进的不会造成单帧的GC峰值。6. 边界处理接缝、LOD与浮动原点分块加载做到最后最烦人的就是边界问题。玩家站在两个块的交界处左边块加载了右边块还没加载就会出现一条明显的分界线。这个问题有三个层面的解法。第一层预加载消除空窗。前面说的提前加载就是为了让玩家走到边界时两边的块都已经就绪。这是最根本的解法但需要足够的加载提前量。第二层接缝融合。地形块之间的接缝除了SetNeighbors还可以用**接缝网格Stitch Mesh**来填补。具体做法是在两个块的边界处生成一条过渡带把两边的高度差平滑掉。这个技术在地形编辑工具里很常见Unity的Terrain本身支持但如果是自定义Mesh地形就得自己写。第三层LOD过渡。块内的物体远处用低模近处用高模这个切换如果发生在块边界会特别明显。解决办法是让LOD的切换距离和块边界错开或者用**交叉淡入淡出Cross Fade**做平滑过渡。Unity的LOD Group自带Cross Fade选项但需要Shader支持标准Shader里要手动开。浮动原点这块我再展开说一下。当地图尺寸超过几千米Unity的浮点精度就不够用了物体会开始抖动物理也会出问题。浮动原点的思路是保持玩家永远在世界原点附近移动的是整个世界。具体做法是当玩家移动超过一定阈值比如1000米就把玩家和所有已加载的块整体平移抵消掉玩家的位移。// 浮动原点偏移 Vector3 offset player.position; if (offset.magnitude 1000f) { // 把所有已加载对象反向平移 foreach (var obj in loadedObjects) obj.transform.position - offset; // 玩家回到原点 player.position Vector3.zero; // 记录累计偏移用于坐标换算 worldOffset offset; }这个操作要小心必须在所有块都加载完成的帧执行否则正在加载的块坐标会错乱。而且偏移之后所有缓存的坐标、导航网格、物理查询都要跟着更新工作量不小。所以浮动原点一般只在超大世界才用中小地图用世界原点居中就够了。7. 我踩过的几个真实坑最后分享几个我在实际项目里踩过的坑都是文档里不会写的。坑一块索引越界。玩家跑到地图边缘blockX算出来是负数或者超出数组范围直接报错。解决办法是在计算块索引时做Clamp并且在地图外围加一圈空气墙或者不可见边界防止玩家跑出去。坑二异步加载的回调地狱。一个块加载完成要触发一堆事情设置邻居、初始化导航、注册到管理器、通知AI系统。如果全用回调代码会变成一团乱麻。我的做法是用协程 状态机把加载流程写成线性的每一步yield等待可读性好很多。坑三编辑器下和打包后行为不一致。编辑器里资源是直接引用的打包后变成AssetBundle加载路径和时机都不一样。我遇到过编辑器里跑得好好的打包后块加载顺序错乱的问题。解决办法是尽早打包测试不要等到最后才打而且要在真机上测模拟器和编辑器都不可靠。坑四GC峰值。异步加载完成的那一刻大量对象同时实例化会产生一次GC造成卡顿。解决办法是分帧实例化以及用对象池复用那些频繁加载卸载的物体比如植被、小道具。坑五导航网格NavMesh的切分。如果整个地图用一张NavMesh分块加载后NPC的寻路会出问题因为它找不到未加载区域的路径。解决办法是用NavMeshSurface按块烘焙每个块有自己的NavMesh块加载时把NavMesh也加载进来。跨块寻路用OffMeshLink连接或者用分层寻路先在大地图层面规划再在块内细化。这些坑我基本都踩过一遍每一个都花了不少时间排查。现在回头看分块加载这套东西框架设计占三成边界处理占七成。把边界问题想清楚了整个系统就稳了。