Unity动态网格地图生成与交互组件实战指南
2026/9/18 15:32:37 网站建设 项目流程

在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-10-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.ScreenPointToRay

Ray 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节的公式换算成格子索引。但这里有个边界判断必须做:如果点到了地图外面,colrow会变成负数或超出范围,直接访问数组会抛异常。所以每次换算后都要校验:

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个UV:

void 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的顶点数上限是65535(16位索引)。一个格子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 Action<int, int> OnCellClicked; public event Action<int, int> OnCellHovered; // 数据 + Mesh 生成 } public class GridInteraction : MonoBehaviour { public GridMap map; // 射线拾取,触发 map 的事件 }

这样换一个项目,直接把GridMapGridInteraction拖过去就能用,视觉部分按需替换。事件驱动的好处是,业务逻辑(比如点击后造建筑)完全不用改地图代码,监听事件即可。

我在实际项目里用这套结构做过战棋、塔防和数字孪生三种场景,改动量都很小。唯一需要按场景调整的是拾取方式:平面地图用数学求交,起伏地形用物理射线,六边形网格则要换一套坐标换算公式。但整体骨架是通用的。

如果你也在做网格地图,我的建议是先把坐标换算和边界校验这两个基础打牢,再往上堆功能。这两块一旦有隐患,后面所有交互都会跟着出问题,而且极难排查。至于渲染方案,小地图随便选,大地图直接上程序化Mesh加图集,别犹豫。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询