Unity动态网格地图生成与交互组件实战指南
发布时间:2026/9/18 15:32:40来源:尧图网络
在Unity项目里做地图绕不开的一个话题就是网格。不管是战棋类的格子地图、模拟经营里的可建造地块还是数字孪生场景中的地形分块本质上都是把连续空间离散成一个个可管理的单元。我最早接触这块是在一个战棋项目里当时地图是美术在编辑器里一格一格摆的改一次地形就要重新摆半天后来痛定思痛做了一套运行时动态生成加交互的方案才算把这块彻底理顺。这篇就围绕动态网格地图生成与交互组件这个主题把从网格数据结构设计、动态生成、坐标换算到点击拾取、悬停高亮、范围选择这一整套东西拆开讲清楚。适合已经会写基础C#脚本、想在Unity里落地一套可复用地图系统的朋友也适合做数字孪生、SLG、塔防这类需要网格逻辑的同学参考。1. 先想清楚网格地图到底要解决什么问题很多人一上来就写代码生成格子结果写到一半发现坐标换算乱成一团拾取对不上性能还崩。问题不在代码在于一开始没把网格地图这个概念拆解清楚。它其实同时承担了三件事空间离散化、数据承载、交互入口。这三件事想明白了后面所有设计都是顺理成章的。1.1 空间离散化把连续世界切成可管理的单元Unity的世界坐标是连续的浮点数但游戏逻辑往往需要格子这种离散概念。所谓离散化就是定义一个映射规则把任意世界坐标归到某个格子索引上。最常见的是正方形网格也有六边形网格。正方形网格的映射最简单给定格子边长cellSize和原点origin世界坐标(x, z)对应的格子索引就是int col Mathf.FloorToInt((worldPos.x - origin.x) / cellSize); int row Mathf.FloorToInt((worldPos.z - origin.z) / cellSize);这里有个坑我必须提前说用FloorToInt而不是RoundToInt。因为格子是从原点往正方向铺的[0, cellSize)这个区间属于第0格[cellSize, 2*cellSize)属于第1格。如果你用四舍五入格子边界附近会出现归属错乱点击拾取时就会点这一格却选中了旁边那格。这个细节在文档里基本不会写但实测下来是拾取错位的头号元凶。反过来格子索引转世界坐标通常取格子中心就是Vector3 center origin new Vector3((col 0.5f) * cellSize, 0, (row 0.5f) * cellSize);注意那个0.5f它把坐标从格子左下角挪到了中心。很多新手忘了加导致生成的物体全部偏了半格视觉上就是格子对不齐。1.2 数据承载每个格子该存什么网格地图不只是视觉上的方块它更是一张数据表。每个格子至少要存地形类型草地、水、山地、通行性能否走、占用状态有没有单位或建筑、以及可能的额外属性资源量、高度。我习惯用一个结构体或类来承载public class GridCell { public int col; public int row; public TerrainType terrain; public bool walkable; public GameObject occupant; // 占用的单位/建筑 public float height; // 用于起伏地形 }用二维数组GridCell[,]存整张地图是最直接的方式。但要注意二维数组在C#里是[row, col]还是[col, row]一定要统一我见过太多项目因为行列顺序在不同函数里写反导致地图转置了排查半天。我的建议是全程用cells[row, col]并且把访问封装成GetCell(col, row)方法从源头杜绝混乱。1.3 交互入口网格是玩家操作的落点玩家点一下屏幕你要知道点到了哪个格子鼠标悬停你要高亮对应格子拖拽选择你要框出一片格子。这些交互全部依赖前面两步的坐标换算和数据查询。所以交互组件本质上是一个翻译层把屏幕输入翻译成格子索引再翻译成业务逻辑。理解了这一点你就不会把拾取逻辑和地图生成逻辑耦合在一起后期维护会轻松很多。2. 动态生成的两种路线Mesh合批还是Prefab实例化网格地图怎么画出来是性能差异最大的地方。我踩过的坑是一开始每个格子实例化一个带MeshRenderer的Prefab地图小的时候没事一旦铺到100x100就是上万个DrawCall帧率直接跪。后来才明白网格地图的渲染有两条完全不同的路线选错了后面很难救。2.1 Prefab实例化简单但只适合小地图每个格子一个GameObject挂个Quad或Cube好处是直观、好调试、每个格子能独立挂脚本和碰撞体。适合10x10以内的小地图或者原型阶段。但它的代价是每个Renderer一次DrawCall除非开合批每个GameObject有Transform开销格子一多内存和CPU都吃不消。如果你确实要用这条路至少做两件事一是用Graphics.DrawMeshInstanced或GPU Instancing把相同材质的格子合批二是碰撞体不要每格一个改用一张整体的MeshCollider或者干脆用数学拾取后面会讲。我实测过100x100的Prefab地图不做优化大概15帧做了Instancing能到60帧以上但内存占用依然比Mesh方案高一个量级。2.2 程序化Mesh大地图的正解真正适合大地图的是把整张网格合并成一个Mesh。思路很简单每个格子贡献两个三角形一个Quad把所有格子的顶点、UV、三角形索引拼成一个大数组一次性提交给MeshFilter和MeshRenderer。这样无论多少格子都只有一次DrawCall。void BuildMesh() { int cellCount width * height; Vector3[] vertices new Vector3[cellCount * 4]; int[] triangles new int[cellCount * 6]; Vector2[] uvs new Vector2[cellCount * 4]; int v 0, t 0; for (int row 0; row height; row) { for (int col 0; col width; col) { Vector3 c origin new Vector3(col * cellSize, 0, row * cellSize); vertices[v 0] c; vertices[v 1] c new Vector3(cellSize, 0, 0); vertices[v 2] c new Vector3(cellSize, 0, cellSize); vertices[v 3] c new Vector3(0, 0, cellSize); uvs[v 0] new Vector2(0, 0); uvs[v 1] new Vector2(1, 0); uvs[v 2] new Vector2(1, 1); uvs[v 3] new Vector2(0, 1); triangles[t 0] v 0; triangles[t 1] v 2; triangles[t 2] v 1; triangles[t 3] v 0; triangles[t 4] v 3; triangles[t 5] v 2; v 4; t 6; } } Mesh mesh new Mesh(); mesh.vertices vertices; mesh.triangles triangles; mesh.uv uvs; mesh.RecalculateNormals(); meshFilter.mesh mesh; }这里有个顶点顺序的坑Unity默认是左手坐标系三角形顶点要按顺时针方向排列才会正面朝上。上面代码里0-2-1和0-3-2的顺序是经过验证的如果你写反了地图会从下面看才可见从上面看是透明的。我第一次写的时候就是顺序反了盯着一个消失的地图看了半小时。2.3 用UV区分格子类型而不是换材质既然整张地图是一个Mesh那不同地形草地、水、山怎么区分答案是用UV映射到一张图集Atlas。把草地、水、山等贴图拼成一张大图每个格子的UV根据地形类型指向图集里的对应区域。这样整张地图还是一个材质、一次DrawCall地形变化只是改UV。Rect uvRect terrainAtlas.GetUV(terrainType); uvs[v 0] new Vector2(uvRect.xMin, uvRect.yMin); uvs[v 1] new Vector2(uvRect.xMax, uvRect.yMin); uvs[v 2] new Vector2(uvRect.xMax, uvRect.yMax); uvs[v 3] new Vector2(uvRect.xMin, uvRect.yMax);地形变化时只需要更新对应格子的UV数组并mesh.uv uvs不用重建整个Mesh。这个技巧在动态改地形的场景里非常关键重建整个Mesh的开销远大于只更新UV。提示图集要注意边缘采样问题相邻格子之间可能出现渗色。解决办法是给图集每个子图留1-2像素的padding或者用Point过滤模式。3. 坐标换算与拾取交互组件的核心链路地图画出来了接下来是让它能点。这一块是交互组件的核心也是最容易出bug的地方。我把整条链路拆成屏幕到世界和世界到格子两段中间用射线衔接。3.1 屏幕射线到世界坐标Unity里从相机发射射线用Camera.ScreenPointToRayRay ray mainCamera.ScreenPointToRay(Input.mousePosition);如果地图有物理碰撞体直接Physics.Raycast拿到命中点。但前面说了大地图用程序化Mesh时我们往往不想加MeshCollider开销大且更新麻烦。这时候用数学平面求交更划算假设地图是水平面y planeY射线与平面的交点可以直接算Plane groundPlane new Plane(Vector3.up, new Vector3(0, planeY, 0)); if (groundPlane.Raycast(ray, out float enter)) { Vector3 worldPoint ray.GetPoint(enter); }这个方案零物理开销速度极快适合纯平面网格地图。如果地图有高低起伏那就得用Physics.Raycast配合地形碰撞体或者自己实现高度采样。3.2 世界坐标到格子索引的边界处理拿到世界坐标后用第1节的公式换算成格子索引。但这里有个边界判断必须做如果点到了地图外面col或row会变成负数或超出范围直接访问数组会抛异常。所以每次换算后都要校验public bool TryGetCell(Vector3 worldPos, out int col, out int row) { col Mathf.FloorToInt((worldPos.x - origin.x) / cellSize); row Mathf.FloorToInt((worldPos.z - origin.z) / cellSize); return col 0 col width row 0 row height; }我见过有项目因为没做这个校验玩家点到地图边缘外就报IndexOutOfRangeException线上崩溃率飙升。这个校验成本极低但能救命。3.3 悬停高亮用一个独立Quad而不是改地图Mesh鼠标悬停时高亮当前格子最省事的做法是单独做一个高亮Quad每帧把它移动到当前悬停格子的位置。这样完全不用动地图Mesh性能也好。void Update() { Ray ray cam.ScreenPointToRay(Input.mousePosition); Plane plane new Plane(Vector3.up, Vector3.zero); if (plane.Raycast(ray, out float enter)) { Vector3 p ray.GetPoint(enter); if (TryGetCell(p, out int col, out int row)) { highlightQuad.SetActive(true); highlightQuad.transform.position CellToWorldCenter(col, row) Vector3.up * 0.01f; } else { highlightQuad.SetActive(false); } } }注意那个 Vector3.up * 0.01f把高亮面稍微抬高一点点避免和地图面Z-Fighting深度冲突导致闪烁。这个偏移量不用大0.01就够太大了会看起来浮在空中。3.4 点击与拖拽选择的区分点击选一格和拖拽框选一片需要区分。我的做法是记录鼠标按下的位置和时间抬起时如果移动距离小于阈值就当点击否则当拖拽if (Input.GetMouseButtonDown(0)) { dragStart Input.mousePosition; isDragging true; } if (Input.GetMouseButtonUp(0) isDragging) { float dist Vector2.Distance(dragStart, Input.mousePosition); if (dist 5f) OnCellClicked(GetCellUnderMouse()); else OnCellsDragged(GetCellsInRect(dragStart, Input.mousePosition)); isDragging false; }框选时把矩形两个角换算成格子索引取min/max就能得到格子范围。这个逻辑在战棋和RTS里非常常用封装好之后复用性很高。4. 让地图活起来动态增删与状态同步静态地图好做难的是运行时动态变化造了个建筑要占格、拆了要释放、地形被炸了要改。这一块如果设计不好数据和视觉很容易不同步。4.1 数据先行视觉跟随我的原则是永远先改数据再刷新视觉。比如放置建筑public bool PlaceBuilding(int col, int row, BuildingData data) { if (!InBounds(col, row) || !cells[row, col].walkable || cells[row, col].occupant ! null) return false; cells[row, col].occupant Instantiate(data.prefab, CellToWorldCenter(col, row), Quaternion.identity); cells[row, col].walkable false; RefreshCellVisual(col, row); return true; }先校验、再改数据、最后刷视觉。这样即使视觉刷新失败数据也是对的不会出现看起来能走实际不能走的诡异状态。4.2 局部刷新而不是整体重建改一个格子的地形不要重建整个Mesh。前面提到用UV区分地形所以局部刷新只需要改这个格子对应的4个UVvoid RefreshCellVisual(int col, int row) { int v (row * width col) * 4; Rect uvRect terrainAtlas.GetUV(cells[row, col].terrain); uvs[v 0] new Vector2(uvRect.xMin, uvRect.yMin); uvs[v 1] new Vector2(uvRect.xMax, uvRect.yMin); uvs[v 2] new Vector2(uvRect.xMax, uvRect.yMax); uvs[v 3] new Vector2(uvRect.xMin, uvRect.yMax); mesh.uv uvs; }实测下来100x100地图改一个格子的UV耗时在微秒级完全无感。如果每次重建整个Mesh那就要重新分配几万个顶点的数组GC压力很大。4.3 占用与寻路的联动如果地图要接寻路比如A*占用状态必须实时同步给寻路网格。我习惯在GridCell里维护walkable寻路时直接读这个字段而不是去查场景里有没有碰撞体。这样寻路和渲染解耦改起来互不影响。要注意的是动态障碍物比如会移动的单位不要写进静态walkable否则单位一走一停就要频繁改地图数据得不偿失。移动单位用单独的动态阻挡层处理。5. 性能与踩坑那些文档不会告诉你的事前面讲的都是怎么做这一节讲怎么不踩坑。这些经验基本都是从实际项目里被坑出来的比任何教程都值钱。5.1 顶点数超限与Mesh分割Unity单个Mesh的顶点数上限是6553516位索引。一个格子4个顶点那么最多约16000个格子也就是126x126左右。超过这个数要么用32位索引mesh.indexFormat IndexFormat.UInt32要么把地图切成多个Mesh分块。我建议直接上32位索引一行代码解决省得后面分块逻辑复杂化。mesh.indexFormat UnityEngine.Rendering.IndexFormat.UInt32;5.2 浮点精度地图越大越明显当地图很大比如1000x1000格每格1米世界坐标会到1000米量级浮点精度开始下降格子边界可能出现抖动。解决办法是把地图原点放在地图中心让坐标正负对称减小绝对值的量级。或者用相对坐标渲染时再偏移。这个坑在小地图上完全看不出来地图一大就暴露。5.3 拾取频率与节流悬停高亮如果每帧都做射线检测格子多了会有开销。虽然平面求交很快但也没必要每帧算。我的做法是记录上一帧的鼠标位置位置没变就不重算if (Input.mousePosition ! lastMousePos) { lastMousePos Input.mousePosition; UpdateHover(); }这个优化在低端设备上效果明显尤其是移动端。5.4 常见问题速查表现象可能原因解决方向地图从上面看不见三角形顶点顺序反了调整为顺时针顺序点击总是选中旁边格子用了RoundToInt或漏了0.5改用FloorToInt中心加0.5格子边缘闪烁Z-Fighting高亮面抬高0.01大地图帧率骤降每格一个DrawCall合并Mesh或开Instancing点到地图外崩溃没做边界校验TryGetCell返回bool地形切换有渗色图集无padding加padding或用Point过滤地图越大越抖浮点精度不足原点居中减小坐标量级这张表基本覆盖了我这些年遇到的大部分问题遇到bug先对照一遍能省不少时间。6. 组件化封装让它真正可复用最后聊聊怎么把这套东西封装成可复用的组件。我的做法是拆成三个脚本GridMap负责数据和Mesh生成GridInteraction负责拾取和输入GridVisual负责高亮和特效。三者通过事件通信互不直接依赖。public class GridMap : MonoBehaviour { public event Actionint, int OnCellClicked; public event Actionint, int OnCellHovered; // 数据 Mesh 生成 } public class GridInteraction : MonoBehaviour { public GridMap map; // 射线拾取触发 map 的事件 }这样换一个项目直接把GridMap和GridInteraction拖过去就能用视觉部分按需替换。事件驱动的好处是业务逻辑比如点击后造建筑完全不用改地图代码监听事件即可。我在实际项目里用这套结构做过战棋、塔防和数字孪生三种场景改动量都很小。唯一需要按场景调整的是拾取方式平面地图用数学求交起伏地形用物理射线六边形网格则要换一套坐标换算公式。但整体骨架是通用的。如果你也在做网格地图我的建议是先把坐标换算和边界校验这两个基础打牢再往上堆功能。这两块一旦有隐患后面所有交互都会跟着出问题而且极难排查。至于渲染方案小地图随便选大地图直接上程序化Mesh加图集别犹豫。
网站建设高端定制企业官网