ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Unity Tilemap缩放瓦片不动网格:实例化、自定义Tile、Shader全解析

Unity Tilemap缩放瓦片不动网格:实例化、自定义Tile、Shader全解析 1. 项目概述与核心痛点1.1 这个需求到底在解决什么问题先讲清楚一个前提这里的“网格尺寸不变”并不是说瓦片渲染出来有多大而是指 Tilemap 的单元格坐标系统、碰撞数据和逻辑占位不能变。很多 Unity 开发者第一次接到这类需求时第一反应是去缩放 Tilemap 节点本身或者去改 TilemapRenderer 的 transform结果发现整张地图都被拉伸了角色寻路、点击选取、物理碰撞全乱套。我们做建造类系统时最典型的情况是地面上要放一座建筑建筑从地基长出来视觉上需要从 0.6 倍缩放到 1.2 倍但建筑占用的格子范围必须保持固定这样碰撞体、拾取范围、AI 寻路才不会被视觉动画带偏。另一个例子是战斗地图的技能范围提示格子高亮闪烁时如果直接改 Tilemap 的 transform整个战场都跟着颤抖。所以“动态缩放瓦片但不改动网格”是一个真实、高频且容易被新手踩坑的需求。这篇文章整理了我实际项目中验证过的三种方案实例化视觉对象加局部缩放、自定义 Tile 类改写 TileData.transform、以及 Shader 材质层面的“视觉假缩放”。三种方案各有适用边界下面的内容会带着完整代码和踩坑记录展开适合正在做地图编辑器、策略游戏或建造系统的开发者参考。1.2 直接缩放 Tilemap 节点为什么不行先把这个最常见的坑说透。Tilemap 的 Transform 一旦发生 Scale 变化它内部所有单元格的世界坐标也跟着缩放。举个例子你在 Tilemap 上用 SetTile 在 (1,0) 放了一个瓦片Tilemap 的 scale 是 1 时这个瓦片中心的位置是 (1.5, 0.5)如果把这个 scale 改成 1.2看起来瓦片变大了但它的碰撞体边界、TilemapCollider2D 生成的包围盒、以及用于判定点击的 CellToWorld 坐标全部被拉伸了。如果项目里还用到了 Grid 组件下的其他子 Tilemap 做分层地图比如地面层、障碍层、装饰层单独缩放某一层会导致层与层之间的对齐彻底错位。更麻烦的是 NavMesh、A* 这类寻路系统是基于世界坐标烘焙的地图一动所有路径都要重新计算运行时做这个操作基本等于给自己挖坑。所以结论很明确如果只是“视觉上想变化”就不要去动 Tilemap 本身而是把变化范围限制在单个瓦片的渲染数据上。下面的三个方案都是在这个前提下展开的。1.3 三个方案的核心思路速览在进入详细代码之前先给一个整体概念方便你对着自己的场景选型。方案一扔掉默认合批把需要动态缩放的瓦片独立成一个带 SpriteRenderer 的实例对象改这个对象的 localScale。优点是自由度高可以随便配合动画缺点是性能和合批要自己维护。方案二写一个自定义 TileBase 子类在 GetTileData 回调里为不同格子写入不同的 Transform 矩阵让同一个 Tilemap 里的每个瓦片各自缩放但不改变任何网格坐标。优点是完全保留合批能力性能上限最高缺点是逻辑稍绕刷新时机需要理解。方案三通过 Shader 的顶点变换或 UV 采样范围来实现“看起来缩放”的效果用 MaterialPropertyBlock 控制参数。优点是不产生额外对象、不破坏渲染批次缺点是控制粒度通常到 Tilemap 层适合整层统一变化。下面就从最直观的方案一讲起。2. 方案一实例化视觉对象加局部缩放2.1 核心思路与适用场景这个方案的理解成本最低Tilemap 只负责记录“这个格子被占了”实际显示出来的视觉体是一个普通的 GameObject。做法是先通过 Tilemap 的单元格坐标计算出世界坐标然后在该坐标上实例化一个预制体之后随便改这个预制体的 localScale网格那边完全不受影响。我很推荐把这种方式用在建造预览和技能范围提示上。比如玩家选择一座建筑后地图上那些被标记为“可建造”的格子每个都长出一个小小的半透明预览块玩家拖动时预览块会按进度从 0.3 倍逐渐放大到 1 倍。因为预览块是独立对象你甚至可以叠加旋转、变色、发光等效果完全不影响底下那层纯逻辑 Tilemap。这种方案也适合表现“地裂”“生长”“消散”类的单格动画原来是一个完整瓦片要碎掉的时候把视觉体替换成几个小碎片对象并各自缩放旋转物理碰撞体单独挂在碎片对象上原本的 Tilemap 格子则可以同步清除或保留占位。2.2 完整实现步骤第一步准备一个空场景里面放一个 Grid 节点下面挂一个 Tilemap 并命名为 LogicTilemap这张地图不参与渲染或者只放一个占位用的透明瓦片专门用来做逻辑判定。第二步准备一个视觉预制体 VisualTile根节点上挂 SpriteRenderer并把 SpriteRenderer 的排序层级设为高于逻辑层。第三步写一个管理类遍历我们需要动态显示的区域按照 Tilemap 的坐标生成视觉体。using System.Collections.Generic; using UnityEngine; using UnityEngine.Tilemaps; public class DynamicVisualTiles : MonoBehaviour { public Tilemap logicTilemap; public GameObject visualTilePrefab; public Vector3Int[] cellPositions; public float fromScale 0.3f; public float toScale 1.2f; private readonly ListGameObject _spawned new ListGameObject(); public void SpawnTiles() { ClearTiles(); foreach (var cell in cellPositions) { Vector3 worldPos logicTilemap.GetCellCenterWorld(cell); GameObject go Instantiate(visualTilePrefab, worldPos, Quaternion.identity, transform); go.transform.localScale Vector3.one * fromScale; _spawned.Add(go); } } public void ClearTiles() { for (int i _spawned.Count - 1; i 0; i--) { if (_spawned[i] ! null) Destroy(_spawned[i]); } _spawned.Clear(); } }这段代码里最关键的就是GetCellCenterWorld(cell)它返回指定格子中心的世界坐标。注意调用它的 Tilemap 必须是已经建好的 Grid 子节点坐标体系才对得上。如果你是动态生成地图建议在地图生成完毕后再调用 SpawnTiles否则坐标可能还没初始化。2.3 动画效果与对象池写法直接用 localScale 只是一个静态结果实际项目中一般都会配合动画。我最常用的方法是挂在视觉对象上的一个协程从 fromScale 平滑过渡到 toScalepublic class TileScaleAnimator : MonoBehaviour { public IEnumerator ScaleTo(float target, float duration) { Vector3 start transform.localScale; Vector3 end Vector3.one * target; float timer 0f; while (timer duration) { timer Time.deltaTime; float t Mathf.Clamp01(timer / duration); // 用 EaseOutBack 做出一点弹性效果视觉上更像“生长” t 1f 2.70158f * Mathf.Pow(t - 1f, 3f) 1.70158f * Mathf.Pow(t - 1f, 2f); transform.localScale Vector3.LerpUnclamped(start, end, t); yield return null; } transform.localScale end; } }这里有个已经验证过的细节EaseOutBack 的公式里那个 1.70158 是回弹强度系数如果觉得弹性太大改成 0.7 到 1.0 之间的值会柔和很多。另外如果你希望动画过程中不抖动记得关闭 SpriteRenderer 的像素对齐相关组件比如 Pixel Perfect Camera 在某些分辨率下会和缩放产生冲突表现是瓦片边缘出现闪烁或毛刺。当动态瓦片数量超过几十个时频繁 Instantiate 和 Destroy 会产生不小的 GC 压力。建议改成对象池预先创建固定数量的视觉对象用队列管理状态。判空、循环复用、把不用的对象 SetActive(false)都比直接销毁省很多。对象池如果要做就把 SpawnTiles 里的 Instantiate 换成从池里取ClearTiles 换成归还对象。2.4 性能和合批注意事项方案一最大的隐患就是渲染合批。默认情况下一张 Tilemap 会在运行时把很多瓦片合并到较少的批次里。但实例化出来的 SpriteRenderer 是独立物体如果场景中还有大量其他动态物体渲染批次数会明显上升在手机上容易造成 CPU 侧的 DrawCall 压力。缓解办法有三个层次。第一层次把需要动态缩放的视觉体数量控制在 50 个以内并且不要每个都挂不同材质属性尽量共用一个 Material。第二层次如果必须大量使用给 SpriteRenderer 的预制体挂上 GPU Instancing 支持的材质并在同一个预制体实例批量创建。第三层次如果你的图形管线支持 SRP Batcher确保 Shader 符合 SRP Batcher 要求调用时按批处理规则排序减少 SetPassCall 切换。我这里专门提醒一下当 VisualTile 需要半透明效果时尽量让所有这类物体的 RenderQueue 一致并且按距离或层级分组避免半透明物体间频繁切换渲染顺序。这些优化不会马上在编辑器里看出差别但是到真机上帧耗时曲线会好看得多。3. 方案二自定义 Tile 类用 TileData.transform 做局部缩放3.1 原理为什么 Tilemap 规则瓦片会听你的话方案二走的是 Unity Tilemap 本身就支持的渲染数据通道。每个格子最终绘制成什么样其实是由 TileBase.GetTileData 回调返回的 TileData 决定的。TileData 里包含 sprite、color、transform、colliderType 等信息。Tilemap 渲染器在准备网格时会按格子的位置把 transform 矩阵应用到对应网格上。这里有一个很重要的点TileData.transform 只影响渲染网格里那几个顶点的坐标不会改变格子本身的逻辑占位。也就是说你把 transform 写成一个缩放 1.5 倍的矩阵这个瓦片视觉上会变大但 SetTile 占用的还是原来那个格子TilemapCollider2D 默认用的还是格子尺寸的碰撞框。这正是“网格尺寸不变”最干净的实现。所以方案二的思路是写一个继承自 TileBase 的类让它能做到想缩放哪个格子就缩放哪个格子。要特别注意一点GetTileData 的 position 参数是相对于这个 Tilemap 自身的单元格坐标。如果你的 Tilemap 原点不是 (0,0)而是放在了别的位置那么你在外部用世界网格坐标去记录缩放数据时需要先转换成 Tilemap 本地坐标。3.2 完整的动态缩放 Tile 实现下面是一个可以在运行时随意修改格子缩放值的 Tile 类为了让示例简单我用静态字典记录每个格子当前的缩放值。using System.Collections.Generic; using UnityEngine; using UnityEngine.Tilemaps; public class DynamicScaleTile : TileBase { public Sprite sprite; private static readonly DictionaryVector3Int, float ScaleMap new DictionaryVector3Int, float(); public static void SetTileScale(Vector3Int cellPos, float scale, Tilemap tilemap) { ScaleMap[cellPos] scale; // 刷新是必须的否则渲染数据不会更新 tilemap.RefreshTile(cellPos); } public override void GetTileData(Vector3Int position, ITilemap tilemap, ref TileData tileData) { tileData.sprite sprite; tileData.color Color.white; tileData.colliderType Tile.ColliderType.Grid; if (ScaleMap.TryGetValue(position, out float scale)) { tileData.transform Matrix4x4.TRS(Vector3.zero, Quaternion.identity, new Vector3(scale, scale, 1f)); } else { tileData.transform Matrix4x4.identity; } } public override void GetTileAnimationData( Vector3Int position, ITilemap tilemap, ref TileAnimationData tileAnimationData) { // 这里留空即可不使用动画帧 } }注意上面的ScaleMap键用的是 GetTileData 回传的 position。如果你的项目里地图原点固定为 0那么外部传入的玩家坐标或者逻辑坐标可以直接用。但假如你给 Tilemap 设置了非零原点比如你把整张地图放在了 (1000, 1000)那外部逻辑坐标必须先减掉原点偏移再作为字典的键否则缩放会定位到错误的格子上。使用的时候先给目标 Tilemap 铺一块这种 DynamicScaleTile然后调用静态方法修改缩放var cellPos new Vector3Int(3, 2, 0); DynamicScaleTile.SetTileScale(cellPos, 1.8f, targetTilemap);Unity 收到 RefreshTile 后会重新对这个格子执行 GetTileData然后把新的顶点矩阵传到底层渲染网格。这个过程不会重建碰撞体也不会改变 Grid 的坐标映射所以叫“轻量刷新”。3.3 刷新频率和性能优化方案二唯一要提防的就是“乱刷”。RefreshTile 如果每帧刷几百上千个格子底层要重新计算这些格子的 Mesh就算规模不大高频处理也会把帧耗时顶上去。我给的优化经验是这么几个第一批量刷新用 RefreshAllTiles 要慎重。编辑器里你这么做没问题但运行时整张地图可能有上万格子一次全刷会卡。只刷那几个要变化的格子就够了。第二如果做的是连续缩放动画不要每帧调用 SetTileScale。正确做法是把目标格子按刷新批次分组每 0.1 秒统一刷新一次或者用一个累计帧数来控制周期。视觉上 10 帧刷一次完全够用动画流畅度不会受影响CPU 负载却能降下一大截。第三如果数量级确实很大比如上千个格子同时在变化建议把这些变化合并成一次刷新。代码上可以先修改 ScaleMap 里的多个值再统一调用 tilemap.RefreshTile(pos1)、RefreshTile(pos2)……Unity 内部会合并同一帧的刷新请求避免重复计算。还有一个容易忽略的细节TilemapRenderer 模式下格子需要设置正确的 SortingLayer 和 OrderInLayer否则缩放后的瓦片可能被其他相邻瓦片遮住。尤其在斜视角或某些 Shader 变体里深度排序问题会被缩放效果放大表现就是瓦片像纸片一样交错闪动。解决方式是给 Grid 下的每个 Tilemap 层设置独立排序层级或者调整瓦片的 pivot让它保持在视觉中心。4. 方案三Shader 加材质属性块做“视觉假缩放”4.1 原理与适用边界前两种方案属于“数据层”的缩放第三种方案更另类完全不改 GameObject也不改 Tilemap 数据改的是渲染时顶点位置。思路是写一个 Unlit Shader让 Sprite 的网格顶点在顶点着色器里围绕自身中心缩放缩放系数由 Shader 属性_Scale控制。这个方案最大的价值是性能上限高。因为所有瓦片仍然走 Tilemap 的合批流程没有额外对象不必每帧刷新网格数据只要改一下材质参数就能整层变化。适合做全屏地面波动、地震时整层地面颤抖、区域内瓦片统一放大等大型效果。适用边界也很明确同一个材质属性动的是整个 TilemapRenderer 引用的材质所以没法做到同一张 Tilemap 里某些格子放大、某些格子缩小。想要做出局部差异只能把需要不同变化的格子拆到不同的 Tilemap 图层再用各自 TilemapRenderer 的 MaterialPropertyBlock 分开控制。4.2 内置渲染管线下的示例 Shader下面这段 Shader 是我在 Built-in 渲染管线中验证过的简版适用于 Sprite 渲染顶点绕中心缩放。Shader Custom/TilemapScaleUnlit { Properties { _MainTex (Sprite Texture, 2D) white {} _Color (Tint, Color) (1,1,1,1) _Scale (Visual Scale, Range(0.05, 3.0)) 1.0 } SubShader { Tags { QueueTransparent RenderTypeTransparent CanUseSpriteAtlasTrue } Cull Off Lighting Off ZWrite Off Blend One OneMinusSrcAlpha Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #include UnityCG.cginc sampler2D _MainTex; fixed4 _Color; float _Scale; struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; fixed4 color : COLOR; }; struct v2f { float2 uv : TEXCOORD0; fixed4 color : COLOR; float4 vertex : SV_POSITION; }; v2f vert(appdata v) { v2f o; // 顶点坐标以精灵中心为原点直接乘缩放系数 float3 scaledVertex float3(v.vertex.xy * _Scale, v.vertex.z); o.vertex UnityObjectToClipPos(scaledVertex); o.uv v.uv; o.color v.color * _Color; return o; } fixed4 frag(v2f i) : SV_Target { fixed4 col tex2D(_MainTex, i.uv) * i.color; // 透明部分剪掉避免外部挂着的透明区域干扰混合 clip(col.a - 0.001); return col; } ENDCG } } }这里我故意把顶点坐标直接乘以 _Scale因为 Sprite 网格的顶点本身就以中心为原点所以能做到中心缩放。如果你的 Tilemap 使用了 pivot 偏移的 Sprite可能还需要把 pivot 中心点算进去否则缩放的锚点会偏移。大多数 Tile 用的 Sprite 都是默认 pivot所以这个简版能直接工作。用下面这段代码控制一个 TilemapRenderer 的整体缩放using UnityEngine; using UnityEngine.Tilemaps; public class TilemapShaderScale : MonoBehaviour { public TilemapRenderer targetRenderer; public float visualScale 1.0f; private MaterialPropertyBlock _mpb; private void Awake() { _mpb new MaterialPropertyBlock(); } public void ApplyScale(float scale) { visualScale scale; targetRenderer.GetPropertyBlock(_mpb); _mpb.SetFloat(_Scale, scale); targetRenderer.SetPropertyBlock(_mpb); } }MaterialPropertyBlock 的优点是它不会为每个渲染器真正生成一份独立材质实例还是在底层共享 Shader可以避免材质实例数量爆炸。如果你把 Tilemap 拆成若干层每层都用这个脚本就能让不同层分别以不同速度缩放实际效果很像地图的呼吸或地震扰动。4.3 配合不同渲染管线的调整现在很多人用 URP 或 HDRP上面 CGPROGRAM 那套不能直接用。URP 里要把 Pass 改成 HLSL并且在顶点阶段使用TransformObjectToHClip。我贴一段 URP 下最简化的版本要点#include Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl struct Attributes { float4 positionOS : POSITION; float2 uv : TEXCOORD0; float4 color : COLOR; }; struct Varyings { float2 uv : TEXCOORD0; float4 color : COLOR; float4 positionCS : SV_POSITION; }; Varyings vert(Attributes input) { Varyings output; float3 scaledPos float3(input.positionOS.xy * _Scale, input.positionOS.z); output.positionCS TransformObjectToHClip(scaledPos); output.uv input.uv; output.color input.color * _Color; return output; }其他部分比如_MainTex的定义、混合状态、裁剪判断和 BRP 版本思路完全一致。如果你用的是 Sprite-Lit 或带光照的 Shader还要把法线方向、切线信息一起缩放否则光照采样在半透明区域会出现接缝但大多数 2D Tilemap 的地面瓦片用不到光照所以不展开。URP 下尤其要注意 SRP Batcher 的兼容。只要 Shader 用的是 SRP 标准输入输出结构并且没有在片段里访问外部全局资源就很容易被合批进去。如果发现缩放后 DrawCall 反而涨了排查重点先看 Shader 是否在片段阶段用了_WorldSpaceCameraPos之类的全局变量这会影响批处理。5. 三种方案对比与选型建议5.1 直观对比表对比维度方案一实例化视觉对象方案二自定义TileData方案三Shader视觉缩放合批效果差需要额外优化最好仍走Tilemap合批最好需要Shader配合控制粒度单个对象任意单个格子可控粒度细通常是Tilemap层粒度实现难度低中中高内存开销高对象数量线性增长低只有数据字典低动画友好度很好直接调localScale一般需要刷新网格较好改材质参数即可适合数量级几十以内上百到上千上千甚至全屏这张表不是要证明哪个方案更好而是说明选型跟场景强相关。简单说你要自由控制就选方案一你要保持地图稳定、又要缩放多个格子优先考虑方案二你要搞全屏大范围视觉特效方案三是性能最稳的。5.2 典型游戏场景怎么选以建造系统为例玩家把建筑拖到某个格子上时底部有 2×2 或 3×3 的高亮预览这组高亮块数量很少而且需要频繁改透明度、大小、颜色——方案一的实例化对象最合适代码直白美术也好调参。以 Roguelike 地牢的“陷阱激活”为例某个陷阱格子踩中后周围的警示瓦片需要一个个爆开放大。警示数量可能在 10 到 30 个而且要伴随碰撞效果。方案二的自定义 Tile 类非常合适既不需要频繁创建对象又能让每个格子的缩放互相独立。以“全图地面被冰冻”或“大地图震动”这种特效为例整个 Tilemap 的视觉需要统一缩放、波动。这种效果如果逐格做数据刷新性能往往扛不住而方案三用一个全局 Shader 参数就能完成。看起来整张地图都在被冻住、撕裂但网格计算和碰撞数据没动过一下。5.3 两个不太推荐的做法写到这里顺便把两个容易误导新手的做法提一下。有一种做法是直接改 Tilemap 的 transform 的 localScale 来做全图缩放前面已经否了它会连累寻路和碰撞。另一种做法是给每个瓦片挂上 Tilemap 自带的 AnimatedTile 然后去改帧序列这看起来很省事但 AnimatedTile 的设计初衷是播放动画帧不是缩放你会在控制动画速度与格子刷新时手忙脚乱最终代码比自定义 Tile 还复杂。所以真正值得花时间的还是把方案二研究透。它是最贴近 Tilemap 设计原则的做法即便不写 Shader也能在性能和灵活性之间找到不错的平衡点。6. 常见问题与踩坑记录6.1 碰撞体没跟着变是不是 Bug很多第一次用方案二的人会惊讶为什么瓦片放大了TilemapCollider2D 还是原来的格子大小这确实不是 Bug而是这套方案的目的。如果你的需求是“让碰撞体的范围和视觉一起变大”那你要改的是tileData.colliderType和Tile.ColliderType的几何定义或者给格子生成独立的碰撞体。但那样就背离了“网格尺寸不变”的前提属于另一种需求了。判断方法很简单先想清楚这个瓦片的碰撞到底应该跟视觉走还是跟网格走。建造区、装饰层、地面层通常碰撞跟网格走陷阱、可破坏物、会动的机关碰撞才跟视觉走。后者一般就不会再用 Tilemap 的 TilemapCollider2D 了而是换用独立的 BoxCollider2D并在动画里同步缩放 collider 的 size。6.2 缩放后边缘出现残影或闪动这个现象我见过多次原因基本出自排序和像素对齐。方案二改的是渲染 mesh 里的顶点如果瓦片 Sprite 有较大透明边放大会露出透明区域和相邻瓦片产生视觉缝隙看起来就像残影。解决方式有三种第一种把 Sprite 的 Mesh Type 设为 Tight减少透明边缘第二种给 TilemapRenderer 的 material 设置_MainTex的 WrapMode 为 Clamp避免采样到纹理外的颜色第三种如果闪动出现在边缘检查邻格是否有相同的 DynamicScaleTile确保它们刷新到同一个批处理队列。6.3 为什么方案二做了动态缩放后寻路仍然不识别如果你的寻路系统是用 Grid 坐标烘焙的比如基于 Tilemap 生成导航网格那么你只要没动 Grid 本身的 transform寻路就应该还认原来的格子。如果发现不识别通常是你把寻路数据建在了视觉对象上比如用方案一创建了一个 1.5 倍缩放的视觉体却在视觉体上挂了 NavMeshObstacle那障碍范围自然就变成了 1.5 倍。这个坑特别隐蔽排查时优先把场景里所有额外挂的碰撞组件列出来看一遍。6.4 运行时大量瓦片缩放导致掉帧遇到这种情况可以先定位是 CPU 卡还是 GPU 卡。CPU 卡多半是 RefreshTile 频率过高改成固定间隔刷新或者用对象池GPU 卡多半是合批被破坏重点检查 Shader 是否支持合批、材质是否被实例化、Sprite 纹理是否被拆分到多个 atlas。我见过最夸张的一次是因为把方案一的对象数从 40 提到 300DrawCall 从 30 涨到接近 200手机帧率直接降到十几帧。后面拆成对象池加 GPU Instancing才压回稳定 60 帧。按我个人的经验这三套方案不是互相替代的关系而是一个递进先用方案一快速做原型验证视觉节奏对不对等效果确定了、地图规模也清楚了再考虑要不要迁移到方案二只有当你确定要处理全屏级的大范围动态效果时才值得把方案三的 Shader 管线接进来。多数项目里方案二已经能覆盖 80% 以上的动态瓦片缩放需求而且它是从 Tilemap 底层逻辑出发的解法改起来不容易留后遗症。如果你也正在被“瓦片缩放但网格不能变”这个问题卡住建议从最小规模的 Demo 开始试很快就能选到适合自己项目的路线。
返回列表