ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Unity平铺网格地图(Tilemap)从零搭建与性能优化全攻略

Unity平铺网格地图(Tilemap)从零搭建与性能优化全攻略

做2D游戏搞地图,最怕什么?我见过太多项目,策划改一版地形,程序就得手调一整天的坐标,美术画好的素材还得在引擎里一张张摆。平铺网格地图(Tilemap)要解决的,就是这个痛点:把地图切成规则的格子,用瓦片批量拼装,碰撞、美术、后续修改全都在同一套网格上完成。这篇文章我会从零开始,用一个实际可跑的流程,把Unity里平铺网格地图怎么做、为什么这么做、踩坑怎么排查,一次讲透。适合正在做2D关卡、想提升地图制作效率的开发者。

1. 从需求谈起:为什么你的2D游戏需要一个Tilemap系统

1.1 直接手画地图的痛点

我见过太多新手第一次做横版或者俯视角地图,都会选择最原始的办法:把美术素材一张张拖进场景,然后对着Inspector手动改坐标。这样做小Demo没问题,但稍微复杂一点就崩溃了。我举个具体例子:假设你要做一张20×15的草地地图,素材是1×1的瓦片,纯手动摆300个Sprite到场景里,还要保证每块严丝合缝,光是眼睛对网格就能对半小时。更麻烦的是改图,策划说要加一条河,所有相关坐标全部要重调,图层顺序稍微一乱,角色就被草挡住了。

还有一个很容易被忽视的问题:手摆Sprite做地图时,碰撞体是逐块生成的。一个300×300的格子场景,就会产生几百个独立的碰撞体,物理系统要实时处理这些互相接壤的碰撞边界,性能损耗非常不划算。玩家可能感觉不出来,但一旦场景变大、动态物体变多,帧率波动就开始明显了。所以我觉得,地图制作这件事,从第一天就应该用对工具,而不是等项目中期再返工。

1.2 Tilemap 与手写网格的根本差异

Tilemap的核心思路是“网格 + 瓦片”两层结构。网格负责定义空间规则,瓦片负责记录每个格子长的什么样、有什么属性。你不再直接操作一个个Sprite的Transform,而是操作一个二维数组,每个数组元素是一块瓦片。渲染和碰撞交给引擎统一合成。这就是它和“手写网格”本质的区别:字典式的数据结构取代了离散对象堆叠。

使用Tilemap有几大立竿见影的好处。第一是批量刷图,地形、河流、墙壁可以用笔刷直接画出,修改成本极低。第二是统一合批,同一图集的瓦片会被自动合并批次渲染,比几百个Sprite快得多。第三是碰撞合并,Tilemap Collider 2D 可以把相邻格子生成一个合并多边形,物理边界数量大大减少。第四是数据友好,地图本质是一个数据模型,运行时改造、存档、程序化生成都非常自然。

有人会问,那我自己写一个二维数组再加一套网格渲染逻辑,是不是也一样?理论上可以,但你要自己处理网格坐标换算、纹理图集采样、碰撞合并、编辑器可视化、规则瓦片自动过渡,这些功能加起来工作量非常大,而且很难做得比现成系统更稳定。所以,除非你有非常特殊的地形需求(比如完全不规则的六边形变体、自定义编辑工具链),否则基于Tilemap扩展,性价比远高于从零造轮子。

2. 动手前的准备工作

2.1 项目创建与包管理器检查

先从创建项目开始。打开Unity Hub,新建一个2D项目。很多人会问:我做的是俯视角3D或者2.5D,是不是该选3D模板?这里我的建议是:只要渲染方式还是基于Sprite和平面坐标,就选2D模板。2D模板会自动更改编辑器布局、摄像机投影、默认导入器的纹理类型,省掉一堆设置。如果实在要混着做,后面也可以手动改成正交摄像机。

新建项目后,第一件事是检查包管理器里有没有Tilemap相关模块。在较新的Unity版本里,2D项目默认自带2D Tilemap Editor。打开Window > Package Manager,在Unity Registry搜索“2D Tilemap Editor”。看不到或者被误删了,就点Install装回来。顺带检查2D Sprite、2D PSD Importer这些常用包,地图做多了都会用到。

