
前阵子做项目时遇到一个需求某个机关触发后地面上一片瓦片要像呼吸一样慢慢膨胀变大等机关关闭再缩回去。听起来不复杂但把需求往细了一拆就出问题了——瓦片视觉尺寸变了角色碰撞、技能范围检测、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 节点结构设计GridGrid组件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 ListVector3Int affectedCells new ListVector3Int(); 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 GetComponentMeshFilter(); 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 GetComponentMeshFilter().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 里反复ResizeRecalculateNormals只在视觉对光照敏感时才需要纯色贴图可以跳过。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 游戏从能做出来走向做得生动的路上躲不开的一道坎。我能给到的最实际的建议是先用最简单的方案实现需求等确认视觉表现不够时再往上升级。不要一开始就为了灵活性上第三套方案除非你确定形变效果必须是核心玩法的一部分。前两套方案在大部分场景下已经够用了第三套是解决问题的终极手段但也是要花钱花性能买来的灵活性按需使用才是合理的工程决策。