做2D游戏做到中期,最让人头疼的往往不是玩法逻辑,而是场景搭建。我最早做平台跳跃游戏时,一张地图全靠手摆Sprite,几百上千个碎块堆在Hierarchy里,找东西靠翻,改东西靠选,调个墙体的位置要在一堆同名物体里辨认半天。最难受的是绘制批次和内存占用,场景稍微大一点,Profiler里Draw Call直接起飞。后来换了Unity TileMap,整个关卡搭建方式才算理顺。这篇文章不打算给你念API文档,而是把TileMap这套系统从概念、原理到实战,说明白它到底怎么工作、应该怎么用,以及在什么情况下它反而不合适。
这篇文章适合两类人:一类是刚开始学Unity、准备做2D游戏但还没有系统接触过瓦片地图的开发者;另一类是已经用过TileMap但只停留在“拖个调色板往场景里画”的程度,想搞清楚背后的数据结构和性能逻辑,顺便解决几个常见坑的人。读完你至少能理解四件事:TileMap为什么要挂在Grid下面、规则瓦片是怎么自动匹配邻居的、碰撞体该如何正确合并、以及运行时动态改格子应该注意什么。
1. 从手动摆图到瓦片地图:TileMap 到底解决了什么
要理解TileMap,先得回到一个最原始的问题:为什么不能直接用Sprite把地图拼出来?
理论上完全可以。把一张张地块图片当作Sprite拖进场景,用Transform摆好位置,无非是工作量的问题。但这种做法在地图稍微复杂一点之后,会呈现三个非常现实的问题。
第一个是场景物体数量爆炸。假设你有做一个100x100格的地图,用Sprite碎片摆放,意味着场景里会出现一万个GameObject。每个GameObject都有Transform、SpriteRenderer,即便内容一样,Unity依然要把它们当作独立的实体来管理。Editor里卡顿是第一层,运行时的管理开销是第二层,而最明显的是在Profiler里能看到大量CPU时间花在C++层级的节点管理上。
第二个是绘制批次过高。SpriteRenderer默认按顺序渲染,虽然Unity有动态合批,但合批的前提是材质一致、贴图一致,并且渲染顺序相邻。手动摆放的Sprite一旦穿插了碰撞体、粒子、UI等元素,合批经常被打断。地图越大,Draw Call越难压下去。
第三个是编辑效率低下。改一格地块,你得先找到那个Sprite物体,再移动它的位置。如果某种地块要更换贴图,需要批量替换,还得担心漏掉某个角落。最恶心的是做地图迭代时,整块区域重排几乎是噩梦。你甚至会想到写编辑器工具来批量摆放,但写完又会发现,工具只适合一次性操作,不适合持续调整。
TileMap解决这三个问题的思路,其实是一次彻底的重构。它不再把每个地块当作一个独立的GameObject,而是把整个地图抽象成一层二维网格数据。每个格子存储的是一个瓦片索引或者引用,真正的瓦片资源(也就是Tile)是共享的。场景里你看到的是一块一块的地块,数据结构里存的却只是一张大表。
这样一来,场景物体数量从“格子数”降到了“图层数”。一万个格子的地图,在Hierarchy里只有几个Tilemap对象。绘制批次方面,TilemapRenderer会尝试把同一张图集上的相邻瓦片合并成一个网格,一次提交完成渲染。编辑效率更是质变,你想改一整块区域,框选、填充、橡皮擦,几秒钟搞定,还能配合规则瓦片让地块自动匹配边界。
用一个不太严谨但很贴切的类比:手动摆Sprite就像是用单独的砖块砌墙,砖块之间没有联系,拆一块是一块;TileMap则像是给墙刷了一层电子表格,你只需要维护“哪个位置是哪种砖”这个数据,墙本身只是一个计算结果。
但这里有一个关键的认知误区需要先纠正。很多初学者以为TileMap只是一个“画地图的工具”,用了它画的图好看、方便。实际上TileMap是一套数据驱动的地图方案,核心价值不在于“画”,而在于“存”和“算”。你可以用代码动态生成地图,也可以在地图运行时修改,甚至可以把地图数据序列化到存档里。调色板只是编辑器层面的一个入口,真正的核心是Tilemap组件上那张“表格”。
理解到这一层,你才能理解后面所有的设计取舍,比如为什么要分多个Tilemap图层、为什么规则瓦片能自动匹配、为什么碰撞体要合并、为什么运行时改格子会触发重绘。
2. 拆开看:Grid、Tilemap、Tile、TilemapRenderer 各自的活儿
很多新手创建TileMap时,是直接右键 > 2D Object > Tilemap,Unity自动生成了一套层级结构:一个Grid父物体下面挂了Tilemap子物体。看起来很简单,但如果你不知道这四个组件各自的职责,后面遇到问题就会一头雾水。我见过不少人在Grid上瞎调Cell Size,调完瓦片全部错位,然后又不知道错在哪。
2.1 Grid:坐标基准与网格布局的提供者
Grid是整个TileMap系统的坐标系基础。它不参与实际的地块渲染,也不存储任何地图数据,它的角色是回答一个问题:“世界坐标里的这个点,落在网格里的哪个格子?”
Grid有三个核心参数被高频使用:Cell Size(格子尺寸)、Cell Gap(格子间隙)、Cell Layout(网格布局)。其中Cell Size经常被搞混。它和Sprite的Pixels Per Unit有直接换算关系。比如你的地块素材是32x32像素,Sprite导入时PPU设为32,那么一个格子对应的世界单位是1米(Unity 2D里1单位通常是1米),Cell Size就应该是(1, 1)。如果PPU是16,同样是32像素的素材,一个格子就是2米,Cell Size要相应地改成(2, 2)。这里有个常见的做法是直接把Cell Size设为素材像素值除以PPU的结果,这是对的,只是容易漏掉像素对齐的问题,后面专门说。
Cell Layout则决定网格类型。绝大多数2D游戏用Rectangle(矩形网格)就行,但如果你做的是斜45度视角的模拟经营类游戏,需要选择Isometric(等距网格);如果你的地图是六边形战棋类游戏,则要选Hexagonal(六边形网格)。网格类型一旦确定,最好不要在项目中期修改,因为瓦片坐标系统全变了,已有的地图数据会错位。
2.2 Tilemap:那张真正的“表格”
Tilemap就是那个存储二维数据的容器。表面上是网格里的一块块瓦片,本质上是内部维护了一个类似字典的数据结构,把网格坐标映射到Tile资源引用上。这个组件提供了非常丰富的读写API,比如SetTile、GetTile、HasTile、ClearAllTiles、SwapTile等。这些API是你做运行时地图修改、建造破坏系统的入口。
Tilemap还自带两个与碰撞相关的子组件:TilemapCollider2D和TilemapCompositeCollider2D(需要额外添加)。前者的作用是根据瓦片自身的碰撞形状生成一个个独立的碰撞体;后者的作用是把相邻的碰撞体合并成一个PolygonCollider2D。肉眼看起来差异不大,但物理引擎处理一个大的复合碰撞体和处理几十上百个小碰撞体的开销完全是两个量级。后面我会专门展开讲。
一个Tilemap对应一个图层,所以做游戏时通常会建立多个Tilemap子物体,比如地表层、墙壁层、装饰层、交互层。这样做的好处是:不同图层可以有不同的排序、不同的碰撞设置,还可以分别控制显隐。你只需要在处理逻辑时按层访问即可。
2.3 Tile:地图单元格的最小数据单元
Tile本身是一个ScriptableObject资源文件。它把“显示哪张图”“碰撞形状是什么”“是否会影响导航”等属性打包在一起。这里的关键点是:Tile是资源引用,不是实例。一万个格子如果都引用同一个Tile,内存里只会加载一份Tile数据,一万个格子共享这一份引用。这就是TileMap内存开销远低于手动摆Sprite的根本原因之一。
Tile上有个很容易被忽略的重要配置项叫做Collider Type。它有三种取值:None、Sprite、Grid。None就是没有碰撞;Sprite是根据精灵的自定义物理形状生成碰撞;Grid则是把整个格子当作一个矩形碰撞。做平台跳跃游戏时,平台地块通常用Sprite模式配合精灵图集里的Physics Shape,可以做出边缘有弧度的地表碰撞。做整体地形时,用Grid模式最简单粗暴。但无论选哪一种,都建议配合Composite Collider使用,否则瓦片多了碰撞体数量会非常夸张。
2.4 TilemapRenderer:决定怎么画出来
这是真正干渲染活的组件。它有Mode属性,分为Chunk和Cell两种。Chunk模式下,Unity会把同一图集中的相邻瓦片合并成尽量少的网格块,这是默认也是推荐模式,对Draw Call最友好。Cell模式则是每个格子单独绘制,几乎没有合批优势,只在某些需要逐格控制的特殊场景下才会用到。
TilemapRenderer还参与了排序。它跟你平时用的SpriteRenderer一样,有Sorting Layer和Order in Layer。同一Tilemap对象下所有瓦片共享同一个排序层级,所以做Y轴遮挡类的2D游戏时(比如角色走到树后会挡住),依赖排序层或Order in Layer是行不通的,需要用分块Tilemap或者烘焙排序的方法来处理,这个后面也会提。
四个组件之间的关系可以这样理解:Grid是尺子和坐标系,Tilemap是数据表,Tile是数据条目,TilemapRenderer是把数据表画出来的画笔。你改Tilemap里的数据,Renderer会去响应这个变化重新生成网格。如果你在运行时改了Tile,但没有看到画面更新,大概率是改错了对象,或者没有调用需要的刷新接口,后者在老版本Unity里是个高频坑。
3. 动手搭一个 2D 瓦片关卡:从创建 Tilemap 到调色板落地
理论说了一堆,现在实际操作一遍。我会带你把一个最基础的平台跳跃关卡地形跑通,包含调色板创建、瓦片制作、绘制和分层。
3.1 准备素材:规范切割精灵图集
很多人卡在这一步就出问题,是因为精灵图集没有整理好。做瓦片素材时,我建议遵循三条铁律:所有地块素材放进同一张图集;每块素材之间留足空白(Padding至少4像素);导入时Filter Mode设为Point。前两条影响是否合批和是否出现边缘出血,第三条影响像素风游戏的清晰度。如果你做的是高清风格,Filter Mode可以选Bilinear,但边缘混血问题还是需要通过Sprite的Padding来解决,细节后面坑点总结里说。
以常见的16x16像素素材为例,图集导入Unity后,把Sprite Mode改为Multiple,然后点击Sprite Editor把每一块切出来。切的时候要注意,网格线要按像素准确对齐,不要出现半像素的情况。切割完成后,把Pixels Per Unit设置成16,Mesh Type设为Full Rect(如果不需要精确碰撞,可以不设Sprite Physics Shape)。
3.2 创建Tilemap和调色板
素材准备好后,在Hierarchy面板里右键 > 2D Object > Tilemap > Rectangular。创建后层级结构是Grid下面挂着一个Tilemap。接着打开Tile Palette窗口(Window > 2D > Tile Palette),第一次打开会让你创建一个新的调色板。调色板本质上是一个文件夹,里面放着可复用的瓦片资产。把切好的精灵直接拖进Tile Palette窗口,Unity会自动为每张精灵生成对应的Tile资源。
这里有个细节,拖入精灵时,Unity会询问是否同时创建瓦片资产。如果你的图集里有很多装饰性的单张Sprite(比如花、石头),它们不适合做成瓦片,可以通过过滤器只选取需要的地块。也可以先批量创建,然后在调色板里删除多余的。
3.3 图层拆分:地面层、墙壁层、装饰层
接着创建两个以上的Tilemap。选中Grid,右键 > 2D Object > Tilemap,再建一个。一个作为Ground(碰撞层),一个作为Wall(也是碰撞层但排序更靠后),还可以再建一个Detail(纯装饰,无碰撞)。分层的意义在于,碰撞和排序可以分开控制。Ground与Wall需要的碰撞体设置相同,但装饰层的TilemapRenderer可以做单独的排序,让角色从树后面经过时产生遮挡。
这里要注意右手边的Tilemap Renderer组件的Sorting Layer设置。默认都在Default层,靠Order in Layer区分。装饰层建议Order值比地表层高,渲染时才会盖在上面。如果你有多个Sorting Layer,也可以分配不同的SortingLayerName,这个看项目需求。
3.4 瓦片绘制:用工具把草图画出来
选中Ground这个Tilemap,在Tile Palette里选择对应地块,然后用画笔工具在Scene视图里绘制。几个常用的快捷键值得记住(鼠标不选中场景物体时):B是画笔,D是橡皮擦,G是填充(油漆桶),R是矩形选区。
这里有个小技巧,用油漆桶工具之前,先选中你要填充的调色板瓦片,然后按下G,再点击场景中的封闭区域,会自动填充整个连通区域。非常方便做地表草皮或者水面。绘制过程中如果发现瓦片对不齐网格,检查一下Grid的Cell Size和Sprite的PPU是否匹配,同时打开Scene视图右上角的Grid工具开关,确保吸附开关打开。
3.5 碰撞体加法和合并:直接影响到物理性能
选择Ground这个Tilemap对象,Add Component添加Tilemap Collider 2D,然后添加Tilemap Composite Collider 2D。关键操作来了:添加Composite Collider 2D时,Unity会提示你同时添加Rigidbody 2D,并自动把Rigidbody 2D的Body Type设为Static。这个Rigidbody不是让你做物理模拟,而是为了让Composite正常工作。如果你打开Tilemap Collider 2D组件,会看到有一个Used By Composite选项,它是自动被勾上的。
加上Composite以后,相邻瓦片的碰撞体合成一个整体,物理系统的碰撞检测数量大幅下降。在包含大量瓦片的地图中,这一步的性能提升通常是数量级的。更妙的是,从地形上挖掉一块时,复合碰撞体会自动重新生成,不需要手动处理。
3.6 导入Cinemachine:摄像机跟随与地形边界约束
有了地图之后,玩家的摄像机不能随便乱走。这里推荐直接引入Cinemachine(Package Manager里搜索安装),创建2D Camera,把目标绑定到角色。为了让摄像机不跑到地图外面,给地形加一个Polygon Collider 2D作为边界(这步要手动用Sprite或者专门建一个Edge Collider),然后给Cinemachine Camera添加Cinemachine Confiner组件,把Confiner Mode设为Confine 2D,并把这个边界Collider赋给它。
这一步解决的是Unity中非常常见的“摄像机跟随跑出地图”问题。如果你没有用Cinemachine,而是用脚本写跟随,也可以,但需要自己做边界约束。Cinemachine的好处是已经处理了很多边界平滑和抖动细节,尤其在2D游戏里,它的丝滑程度比自己写的Update加Lerp要好。
4. 规则瓦片:让相邻地块自动匹配,搭建效率大幅提升的关键
用调色板手动画图,对小型关卡完全够用。但如果你画的是草地,边框、泥地、山体混在一起,手动一格一格选择瓦片会累到怀疑人生。规则瓦片(Rule Tile)存在的意义就是处理这种场景:你只需要用一种“笔”画,Tile会根据周围格子的状态,自动选出最合适的Sprite。
4.1 简单讲讲规则瓦片的工作逻辑
场景里的每个瓦片,都有上下左右四个邻居(也有些规则支持斜角判断)。规则瓦片会针对每个可能的邻居状态组合,指定一张Sprite,或者指定一个规则。比如“如果左边和右边都有同类瓦片,就显示中间地块”“如果左边有而右边没有,就显示左边边缘地块”。当你不满足所有自定义规则时,可以用“默认Sprite”来作为兜底。
这种思路解决的是地形边缘的复杂匹配。手动画图时,你眼睛看的是整块草地的轮廓和边界,然后一块块挑对应的图块;规则瓦片把这件事变成:画出草地的主体,边缘自动长出来。
4.2 创建规则瓦片并配置规则
在Project窗口右键 > Create > 2D > Tiles > Rule Tile,创建规则瓦片资产。把瓦片资产拖入Tile Palette,选中这个Rule Tile,再点击调色板窗口左上角的“Edit Rule Tile”按钮(不同Unity版本位置略有差异),就会打开规则编辑界面。
规则编辑器里是一个3x3网格,中间一格代表“当前瓦片本身”。周围八格可以设置五种状态:箭头图标表示“这个位置必须有一个同类瓦片”,X图标表示“这个位置必须没有同类瓦片”,问号表示“忽略这个位置”,另外还有“要有但不要求同类”和“不能有且不要求非同类”的组合。通过组合这些状态,你可以配置出草地边缘、转角、孤岛等全部情况。
打个比方,草地的“内部”规则是周围四格全部要有同类瓦片;而草地“边缘”规则是左、右、上都有同类,但下方没有,就显示下缘的图块。把所有组合都填上,草地上下左右边缘、四个转角就都齐了。
4.3 规则瓦片的进阶玩法与动画瓦片
除了Rule Tile,2D Extras包里还提供了不少现成的瓦片类型,比如Animated Tile(动画瓦片)和Random Tile(随机瓦片)。
动画瓦片用于水面、熔岩这类动态地块,它允许你把若干帧Sprite按顺序播放,可以设置动画速度。随机瓦片则是在多个Sprite里随机挑选一个显示,用来做草地多样性的细节非常合适。比如你在画草地时用随机瓦片,有的地块带小花、有的带小石头,视觉上就不会出现大面积同样的贴图,看起来死板。随机瓦片和规则瓦片还可以组合使用:一个规则瓦片匹配的是地形边缘,但匹配到特定状态时,可以引用一个随机瓦片。这个思路做出来的地形会非常自然。
我实际做项目时,通常会先建立一个“草地主体”规则瓦片,再建立一个“草地边缘”规则瓦片,两者通过规则关联到一起。画的时候,只要把“草地主体”规则瓦片当作画笔,往空白处抹,边缘和转角全部自动生成。这种方法能让一个100格乘100格的地表在几分钟内完成,且边界严丝合缝。
4.4 规则瓦片配置时最容易犯的错
最容易出错的地方是规则里外边缘状态没配全。常见的情况是画完之后发现:转角处露底、上下边缘方向反了。翻车的根本原因是规则配置时只考虑了“相邻四格”而没考虑“对角”。比如草地出现一个孤岛,需要同时匹配四个对角方向,如果你没有为“四个对角都是草地、四边都是空地”这个组合指定Sprite,瓦片就会变成默认Sprite或空白。
解决办法只有一个:在规则编辑器里把所有组合状态排一遍。不要嫌麻烦,草地的边缘+转角+孤岛加起来通常不会超过16种组合。一次性配好,整个项目受益。另外,规则瓦片的Sprite需要在一张图集上,否则不同图集之间的邻居匹配会导致合批断开,严重的话会影响Draw Call。
5. 碰撞、排序、摄像机:把瓦片地图接入实际游戏场景
地图画好只是第一步,真正的考验是它进入物理、渲染、交互链路之后的表现。
5.1 瓦片碰撞体的开销问题:从十几个到几百个Collider
做过大世界地图的人都有体会,如果不做碰撞合并,一个小关卡的地面就可能有几百个Collider。Unity的2D物理引擎在检测碰撞时,每个Collider都需要参与BroadPhase计算。Collider数量一多,哪怕物体静止,物理线程的负担也会明显上升。
解决办法我在3.5节已经提到,就是Tilemap Collider 2D + Composite Collider 2D的组合。这里有一个进阶问题:如果只有部分瓦片需要碰撞怎么办?
最直接的方法是创建多个Tilemap:一个用于纯视觉的地面层(没有Tilemap Collider),另一个用于碰撞层(有碰撞组件)。碰撞层里可以放那些碰撞体和地面视觉完全一致的瓦片,也可以放一些不可见的“空气墙”瓦片,只在碰撞层里存在。
有一个细节值得提醒:Composite Collider 2D的Geometry Type分为Outlines和Polygons。Outlines模式会把复合体生成一个外轮廓碰撞体,适合实心地面,物理效率更高;Polygons模式会把内部区域也剖分成多个简单多边形。做平台跳跃关卡时建议用Outlines。但如果你有中空区域(比如一个洞穴的入口),Outlines可能会把洞口直接补上,反而挡路。遇到这种情况,可以把地面和洞口分到两个Tilemap,分别处理碰撞。
5.2 瓦片地图的阴影问题:别让SpriteLit遮挡了所有光
2D游戏一旦用了Universal RP(URP)的2D Light,瓦片地图自身的渲染方式会影响光照效果。默认情况下,TilemapRenderer使用的材质是Sprite-Lit-Default,它本身是支持2D光照的。但有一类问题经常出现:瓦片在光照下没有正确的法线,导致阴影方向上表现得非常怪,或者边缘的阴影硬得一塌糊涂。
解决思路是修改瓦片Sprite的Custom Physics Shape。你在Sprite Editor里,选择Custom Physics Shape,可以为精灵画出逐像素或者逐形状的法线轮廓。瓦片碰撞体用的也是这个形状(如果Collider Type是Sprite模式的话)。注意,这里的Physics Shape同时被光照系统和碰撞系统共用,二合一其实很方便。改好Physics Shape后,光照下的边缘过渡会自然很多,不会再出现一个方块挡光挡成硬边的情况。
另外,如果你在项目里发现瓦片的阴影方向不受控制,检查一下是否把瓦片放到了错误的Sorting Layer。2D光源的Normal Map影响的是材质光照计算,不受Sorting Layer控制,但阴影的投射和接收受SpriteRenderer的Cast Shadows配置影响。TilemapRenderer本身没有直接暴露Cast Shadows选项?实际上是有的:在URP项下,TilemapRenderer的Additional Settings里有Shadow Casting Mode。记得把它设置为On或者 Shadows Only,因为默认可能被关掉了。
5.3 排序的边界很痛苦:如何让角色合理地走进树后面
2D游戏里Y轴排序(角色根据Y坐标动态调整显示层级)是个经典问题。Tilemap在地形层面用起来很顺手,但涉及到动态遮挡时,如果角色不想按Y轴动态改变Sorting Order(而是想直接走到树后),问题就来了。
最简单的处理方案是:不要把所有东西放在一个Tilemap层里。把门、树、屋顶这类需要压在角色上方的物件单独放到另一个Tilemap,字符的动态排序只用在“地面层以上”的物体上。具体做法是给角色设置一个自定义的Sorting Order,在Update里根据Y坐标来更新其Order in Layer值。地面Tilemap的Order值设一个基础值,装饰Tilemap的Order设一个更高值,角色Order在两者之间动态移动,就会产生正确的遮挡效果。
这里有一个常见坑:角色的Order值不能和地面的Order值重叠,否则会出现闪烁或者排序不稳定。建议要留出明显间隙,比如地面是-100,角色范围是-50到+50,装饰层是+100。这样即便角色站在最下方,Order也追不上装饰层。用一个大间隔数字做调节,远比用0/1/2精确但混乱得多。
5.4 运行时地图交互:GetTile、HasTile 和 SetTile 的使用模式
做建造游戏或Roguelike时,运行时修改地图是刚需。基本思路是通过Tilemap的API在代码里读写格子数据。
判断某个格子是否是特定地块,用GetTile(new Vector3Int(x, y, 0))或HasTile。把地块换掉/放上去用SetTile(position, tile)。注意SetTile接收的是Vector3Int,而你在场景里看到的世界坐标是Vector3。两者需要互相转换。Grid提供了WorldToCell和CellToWorld方法,这是所有坐标换算的入口。
每次调用SetTile都会触发一次Tilemap的重新生成和渲染更新。如果你在一个循环里改一两百个格子,性能可能明显卡顿。原因是每次SetTile都可能让TilemapRenderer重新计算受影响网格块。对于需要频繁修改大量格子的场景(比如爆炸炸掉一块地形),应该先通过SetTiles或者SetTilesBlock批量修改,再统一刷新。或者临时把Tilemap的CompressPaintingCanvas这类高级选项打开,这个选项在运行时频繁修改时能显著减少内部数据结构的调整开销。
很多教程会告诉你用GetTile拿瓦片,再判断Tile资源名来识别地块类型。这个做法可以工作,但性能一般。更好的方法是用Tilemap的自定义数据或者直接通过GetTilemapCollider2D的结果配合物理检测来判断空格和地块。识别具体是“哪种地块”时,我习惯把Tile资产本身做成强引用数组索引,用数字ID对应Tile资源,而不是每次比较对象引用。
6. 性能与几个高频坑:为什么我的地形会闪、会裂、会乱
最后这部分我准备把这些年踩过的坑集中复盘一下,很多问题你在文档里根本搜不到,只有项目做到特定阶段才会遇到。
6.1 边缘裂缝:图集Padding、Filter Mode和像素对齐
瓦片边缘裂缝可以说是瓦片地图最高频的显示问题。表现是相邻地块之间有一条细缝,或者边缘出现半透明的锯齿。根因通常有三个:第一,图集里各精灵贴图边缘像素不足,导致采样时使用了相邻精灵的像素,产生白色或透明边缘;第二,Filter Mode使用了Bilinear,导致边缘像素被模糊后和旁边地块融合出错;第三,Pixel Per Unit和Cell Size没有精确对应,造成半像素偏移。
解决办法是:图集切割时设置Padding至少4px;Sprite导入时Filter Mode设为Point(像素风)或者保持Bilinear但确保图集边缘有透明区域;Pixels Per Unit和Sprite实际像素尺寸严格一致。大多数2D游戏的美术资源在出图时就会处理这些,但程序需要理解原因,才能在不同项目中避免同样的坑。
6.2 单元格数据和渲染更新不同步:编辑器里改了,运行时不显示
老版本Unity里有一个高频坑:代码里调用SetTile后,Tilemap的渲染不一定立刻刷新。尤其在编辑器下调试时,你可以在Inspector里看到数据变了,但Scene视图看不到瓦片。这通常是因为Tilemap在运行时收到数据变化后,渲染网格需要在下一次LateUpdate里重建。如果你的代码在LateUpdate之后才设置Tile,那么真正的刷新还要再等一帧。
更稳妥的做法是修改完一批瓦片之后,调用Tilemap.RefreshAllTiles()或者Tilemap.RefreshTile(pos)手动触发刷新。这个方法本身开销不大,但主动调用后,编辑器里的显示通常能立刻同步。另外,如果用了规则瓦片,当周边地块发生变化时,也需要主动刷新受影响的邻居,规则的自动更新不会瞬间覆盖所有依赖区块。
6.3 新地图块的动态合批问题:想合批就别乱动
TilemapRenderer默认是Chunk模式,Chunk模式下绘制批次优化很好。但代价是:瓦片每次增删,都要重新生成网格块。这其实是一个典型的CPU vs GPU权衡。如果你在做运行时频繁破坏的地图(比如“我的世界”风格),合批块会不断重建,性能上反而可能不如Cell模式,或者不如把地图拆成多个小Tilemap再分块刷新。
实际项目中,我倾向于把“静态部分”和“动态部分”彻底分开。静态地面永远不修改,给它一个独立的Tilemap,用Chunk模式在初始化时烘焙好;动态部分(可破坏物、建造物)用另一个Tilemap,并设置适当的刷新策略。这样既能享受静态地图的低Draw Call,又不会因为动态修改频繁触发全量重建。这个思路和你做导航网格烘焙时的动静分离逻辑非常像。
6.4 地图加载速度与内存:超大瓦片地图如何优化
一张500x500的瓦片地图,数据量其实很小(每个格子只有几个字节的索引),但渲染层和碰撞层的生成会占一定开销。碰到极大型地图时,Unity官方建议采用分块加载或者按需生成。最直接的方式是代码控制可见范围——离摄像机太远的Tilemap直接SetActive(false);或者使用多个Tilemap块,每个块负责一块区域,按摄像机位置加载附近的块。
内存方面,Tile资源是ScriptableObject,长时间不用的可以把引用释放,但真正吃内存的是碰撞网格和渲染网格。如果你想降低内存占用,可以关掉远处Tilemap的TilemapRenderer,只保留碰撞(如果需要的话)。这个优化在其它的2D卷轴游戏中效果显著,因为远处的地块已经不需要显示,只保留碰撞数据就能支撑物理和检测。
6.5 一个容易被忽略的顺序问题:Grid和Tilemap的缩放
如果你不小心在Grid上设置了Scale或者Rotation,而不是只修改Grid组件里的Cell Size,那整个世界坐标和网格坐标的映射就会错乱。很多初学者想调整地图大小,直接改Grid的Scale,结果瓦片全跑偏,物理碰撞和渲染对不上。
记住一条铁律:Grid本身不要动Transform的Scale和Rotation,参数全在Grid组件的Cell Size、Cell Gap和Cell Layout上设置。如果你确实需要整体缩放地图(比如做像素完美缩放),也应该通过Camera或Canvas来处理,而不是网格系统。这个很容易踩,踩过之后排查起来特别耗时,因为场景里看起来一切正常,但就是哪里不对。
6.6 瓦片数据被误删、引用丢失怎么办
Project窗口里,如果你从图集生成的瓦片资产被误删,场景里所有对应的地块会变成空白。这里有个好习惯:瓦片资产和调色板文件都单独放到一个目录,不要把瓦片资产和美术原图混在一起,防止批量操作时误删。另外,在预制体里保存的地图数据,本质上保存的是Tile的引用GUID。GUID变了,地图会大面积丢失。所以做大型项目时建议对瓦片资产建立命名规范,避免美术工程重命名导致GUID丢失。
如果你已经丢了引用,又没有备份,比较笨但有效的方法是:打开Prefab文件(或者场景文件),搜索丢失的Tile GUID,把新瓦片资产的GUID替换进去。这个方法应该只在实在没有办法时才用,一般项目里还是靠版本管理工具找回更靠谱。
最后的经验
做了好几个2D项目之后,我对TileMap这个系统最大的体会是:它的学习曲线其实很平缓,但真正用好它,靠的不是看文档,而是理解它的数据流和渲染模型。你用调色板画地图的本意是“画”,但底层是“写数据”,理解了这一点,规则瓦片、动态修改、性能优化这些概念就是顺理成章的事情。如果你正打算做一个2D游戏,不要怕花时间把瓦片图集和规则瓦片的基础整理好,前期多花两个小时,后面能省几十个小时的返工时间。