另外强烈建议把Edit > Project Settings > Editor里的Sprite Packer模式设为Enabled For Build,这个和后面的合批优化有关,我放到性能那一节详细说。很多人一开始不重视这个开关,等地图刷大了才发现Draw Call爆炸,再回去改图集配置反而麻烦。

2.2 弄懂 Grid 和 Tilemap 这对核心组件

Unity的Tilemap体系里,Grid和Tilemap是父子关系,职责完全不同。

  • Grid:定义网格的排列规则。Cell Layout决定格子是矩形(Rectangle)、六边形(Hexagon)还是等轴(Isometric);Cell Size决定格子的物理大小;Cell Gap决定格子之间的间隙。Grid本身不渲染任何东西,它只是一个空间参考系。
  • Tilemap:依附在Grid下,真正存储和渲染瓦片的组件。一个Grid下可以挂多个Tilemap,比如“地形层”“装饰层”“碰撞层”各挂一个,叠加显示。

为什么拆成两个组件而不是合成一个?因为实际项目里经常需要“同一个网格,多层地图”。地形、血迹、阴影都要对准同一套格子坐标,如果每个层都各自定义网格,坐标换算会非常痛苦。Grid作为唯一的地图参考系,N个Tilemap共用同一把尺子,这是很优雅的设计。

还有一点要知道:Tilemap的坐标不是世界坐标,而是格子坐标。比如(0,0)是中心格,(1,0)是它的右边一格。网格坐标和世界坐标之间的换算是开发时绕不开的基础操作,后面我会给具体代码。

3. 第一张地图的完整搭建流程

3.1 创建Grid与Tilemap层级结构

在Hierarchy窗口里右键选择2D Object > Tilemap > Rectangular,场景里会自动生成一个Grid对象,下面挂一个叫Tilemap的子级。这一步实际上做了一个很有价值的事:在Grid上自动加了Grid组件,在Tilemap上自动加了Tilemap和Tilemap Renderer组件。

建完之后,我给不同用途的层命名规范,防止后面图层多到认不出。我的习惯是:

  • Terrain:地形层,放草地、土地这类底层。
  • Props:道具层,放树木、石头、箱子。
  • Collider:碰撞层,单独用不渲染的瓦片做隐藏碰撞。

命名先不说,层级顺序很重要。Tilemap越靠上,渲染越靠前。地形层应该放在最底下,装饰层在上面。这个顺序对应的是“地图最终绘制顺序”。

这里我要提醒一个新手常犯的错:不要在一个Tilemap里既放可视瓦片又放碰撞瓦片。想法很简单:我希望某个格子既有草地的外观又能挡人。但如果把碰撞挂在可见瓦片上,Tilemap Collider 2D会对所有瓦片生成碰撞体,包括那些只是想装饰的小草、小石子,结果角色被一颗草卡住,非常影响体验。正确的姿势是:视觉层只管画,碰撞层用专门的无Sprite空瓦片或者另一个Tilemap单独做。这样做还有一个好处:视觉层可以做循环动画(比如水面闪烁),碰撞逻辑完全不受影响。

3.2 把美术切片变成可用瓦片

地图素材通常是一张整图,要先切成小瓦片。Unity切图不一定要用外部工具,Sprite Editor就够。选中导入的图片,Inspector里把Texture Type设为Sprite (2D and UI),Sprite Mode设为Multiple,然后点Sprite Editor。在Sprite Editor窗口里,用Slice菜单按Grid By Cell Size切分。比如瓦片是16×16,就在Pixel Size填16、16,Apply之后图片就被切成了N个独立Sprite。

