前阵子做项目时遇到一个需求:某个机关触发后,地面上一片瓦片要像呼吸一样慢慢膨胀变大,等机关关闭再缩回去。听起来不复杂,但把需求往细了一拆就出问题了——瓦片视觉尺寸变了,角色碰撞、技能范围检测、Tilemap的WorldToCell坐标换算全乱了。如果要保持网格尺寸逻辑不变,只让画出来的瓦片动起来,Unity默认的Tilemap体系是不支持直接改单个瓦片大小的。这篇文章就聊聊我实际试下来可行的三种方案,从最简单到最灵活,代码和坑都放在里面。
需求场景概括成一句话就是:渲染层缩放、逻辑层不动。如果你做2D游戏,迟早会碰上一次类似的需求。比如Boss战的地板碎裂、技能范围内的地形凸起、河流水位涨落导致的岸边瓦片挤压、甚至整张地图在读图时做一个放大进入的过渡动画。这些场景的共同特点是:瓦片的视觉表现需要变化,但网格坐标、碰撞体、寻路数据必须保持稳定,否则所有基于Cell坐标的玩法逻辑会连环崩。
网上很多帖子会直接给你tilemap.transform.localScale = new Vector3(2, 2, 1)这种答案,确实能让瓦片变大,但换来的是一堆新的麻烦。我帮你把三种方案的做法、原理、适应场景和踩过的坑一次讲完,看完你就能直接根据自己项目的情况来选。
1. 需求场景拆解:视觉膨胀与逻辑坐标为什么要分离
先从根上讲清楚这个问题为什么存在。Unity Tilemap的本质是一张二维网格,每个格子(Cell)有一个Vector3Int坐标,格子尺寸由Grid组件的Cell Size控制。瓦片只是网格上的填充物,它的渲染由TilemapRenderer负责,而逻辑层面的坐标转换走的是GridLayout.WorldToCell/CellToWorld这套接口。
正常情况下,WorldToCell是"世界坐标 → 格子坐标"的桥梁。角色走到世界坐标 (3.2, 4.8),你调用这个方法,得到 (3, 4),然后就能通过GetTile拿到这个格子上的瓦片数据。这套逻辑的前提是:Grid 本身的缩放系数固定为1。
问题来了——当你把Tilemap的localScale改成 (2, 2, 1),Grid 的缩放没有变,但渲染的格子尺寸变大了一倍。此时:
WorldToCell的转换结果会根据 Transform 的缩放发生变化,同样一个世界坐标,在缩放前后可能映射到不同的格子。- 瓦片的碰撞体(
TilemapCollider2D)跟着变大,角色会被空气墙卡住。 - 格子坐标和世界坐标的比例关系全部乱套。
这就是"视觉表现和逻辑数据耦合"带来的典型麻烦。真正健康的做法,是把"瓦片的样子"和"瓦片的格子身份"解耦。下面三种方案,本质上是三种不同级别的解耦方式:第一种在坐标换算层面解耦,第二种在单瓦片渲染矩阵层面解耦,第三种直接把渲染数据从 Tilemap 体系里拿出来自己控制。
2. 方案一:整张Tilemap缩放,借助"子物体"技巧保住逻辑坐标
2.1 核心思路:让缩放只作用于渲染层
这个方案是三种里面最简单的,适合"整张地图一起缩放、而且只做一次性过渡动画"的场景。思路很简单:把Tilemap挂在一个空的GameObject下面,缩放时只缩放子物体,坐标换算时用父物体的世界坐标。
听起来有点像绕路,但这一招在实战中非常实用。很多新手直接改Tilemap的localScale,然后把WorldToCell的计算结果各种补偿,代码越写越乱。用父物体隔离之后,逻辑上你当作 Tilemap 压根没缩放就好。
2.2 节点结构设计
Grid(Grid组件,Cell Size保持标准值) ├── TilemapRoot(空物体,永远保持 scale = 1) │ └── Tilemap(挂Tilemap + TilemapRenderer + TilemapCollider2D) └── 其他逻辑节点(玩家、敌人、触发器)平时操作Tilemap,读写瓦片、碰撞等,都走TilemapRoot下的原始节点。需要整体缩放时,只改TilemapRoot的localScale。因为Transform的缩放会级联到子物体,瓦片的渲染尺寸变化了,但 Grid 组件和 Tilemap 组件的"逻辑尺寸"没变。
2.3 坐标换算的正确写法
即使这样做了,你在脚本里如果直接调用tilemap.WorldToCell(worldPos),还是要小心。因为WorldToCell内部会用到Tilemap的transform,缩放后的transform会污染计算结果。正确的做法是:先用TilemapRoot.InverseTransformPoint把世界坐标转换到未缩放空间,再做换算。
using UnityEngine; using UnityEngine.Tilemaps; public class ScaledTilemapCoordinateResolver : MonoBehaviour { public Grid grid; public Transform tilemapRoot; public Tilemap tilemap; // 实际挂着Tilemap的子物体 // 在任何缩放状态下,把世界坐标稳定转换为逻辑格子坐标 public Vector3Int WorldToCellStable(Vector3 worldPos) { Vector3 localPos = tilemapRoot.InverseTransformPoint(worldPos); // 注意这里用grid的CellToLocal,而不是tilemap的WorldToCell return grid.WorldToCell(tilemapRoot.TransformPoint(localPos)); } // 把逻辑格子坐标转回世界坐标(不受缩放影响) public Vector3 CellToWorldStable(Vector3Int cellPos) { Vector3 worldPos = grid.CellToWorld(cellPos); return tilemapRoot.TransformPoint(worldPos); } }这段代码的关键在于:所有需要"稳定坐标"的操作,都通过tilemapRoot这个不缩放的节点做中转。世界坐标先转到 Root 的局部空间,再做格子换算;格子坐标先算好世界位置,再经过 Root 转到真实世界。这样无论tilemapRoot.localScale怎么变,逻辑层看到的世界坐标和格子坐标的映射关系恒定。
2.4 这个方案的坑在哪里
最明显的坑是:所有瓦片是"整体"缩放,没法做局部某一个区域飘起来的效果。你只能让整张图从 1 倍放大到 1.5 倍,或者从 1.5 缩回 1 倍,做不了"地图左边正常、右边膨胀"这种局部控制。
另一个坑是碰撞体的问题。TilemapCollider2D在缩放后,生成的Collider2D形状会跟随 Transform 缩放。如果角色是动态的、怪物寻路会用到瓦片碰撞体,缩放过程中碰撞也会变。如果你的项目里瓦片碰撞主要用于地面阻挡,临时变大一倍问题不大(无非是角色可移动范围变小),但如果是有特殊要求的地形交互逻辑,这套方案就不够用。
这个方案我建议用在全局过渡动画上,比如进入新关卡时地图从中心点放大弹出,或者死亡重生的镜头拉回效果。做这类一次性演出的性价比最高,改动小、出效果快。
3. 方案二:SetTransformMatrix 逐瓦片矩阵变换,局部缩放神器
3.1 官方留的后门:每个瓦片都有自己的变换矩阵
Tilemap这个组件虽然没提供直接改瓦片大小的属性,但它的底层渲染实际上会给每个瓦片生成一个独立的绘制矩阵。Unity 在 API 里留了SetTransformMatrix这个方法,允许你给指定格子设置一个自定义的Matrix4x4。这个矩阵作用在这个格子的瓦片渲染上,直接影响绘制时的缩放、旋转和平移。
也就是说,你完全可以把某个格子的瓦片缩放大 1.5 倍,其他格子保持原样,视觉上就像这个格子膨胀了。而在这个格子的逻辑层,GetTile、GetCollider、WorldToCell的结果不受任何影响——因为SetTransformMatrix只改了渲染矩阵,没有动 Tilemap 的 Transform 和网格数据。
using UnityEngine; using UnityEngine.Tilemaps; public class TileScaleAnimation : MonoBehaviour { public Tilemap tilemap; public AnimationCurve scaleCurve; // 缩放曲线 // 对单个格子设置缩放,pivot为中心点 public void SetTileScale(Vector3Int cellPos, float scaleValue) { Matrix4x4 matrix = BuildScaleMatrix(cellPos, scaleValue); tilemap.SetTransformMatrix(cellPos, matrix); } // 关键:构建以格子中心为锚点的缩放矩阵 private Matrix4x4 BuildScaleMatrix(Vector3Int cellPos, float scale) { Vector3 cellCenter = tilemap.GetCellCenterWorld(cellPos); Vector3 origin = tilemap.transform.InverseTransformPoint(cellCenter); Matrix4x4 translateToOrigin = Matrix4x4.TRS(-origin, Quaternion.identity, Vector3.one); Matrix4x4 scaleMatrix = Matrix4x4.TRS(Vector3.zero, Quaternion.identity, new Vector3(scale, scale, 1f)); Matrix4x4 translateBack = Matrix4x4.TRS(origin, Quaternion.identity, Vector3.one); return translateBack * scaleMatrix * translateToOrigin; } // 恢复默认 public void ResetTileScale(Vector3Int cellPos) { tilemap.SetTransformMatrix(cellPos, Matrix4x4.identity); } }BuildScaleMatrix这个函数值得解释一下。如果直接Matrix4x4.TRS(Vector3.zero, Quaternion.identity, new Vector3(2f, 2f, 1f)),缩放的基准点是格子的左下角(Tilemap 瓦片默认锚点),放大后瓦片会向斜上方延伸,看起来是"生长"而不是"膨胀"。为了让瓦片在视野中保持原地变大,矩阵必须先平移到格子中心,再缩放,再平移回来,也就是上面代码里的三段式组合。
3.2 局部呼吸动画实操
这个方案做局部区域的瓦片动态缩放特别顺手。比如你想让Boss战场的中心区域瓦片做周期性的起伏,只需要在Update里遍历目标区域的所有格子,根据曲线计算权重并对格子设置矩阵。
using System.Collections.Generic; using UnityEngine; using UnityEngine.Tilemaps; public class BreathableArea : MonoBehaviour { public Tilemap tilemap; public Vector3Int centerCell; public int radius = 5; public float maxScale = 1.6f; public float speed = 1.5f; private List<Vector3Int> affectedCells = new List<Vector3Int>(); private float timer; void Start() { // 预先收集圆形范围内的所有格子,避免每帧循环大范围 for (int x = -radius; x <= radius; x++) { for (int y = -radius; y <= radius; y++) { if (x * x + y * y <= radius * radius) { affectedCells.Add(centerCell + new Vector3Int(x, y, 0)); } } } } void Update() { timer += Time.deltaTime * speed; float progress = (Mathf.Sin(timer) + 1f) * 0.5f; // 0~1循环 float currentScale = Mathf.Lerp(1f, maxScale, progress); foreach (Vector3Int cell in affectedCells) { if (tilemap.HasTile(cell)) { SetTileScale(cell, currentScale); } } // 注意:缩放矩阵变了之后,需要手动刷新Tilemap的渲染 tilemap.RefreshAllTiles(); } }3.3 这个方案最大的坑:碰撞跟不上渲染
用SetTransformMatrix改完瓦片矩阵后,最困惑新手的点是:瓦片视觉上变大了一圈,但TilemapCollider2D生成的碰撞体还停在原来的格子里。因为TilemapCollider2D在 Bake 的时候读取的是瓦片的基础尺寸信息,瓦片的SetTransformMatrix不会参与碰撞体生成。
结果就是你视觉上看到一块石头膨胀变大了,角色却直接穿过去,或者被原本不该碰到的透明区域挡住。解决思路有两个:
- 想真正"可碰撞的膨胀",需要自己在目标区域添加临时碰撞体,比如
CircleCollider2D或PolygonCollider2D,跟随膨胀区域移动。适合机关、陷阱这类需要明确交互判定的场景。 - 如果膨胀只是视觉演出,碰撞保持原样也能接受,那就不需要做任何额外处理。
我当时做机关地板的时候,用的就是第二种思路——视觉上地板鼓起,角色踩上去的判定范围仍然按照原始格子,反而简化了玩法逻辑,玩家不需要精准踩点。
3.4 性能与批处理
SetTransformMatrix每次调用都会触发 Tilemap 内部的网格更新,如果在一帧里对几十个格子连续调用,再加上RefreshAllTiles(),性能会明显掉帧。实测下来(基于常见的2D游戏配置),单帧范围控制在30个格子以内问题不大,超过50个就会感觉到卡顿。
优化方向有两个:一是把格子的矩阵更新分摊到多帧,每帧只处理一小批;二是只在"变化发生"的那一帧设置矩阵,不要每帧对所有格子重复设相同值。上面BreathableArea示例为了演示写的比较粗暴,实际项目里我会记录上一帧的 scale 值,只有变化超过阈值才重新设置矩阵。
4. 方案三:读Tilemap重建Mesh,用顶点动画做到真正的"地形起伏"
4.1 为什么还需要第三种方案
SetTransformMatrix再灵活,本质也只是"单个瓦片做刚体变换"——缩放、旋转、平移,瓦片四条边永远保持直线。但如果你的需求是地形像布一样波动、瓦片和瓦片之间出现错落的高低差,或者边缘要产生柔和的隆起作用,矩阵方案就彻底不够用了。
因为TilemapRenderer的渲染管线里,瓦片是作为独立矩形精灵绘制的,你对单个瓦片能做的变换,也限制在这个矩形内部。想让多个瓦片之间产生连续的形变、让瓦片顶点跟着噪声函数起伏,唯一的办法就是绕过TilemapRenderer,直接把瓦片数据读出来,用Mesh重新绘制。
这个方案上手成本高一些,但解耦最彻底,而且能扩展出很多"原版 Tilemap 想都不敢想"的效果。
4.2 实现步骤拆解
流程不复杂:先用GetTilesBlock读出一块区域里所有非空格子,然后为每个瓦片生成一个带四个顶点的 Quad,最后把所有 Quad 合并成一个 Mesh,交给普通的MeshRenderer + MeshFilter渲染。
using UnityEngine; using UnityEngine.Tilemaps; [RequireComponent(typeof(MeshFilter), typeof(MeshRenderer))] public class TilemapToMesh : MonoBehaviour { public Tilemap tilemap; public BoundsInt area; [ContextMenu("生成Mesh")] public void GenerateMesh() { Mesh mesh = new Mesh(); Vector3[] vertices = new Vector3[0]; Vector2[] uvs = new Vector2[0]; int[] triangles = new int[0]; int vertIndex = 0; int triIndex = 0; for (int x = area.xMin; x < area.xMax; x++) { for (int y = area.yMin; y < area.yMax; y++) { Vector3Int cellPos = new Vector3Int(x, y, 0); TileBase tile = tilemap.GetTile(cellPos); if (tile == null) continue; Tile tileAsset = tile as Tile; if (tileAsset == null) continue; Sprite sprite = tileAsset.sprite; if (sprite == null) continue; Vector3 cellCenter = tilemap.GetCellCenterWorld(cellPos); Vector3 cellSize = tilemap.cellSize; // 一个瓦片 = 2个三角形 = 4个顶点 System.Array.Resize(ref vertices, vertices.Length + 4); System.Array.Resize(ref uvs, uvs.Length + 4); System.Array.Resize(ref triangles, triangles.Length + 6); float halfX = cellSize.x * 0.5f; float halfY = cellSize.y * 0.5f; vertices[vertIndex + 0] = cellCenter + new Vector3(-halfX, -halfY, 0); vertices[vertIndex + 1] = cellCenter + new Vector3(halfX, -halfY, 0); vertices[vertIndex + 2] = cellCenter + new Vector3(halfX, halfY, 0); vertices[vertIndex + 3] = cellCenter + new Vector3(-halfX, halfY, 0); Rect spriteRect = sprite.rect; Vector2 uvMin = new Vector2( spriteRect.x / sprite.texture.width, spriteRect.y / sprite.texture.height ); Vector2 uvMax = new Vector2( (spriteRect.x + spriteRect.width) / sprite.texture.width, (spriteRect.y + spriteRect.height) / sprite.texture.height ); uvs[vertIndex + 0] = uvMin; uvs[vertIndex + 1] = new Vector2(uvMax.x, uvMin.y); uvs[vertIndex + 2] = uvMax; uvs[vertIndex + 3] = new Vector2(uvMin.x, uvMax.y); triangles[triIndex + 0] = vertIndex + 0; triangles[triIndex + 1] = vertIndex + 2; triangles[triIndex + 2] = vertIndex + 1; triangles[triIndex + 3] = vertIndex + 0; triangles[triIndex + 4] = vertIndex + 3; triangles[triIndex + 5] = vertIndex + 2; vertIndex += 4; triIndex += 6; } } mesh.vertices = vertices; mesh.uv = uvs; mesh.triangles = triangles; mesh.RecalculateNormals(); mesh.RecalculateBounds(); MeshFilter mf = GetComponent<MeshFilter>(); mf.sharedMesh = mesh; } }这段代码生成了一个静态 Mesh,接下来你想做什么就有很大的自由度了。比如在 Update 里通过 PerlinNoise 计算每个顶点的高度,就能让整片瓦片像水面一样流动起伏。
4.3 顶点级形变实例:圆形隆起
这里给一个比较实用的例子:读取 Mesh 顶点,根据顶点到中心点的距离计算隆起高度,做一个平滑的"鼓包"效果。
using UnityEngine; public class MeshVertexDeformer : MonoBehaviour { private Mesh mesh; private Vector3[] originalVertices; public Vector3 center; public float radius = 3f; public float height = 0.8f; void Start() { mesh = GetComponent<MeshFilter>().sharedMesh; originalVertices = mesh.vertices; } void Update() { Vector3[] vertices = (Vector3[])originalVertices.Clone(); for (int i = 0; i < vertices.Length; i++) { Vector3 worldVert = transform.TransformPoint(vertices[i]); float dist = Vector3.Distance(worldVert, center); if (dist < radius) { float t = 1f - (dist / radius); // smoothstep 让过渡更自然 t = t * t * (3f - 2f * t); vertices[i].z += height * t; } } mesh.vertices = vertices; mesh.RecalculateNormals(); mesh.RecalculateBounds(); } }这个方案的优越性在于,瓦片之间没有"裂缝"概念,因为所有顶点是连续分布的网格。你可以把它当作一个可以自由形变的地形面片。配合不同的噪声函数、冲击波算法、甚至鼠标交互(点击哪里哪里凹陷),能实现的效果比前两种方案高一个维度。
4.4 碰撞和性能问题怎么处理
隐患也很明确:TilemapCollider2D对MeshRenderer无效,碰撞需要自己想办法。比较简单的做法是加一个MeshCollider,每次顶点更新后重新赋值sharedMesh,让碰撞体跟着形变走。但要注意MeshCollider的更新在动态 Mesh 上成本很高,如果形变的顶点数量多(超过几百),建议把碰撞更新的频率降低,比如每3帧同步一次。另外一个轻量方案是:视觉用 Mesh 形变,碰撞仍然保留原来隐藏的TilemapCollider2D,只在关键交互时需要精确判断。大多数演出式的形变其实只需要视觉,碰撞层不参与也没关系。
性能方面,这个方案由于完全用自己的 Mesh 渲染,开销主要取决于顶点数和RecalculateNormals/Bounds的调用频率。一块 20×20 的瓦片区域大概是 1600 个顶点,在常见 2D 项目中这个量级在移动端也能跑得动。几个小优化:提前把顶点数组的Capacity预留好,不要在 Update 里反复Resize;RecalculateNormals只在视觉对光照敏感时才需要,纯色贴图可以跳过。
5. 三种方案放在一起选型,还有几点实战补充
5.1 直接给结论:按需求对号入座
| 方案 | 实现难度 | 局部控制 | 碰撞同步 | 性能开销 | 最适合的场景 |
|---|---|---|---|---|---|
| 方案一:整表缩放 + 父物体隔离 | 低 | 不支持 | 自然同步(会变化) | 极低 | 全局过渡动画、读图演出 |
| 方案二:SetTransformMatrix 逐瓦片 | 中 | 支持 | 不同步,需自行处理 | 中(格子数量敏感) | 机关地板、技能区域、单瓦片演出 |
| 方案三:Mesh重建 + 顶点形变 | 高 | 最灵活 | 需自行处理,可用MeshCollider | 高(顶点数量敏感) | 地形起伏、水面流动、高级演出 |
选型的核心是看你的需求落在哪个维度。只是希望所有瓦片一起变大变小做演出?方案一。希望某个局部区域的瓦片像被技能吹起来一样膨胀、但碰撞还是原样?方案二。希望地形本身能像布一样波动、高度连续变化?方案三没得跑。
5.2 实测时要注意的几个细节
第一,关于SetTransformMatrix和RefreshAllTiles的配合。只设置矩阵不刷新,有时候画面不会立即更新。但RefreshAllTiles是个重操作,每帧调用性能很伤。实测下来的经验是:SetTransformMatrix本身就足以触发脏区更新,RefreshAllTiles主要在"瓦片内容本身改变"时才需要调用。如果你只是改矩阵,大多数 Unity 版本里是不需要额外的刷新调用的。建议自己写个小 Demo 测试一下,以你项目的实际表现为主。
第二,GetCellCenterWorld在 Tilemap 缩放状态下返回的世界坐标也是带缩放影响的。方案二里面构建缩放矩阵时,我用tilemap.transform.InverseTransformPoint把中心点转换到了局部空间,就是为了避免这个坑。如果你的 Tilemap 挂在根节点没有缩放,这个转换可以省掉,但建议保留,万一后面改成缩放模式代码也不会炸。
第三,多图层 Tilemap 要注意遮挡顺序。方案二里瓦片放大后会向周围溢出,如果地图有多个 Tilemap 层(地面层、植被层、道具层),膨胀的瓦片可能覆盖到其他层的绘制,看起来像穿模。解决办法是给膨胀的层单独提升SpriteRenderer.sortingOrder,或者让膨胀瓦片所在的 TilemapRenderer 渲染排序更高。方案三因为是自己的 MeshRenderer,排序也完全可控,不过需要在材质上多调一下渲染队列。
第四,方案三配合相机缩放的效果。我发现把 Mesh 形变的 Tilemap 和方案一的父物体隔离技巧结合使用,可以实现"整块地图形变 + 整体缩放"的叠加效果。因为 Mesh 挂在自己的节点上,节点属于逻辑层,缩放只影响显示尺寸,形变是在局部空间计算的,两者互不干扰。这种叠加用法在做一个复杂的关卡演出时非常有用。
第五,阴影问题。最近在好几个项目里都碰到过加阴影系统后 Tilemap 表现异常的反馈。如果是用SetTransformMatrix缩放瓦片,或者 Mesh 形变后不更新法线,2D光照/阴影系统的判断都会出错。解决方案要么是形变后手动RecalculateNormals,要么把阴影投射关闭,用假阴影贴图替代。做方案三的时候,法线不更新会导致阴影方向完全错乱,这个问题坑了我一天时间,写在这里给你排雷。
5.3 关于瓦片地图的一些题外话
在查资料的过程中发现很多人在找"瓦片地图 9宫格"、piskel制作瓦片地图荒地、地图瓦片加载这类的内容,说明用 Tilemap 做地图已经是很常见的选择了。Piskel这类工具做瓦片素材确实方便,但素材只是第一步,运行时怎么处理瓦片、怎么让瓦片响应玩法逻辑才是决定项目上限的部分。我觉得动态缩放这个需求,本质上就是"让静态的地图拥有动态的表现力",这是 2D 游戏从"能做出来"走向"做得生动"的路上躲不开的一道坎。
我能给到的最实际的建议是:先用最简单的方案实现需求,等确认视觉表现不够时再往上升级。不要一开始就为了"灵活性"上第三套方案,除非你确定形变效果必须是核心玩法的一部分。前两套方案在大部分场景下已经够用了,第三套是解决问题的终极手段,但也是要花钱花性能买来的灵活性,按需使用才是合理的工程决策。