这里有一个决定成败的细节:像素单位(Pixels Per Unit)必须和格子大小匹配。如果瓦片是16×16像素,而PPU设成了默认的100,那这个瓦片在场景里占0.16个单位,和Grid默认1个单位的Cell Size根本对不上。我习惯把PPU统一设置成瓦片边长(16就设16,32就设32),这样一块瓦片正好占据1个Grid单元格。当然也可以在Grid组件的Cell Size里写0.16去适配,但那样所有美术素材都得很一致,比改组件设置麻烦得多。切好的Sprite会显示在Project窗口,展开图片就能看到。

补充一个实战技巧:切图时建议把Sprite Editor的“Keep Padding”勾上,并设置Padding值为4或8。这样每个瓦片四周会多一圈透明像素,运行渲染时能避免边缘采样出血。这个问题在连续的地形图里尤其明显,我现在切图直接默认勾上,省得后面出现细缝再回来处理。

3.3 用调色板刷出地形

瓦片备好了,打开Window > 2D > Tile Palette。第一次打开,点Create New Palette,命名为Map,Cell Layout选Rectangle。

创建后,调色板窗口左边是空画布,把Project里的Sprite拖进画布,Unity会自动生成对应的Tile资产(Asset)。这些Tile资产才是瓦片绘制的最小单位,而不是Sprite本身。两者区别要搞清楚:Sprite是纯图像数据,Tile是位于特定网格中的“带有交互逻辑的实例描述”,它可以额外携带碰撞、动画、规则等数据。

然后就可以画图了。选中调色板里的瓦片,用画笔工具在Scene视图刷图,或者选中一块区域用矩形填充工具。刷的时候按住Shift可以连续填充矩形,这个快捷方式我强烈建议记下来,大面积铺地形效率能高一倍以上。

画完第一张图,你会看到Tilemap Renderer把瓦片组合成一个Mesh进行渲染。这时可以做个小测试:选择角色,放到地图上,跑一跑,看看有没有穿帮或者缝隙。缝隙问题通常来自图集中瓦片边缘的出血,处理办法我在性能与排查那一节详细讲。

4. 细节打磨:让地图不再是“积木块”

刷完第一版地形,整体看起来会有一种“格子感”——草地是一格一格完全相同的块,相邻地形类型之间没有任何过渡,像网格纸上填色。这一节主要解决这个问题。

4.1 规则瓦片让地形自动过渡

所谓“规则瓦片”,英文是Rule Tile,它的核心思想是:你不用手动选每一块瓦片,只要定义“这个瓦片在什么邻居组合下显示什么Sprite”,系统就会根据周围8个格子的状态自动选择最合适的贴图。这很像自动铺路算法:当你把一块规则瓦片刷到一个位置,它的外观会根据邻居的情况自动变化。

拿草地与泥土交界举例。美术通常会提供一组过渡瓦片:中间的泥土、中间偏上的泥草边界、右侧泥草边界、右上角的草角等等。在Unity里创建一个Rule Tile很简单:Project窗口右键Create > Tiles > Rule Tile。选中该资产,把美术素材按规则棋盘的九宫格拖入。左下角那个小窗口代表当前瓦片自身,周围8个格子设定每种邻居的判定状态。状态有三类:Required(必须是某瓦片)、Not Required(必须不是某瓦片)、Don't Care(无所谓)。

配好之后,把这个Rule Tile刷到地上,你会看到边界自动衔接了。重点是:如果后期要改地形走向,只需要用橡皮擦擦掉几个格子,周边瓦片会自动重新计算并替换外观。这极大减少手工微调的工作量。

这里我想多说两句配置细节。同类地形最好用同一个Tile Base作为邻居判断标准。如果草地来自图集A、泥土来自图集B,规则判断时可以选择限定“同Tile基础”,不要用“任意Tile”,否则跨Tilemap的层也会参与判断,容易出现意外结果。另外,大量Rule Tile同时刷新时会有一定的计算开销,建议只在刷完地图后执行一次批量刷新,不要在Update里每帧刷新所有Tilemap。

关于Rule Tile的进阶用法,还有一个叫Animated Tile的变体。它允许一个瓦片连续替换多张Sprite,适合做水面、火焰这类循环动画。配合Tilemap的刷新机制,做动态场景非常出色。我曾在一个小项目里用Animated Tile做河流,结果整个水面都在循环流动,编辑器里直接能看到效果,效率非常高。

4.2 排序层:角色与地图的遮挡关系

瓦片是2D Sprite,要正确处理角色和地图的遮挡,需要看渲染排序。2D排序核心按两个因素:Sorting Layer和Order in Layer。不同Tilemap层之间,Sorting Layer越靠后的先渲染、越靠前的后渲染,后渲染的覆盖先渲染的。我的习惯是:地形层Sorting Layer设为Default的“0”,其Order in Layer设为-10;装饰层设为Default的“5”;角色动态物体单独给最高层。

如果你做俯视角,且地图有Y轴深度(例如人物要绕到大树后面),只有Sorting Layer还远远不够,得开启Sprite Renderer的Custom Axis排序。在Project Settings > Graphics > Sprite Renderer里,可以设置Transparency Sort Mode为Custom Axis,并把Transparency Sort Axis设为(0, 1, 0),这样引擎按Y坐标从低到高排序:站在树下方(Y更小)的物体先渲染,树后方的物体自然被树遮挡,实现“绕到背后”的效果。注意这个设置是全局的,会同时影响所有Sprite Renderer,调试时要统一观察。

Tilemap Renderer 2D本身也可以参与这种Y轴排序。在Render Mode里选择Individual,它会按每个瓦片的Y坐标动态调整渲染顺序。代价是批次合并减少,因为每个瓦片的Sort Order都不一样,GPU必须单独绘制。所以我通常只在装饰层用Individual模式,大面积地形层保持Chunk模式,用空间换取视觉效果。这个选择没有绝对标准,关键看你项目的镜头是固定还是跟随、场景是开放还是封闭。

4.3 碰撞体的配置与合并

给Tilemap加物理:选中Tilemap对象,添加Tilemap Collider 2D。默认情况下,引擎会为每个有碰撞体的瓦片生成一个独立的碰撞多边形,然后同Tilemap内的相邻瓦片自动合并,所以通常不需要自己拼碰撞。加上之后,建议再勾选Used By Composite、并在同物体上添加Composite Collider 2D。Composite Collider会把多个碰撞多边形进一步合并成一个凸多边形/凹多边形组合,物理模拟时边界数量大幅减少,也规避了“角色卡在格子缝里”的抖动问题。

不过要注意:Composite Collider要求碰撞体必须是凸多边形,瓦片生成的多边形需要做标记:确保瓦片资产里的Collider Type设置为Sprite(而不是Grid或None),且对应的Sprite形状简单。硬要复杂碰撞形,建议用专门的Physics Shape编辑,否则Composite会报错或忽略部分形状。

在碰撞层上使用的瓦片,我个人的做法是:新建一个空白的Tile,叫CollisionTile,不需要关联Sprite。然后给这个Tile资产设置一个矩形碰撞体(Collider Type选Sprite)。把CollisionTile刷到需要阻挡的位置,渲染上完全不可见,但碰撞生效。这种做法让地图的“视觉”和“物理”彻底分离,修改碰撞时完全不影响画面表现。尤其在做复杂关卡时,策划自己就能在编辑器里添加看不见的墙,非常直观。

5. 运行时动态操作与代码控制

编辑器里刷好的地图只是“结果”,真实游戏里往往还需要在运行时创建、替换、删除瓦片,甚至程序化生成整张地图。这就要用代码操作了。

5.1 用代码动态创建与替换瓦片

所有Tilemap的代码操作都围绕几个核心API:

// 获取某个格子的瓦片 TileBase tile = tilemap.GetTile(new Vector3Int(0, 0, 0)); // 设置某个格子的瓦片 tilemap.SetTile(new Vector3Int(0, 0, 0), myTile); // 清空某些格子 tilemap.SetTile(new Vector3Int(0, 0, 0), null);

SetTile是底层API,无论你用的是规则瓦片、动画瓦片还是普通Tile,格子类型统一叫TileBase。写代码前一定要明白这个抽象概念:你操作的是接口,不是某个具体瓦片类。

网格坐标向世界坐标转换需要经过Grid:

// 格子坐标转世界坐标 Vector3 worldPos = grid.CellToWorld(new Vector3Int(5, 5, 0)); // 世界坐标转格子坐标 Vector3Int cell = grid.WorldToCell(transform.position);

在写放瓦片逻辑时,最常见的需求是“单位走到哪,就把脚下的某块瓦片替换掉”。比如踩过的草地变成泥土。流程是:在Update里对玩家的世界坐标做WorldToCell得到格子坐标,然后用GetTile判断当前格子的类型,如果是草地,就SetTile替换成泥土。注意WorldToCell返回的Vector3Int已经是整数格子坐标,Z层的值一般取0。

这里我提醒一个细节:不同Tilemap层是独立的,修改前先确认你操作的是哪个层的Tilemap组件。我见过有同学GetTile取的是装饰层,SetTile却设置到地形层,结果地图上出现两个重叠的物件,排查半天才发现是层引用搞混了。

5.2 地图数据的序列化与保存

刷好的地图是场景数据的一部分,随场景保存。但如果做了运行时动态修改(比如地面被破坏),存档时就需要把修改后的瓦片状态记录下来。最简单的方案:遍历Tilemap的所有格子,把每个非空格子的坐标加TileBase名称存进一个列表。加载存档时再反过来SetTile。这个小方案有几个坑:第一,遍历要用tilemap.cellBounds,它是一个BoundsInt,记录被占用的最小包围盒;第二,TileBase没有公开的ID,拿TileBase.name做标识,要注意重名问题;第三,如果是规则瓦片,存档时直接保存的是规则瓦片本身,重新SetTile后规则逻辑会自动重新计算外观,所以不需要额外保存邻居状态,这点很关键。

如果地图特别大,比如1000×1000,遍历全部格子做存档就不太现实。我建议维护一个“脏格子”列表:只在格子状态改变的时候记录该格子的坐标和瓦片名。运行结束后合并保存。这也是很多体素游戏和roguelike通用的做法。实际项目中,我习惯把存档数据封装成一个自定义的DataContainer,格子列表序列化成JSON或二进制,加载时再分发到对应Tilemap,后续扩展也比较方便。

5.3 运行时地图刷新性能注意点

动态改Tilemap时,引擎会自动对改动区域做Mesh重建,所以不需要手动刷新。但如果一帧内改了成千上万个格子,Mesh重建会拖垮帧率。应对策略是:把改动缓存在一个队列里,每帧最多处理一定数量(比如每帧500格),这样大量刷地的功能不会带来卡顿。

另一个实用的API是tilemap.ResizeBounds(),当你批量扩展地图边界时,调它可以让引擎一次性更新包围盒,避免每设置一格就计算一次边界。我自己写程序化生成算法时,都是先算好整个地图范围,直接ResizeBounds,然后在一个循环里SetTile,结束后一次刷新,效率高很多。程序化生成方面,网上能找到很多Perlin噪声生成地形的例子,原理就是遍历格子、采样噪声值、根据阈值SetTile,搭配规则瓦片就能快速生成一片自然过渡的地形区域。

6. 性能优化与常见问题排查

写到这里,平铺网格地图的基础操作已经能完整跑通了。但如果要放进真实项目,还必须面对性能问题。2D地图看起来简单,性能瓶颈藏得很深。

6.1 图集、平台设置与合批

最重要的一件事:把地图素材打进图集。选中所有瓦片Sprite,把它们放入同一个Sprite Atlas(右键Create > 2D > Sprite Atlas),并设置Atlas的Type为Master。运行后,Tilemap Renderer会把这些瓦片绘制合并为一个Draw Call。没有图集的话,Shader需要逐个纹理去绑定,Draw Call数量会线性上升,移动端尤其明显。

影响合批还有一个容易被忽略的变量:瓦片的颜色。如果瓦片Sprite引用了不同图集,或者Tilemap上启用了Alpha裁剪,合批都可能被打断。在Tilemap Renderer的Detect Chunk Culling Bounds被修改时,同样会临时中断渲染合并,所以别一直动态改Render Bounds。

再说说瓦片Unity包里的Texture Compression。地图这类大图集,在PC上可以用RGBA32,在移动端建议压缩成ASTC。压缩太高近距离会出现画面模糊,太低则内存爆炸。我通常把图集的Max Size设置成2048或者4096,具体看地图最大尺寸来定。最后记得在Inspector里点一下Atlas资产的Generate按钮,否则图集不会真正生成。

6.2 常见问题速查表

整理几个我实际工作中高频遇到的问题,按“现象-原因-解法”列出来,方便大家按图索骥:

现象原因解法
瓦片之间有细缝图集无Padding,或者PPU设置不一致切图时勾选Keep Padding,统一PPU
角色卡在瓦片边缘瓦片单格碰撞体过多,或者碰撞合并缺失用Composite Collider合并,或单独碰撞层
背景和前景重叠看不清Sorting Layer设置错误设置正确层级;俯视角开启Y轴排序
动态刷地图卡顿一帧内SetTile太多分帧处理,先ResizeBounds
瓦片显示变色或模糊图集压缩太高或Sprite Atlas未生成调压缩格式,检查Atlas是否已Generate
规则瓦片边缘不衔接邻居判定条件写错检查Rule Tile九宫格配置,注意Required/Not Required状态
Tilemap没有碰撞忘了加Collider,或空Tile资产上未设置碰撞确保地形或碰撞层挂Tilemap Collider 2D

这里我要单独拎出来讲一个排查很坑的事:Tilemap Collider 2D在设置了Used By Composite后,如果Tilemap对象上还挂了Rigidbody 2D,刚体类型必须是Static或者Kinematic,否则组件之间会发生冲突,碰撞体完全失效但编辑器不报错。我遇到过两次,都是排查了半天才发现是Rigidbody 2D类型设错。正常情况下,静态地图不要给Tilemap对象挂Dynamic刚体,用静态碰撞就够了。

另一个常被忽略的问题:像素完美。如果项目用了Pixel Perfect Camera,瓦片上的Sprite PPU、Camera的Pixel Ratio、目标分辨率三者要匹配。否则即使Tilemap本身没错,屏幕上也会出现发虚或闪烁。这个和前面缝隙问题往往同时出现,排查时优先确认PPU。在手机上,不同屏幕分辨率带来的像素缩放差异尤其明显,建议打包前用多个分辨率测试一遍。

7. 写在最后:我的一些工作习惯

最后分享几个我自己在多个项目里沉淀下来的习惯,不一定适合所有人,但确实让我省了很多事。

第一个习惯是给瓦片命名时带上位置和状态信息。比如grass_center、grass_top_0、grass_top_1,规则瓦片多了之后,在Project窗口按名字排序非常重要。Unity资源窗口不支持多级Tag管理,命名规范基本就是唯一的管理手段。

第二个习惯是项目一开始就建立碰撞层,哪怕地图还没画。这样后续刷图时可以顺手把碰撞画上去,而不是等美术全部画完再回头补物理。我吃过几次亏:地图已经定稿,策划开始调关卡,结果发现还有个地方忘了加碰撞,再回去改动就会影响已验证的流程,非常麻烦。

第三个习惯是尽量用ScriptableObject保存瓦片的扩展信息。瓦片本身只负责长什么样,但一张地图往往还需要“这块地能不能走”“踩上去会不会扣血”“要不要触发事件”这类逻辑数据。把这些配置放在和Tile对应的ScriptableObject里,策划改数据不需要动代码,程序这边也更容易维护。

平铺网格地图这个领域,入门不难,难的是把细节做到位。网格坐标、规则瓦片、碰撞合并、图集合批,每一环都会影响最终手感。你按照这篇文章的路径从零搭一遍,再对照排查表调几轮,基本上就能踩平90%的坑。剩下的,就交给实际项目去磨了。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进