1. 项目概述:当瓦片需要“呼吸”时,网格为何必须“定海神针”
在Unity里做2D游戏,尤其是平台跳跃、RPG或者策略类项目,Tilemap(瓦片地图)几乎是标配。它让关卡设计变得像拼图一样直观高效。但不知道你有没有遇到过这样的需求:游戏里某个区域的瓦片需要根据玩法动态变化大小——比如,一个会周期性膨胀收缩的毒气区域,或者一个受玩家能量影响而脉动的魔法地板。你的第一反应可能是:简单,直接改Tilemap里Sprite的Scale不就行了?
如果你真这么试过,大概率会立刻遇到一个让人头疼的问题:当你放大一个瓦片(Tile)时,它确实变大了,但它所占据的那个网格(Grid)的尺寸,也跟着一起被撑大了!后果就是,原本严丝合缝的地图瞬间变得支离破碎,相邻的瓦片之间出现了难看的缝隙,或者更糟,整个Tilemap的坐标和对齐全都乱套了。玩家角色原本能稳稳站立的平台边缘,现在可能因为网格的错位而掉下去。这背后的核心矛盾在于:在Unity的默认Tilemap系统中,瓦片的视觉表现(Sprite的缩放)和它的逻辑占位(网格单元Cell的大小)是强耦合的。
所以,我们今天要啃的硬骨头就是“Unity Tilemap动态瓦片缩放:保持网格尺寸不变”。这个需求的核心价值在于“解耦”:让瓦片的视觉缩放独立于其所在的网格逻辑框架。网格必须像棋盘上的格子一样固定不变,这是游戏逻辑(如碰撞检测、坐标计算、寻路)的基石;而瓦片作为棋盘上的棋子,则可以自由地放大、缩小、旋转,实现丰富的动态视觉效果。这不仅仅是解决一个显示BUG,更是为2D游戏动态环境交互打开了一扇新的大门。无论你是想制作动态变化的地形,还是为特定瓦片添加生动的视觉效果,掌握这项技术都能让你的游戏世界更加鲜活。
2. 核心思路拆解:从“蛮干”到“巧劲”的三种路径
面对这个问题,最直接的“蛮干”方法就是去修改Unity的源码或者用非常Hack的方式去打断内部关联,但这显然不现实也不稳定。经过实践,我总结了三种主流且可靠的实现方案,它们分别从不同的层级切入,各有优劣和适用场景。
2.1 方案一:基于Shader的视觉缩放(纯GPU方案)
这是性能最优雅、对游戏逻辑零侵入的方案。其核心思想是:不动瓦片(Tile)的Transform,也不动网格(Grid),而是通过自定义Shader,在渲染阶段对瓦片贴图进行缩放。
实现原理:我们为Tilemap使用的材质球编写一个自定义的Shader。在这个Shader中,我们新增一个属性,比如_TileScale,它是一个float2类型,代表UV坐标的缩放系数。在片元着色器(Fragment Shader)里,我们对输入的UV坐标进行反向缩放计算。简单来说,如果我想让贴图视觉上放大2倍,那么我就在Shader里将采样的UV坐标范围缩小到原来的0.5倍,这样同一张贴图就被“拉伸”到了更大的屏幕区域,但瓦片占据的网格空间丝毫未变。
优点:
- 性能极高:所有计算在GPU上完成,不增加CPU负担,不破坏合批。
- 逻辑无损:Collider、坐标转换等所有游戏逻辑完全不受影响,因为它们感知到的依然是原始大小的瓦片。
- 动态灵活:可以通过MaterialPropertyBlock为不同的Tilemap甚至不同的瓦片单独设置缩放系数,实现局部动态效果。
缺点与注意事项:
- Shader入门门槛:需要一定的Shader编写和调试能力。
- 纹理采样边缘问题:缩放中心默认是UV的(0,0)点,即左下角。如果你需要以瓦片中心进行缩放,需要在Shader中进行UV坐标偏移计算,否则放大时瓦片会向一个方向“飘走”。这是一个关键细节,公式大致为:
scaledUV = (originalUV - 0.5) / _TileScale + 0.5。 - 与Tilemap动画的兼容性:如果瓦片本身是动画瓦片(Animated Tile),需要确保自定义Shader能正确处理动画UV。
提示:对于不熟悉Shader的朋友,可以先用一个简单的测试Shader入手。在Unity中创建一个Unlit Shader,在
frag函数里修改i.uv,你就能立刻看到效果。这是理解此方案最快的方式。
2.2 方案二:动态创建子游戏对象(GameObject per Tile)
这是一种更“Unity传统”的思路,将每个需要缩放的瓦片,替换为一个独立的、带有SpriteRenderer的子GameObject。
实现原理:我们不再使用标准的Tile来绘制这些特殊瓦片。取而代之的是,在生成Tilemap时(或在运行时检测到特定瓦片时),我们在该瓦片对应的网格世界坐标位置,实例化一个预设体(Prefab)。这个预设体包含一个SpriteRenderer,其Sprite就是原来瓦片的图片。然后,你可以随意缩放这个GameObject的Transform,因为它已经完全脱离了Tilemap的网格系统,独立存在于场景中。
优点:
- 直观易控:完全使用Unity常规的Transform系统进行缩放、旋转,控制方式非常直观。
- 功能扩展性强:可以轻松为这个GameObject添加额外的组件,如自定义碰撞体、粒子效果、动画控制器等,实现复杂的交互。
- 无需Shader:对美术和策划更友好,他们可以直接在场景中调整预览。
缺点与注意事项:
- 性能开销大:每个动态瓦片都是一个独立的GameObject,会显著增加Draw Call(如果材质不同),并加重CPU的管理负担。不适合大规模使用(比如成百上千个)。
- 管理复杂度高:你需要自己管理这些动态生成对象的生命周期(创建、销毁、缓存),并确保它们的坐标与Tilemap网格同步。
- 遮挡排序问题:Tilemap有内置的排序层级(Order in Layer),手动创建的SpriteRenderer需要仔细设置Sorting Layer和Order in Layer,才能与静态Tilemap正确混合显示。
实操心得:这个方案最适合用于场景中数量稀少但交互复杂的“特效瓦片”,比如一个会被点爆的炸药桶瓦片,或者一个播放复杂动画的传送阵。你可以为其预制体添加完整的动画状态机和音效,这是纯Shader方案难以比拟的。
2.3 方案三:运行时替换Sprite(Asset层面操作)
这个方案比较取巧,它操作的是瓦片所使用的Sprite资产本身,通过程序化地生成不同缩放尺寸的Sprite副本来实现。
实现原理:Unity的Tile在绘制时,引用的是一个Sprite资产。我们可以在运行时,根据需要的缩放比例,通过代码(如Texture2D.ReadPixels配合Sprite.Create)动态生成一个新的、尺寸不同的Sprite。然后,将这个新Sprite赋值给Tilemap中特定位置的Tile,或者直接创建一个使用该Sprite的新Tile并替换上去。
优点:
- 兼容性最好:替换后的Tile依然是标准Tile,Tilemap的所有功能(如规则瓦片、动画瓦片)和优化(合批)都得以保留。
- 逻辑统一:对于游戏逻辑而言,它处理的仍然是标准的Tilemap和Tile,没有引入新的实体类型。
缺点与注意事项:
- 内存与性能隐患:动态创建Sprite会产生新的纹理资源,如果不进行缓存管理,极易造成内存泄漏和GC(垃圾回收)压力。必须建立一套“缩放比例-Sprite”的缓存字典。
- 实时性较差:生成Sprite(特别是大图)是CPU密集型操作,不适合每帧都进行。更适合在加载关卡时预先处理好几种固定比例的Sprite。
- 修改的是资产引用:这会导致所有使用原Sprite的Tile都发生变化(除非你用了Sprite的多个副本)。通常需要配合Tilemap的特定瓦片替换API,只修改目标位置的瓦片。
注意:这是三种方案中陷阱最多的。如果你决定采用,务必设计一个健壮的缓存池,并在场景切换时妥善清理生成的Sprite资产,否则内存增长会悄无声息地拖垮你的游戏。
3. 方案抉择与实战环境搭建
纸上谈兵终觉浅,绝知此事要躬行。理论说完,我们得动手选一个。对于大多数追求性能和优雅解耦的场景,方案一(Shader方案)是首选。它不仅解决了核心问题,还保持了Tilemap系统的完整性,性能开销最小。因此,接下来的深度实操,我们将以方案一为主线,为你完整还原实现过程。
首先,我们搭建一个标准的测试环境:
- 在Unity中创建一个2D项目。
- 在Hierarchy面板右键 -> 2D Object -> Tilemap,创建一个默认的Tilemap(它会自动带一个Grid父物体)。
- 准备一张简单的瓦片图(Sprite),比如一个32x32像素的方块,导入到项目中并设置为Sprite类型。
- 打开Window -> 2D -> Tile Palette,创建一个调色板,将你的瓦片Sprite拖进去,然后在场景中的Tilemap上绘制一片区域。
现在,如果你选中某个瓦片,在Inspector里修改其Sprite的Scale,立刻就能看到之前提到的“网格被撑大”的灾难现场。我们的目标就是消灭这种现象。
4. 核心实现:编写自定义Tilemap缩放Shader
我们选择编写一个Surface Shader,因为它能更好地与Unity的灯光系统(如果你使用2D光照)配合。但为了最简演示,我们从Unlit Shader开始,更清晰。
4.1 创建基础Unlit缩放Shader
在Project面板右键 -> Create -> Shader -> Unlit Shader,命名为“UnlitTileScalable”。 用代码编辑器打开它,我们进行关键修改:
Shader "Custom/UnlitTileScalable" { Properties { _MainTex ("Texture", 2D) = "white" {} // 新增缩放属性,默认(1,1)表示不缩放 _TileScale ("Tile Scale", Vector) = (1, 1, 0, 0) // 新增缩放中心偏移,用于实现以瓦片中心缩放 _Pivot ("Pivot (XY)", Vector) = (0.5, 0.5, 0, 0) } SubShader { Tags { "RenderType"="Opaque" } LOD 100 Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #include "UnityCG.cginc" struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; }; struct v2f { float2 uv : TEXCOORD0; float4 vertex : SV_POSITION; }; sampler2D _MainTex; float4 _MainTex_ST; // 包含了纹理的Tiling和Offset float2 _TileScale; float2 _Pivot; v2f vert (appdata v) { v2f o; o.vertex = UnityObjectToClipPos(v.vertex); // 关键计算:对UV进行缩放和偏移 // 1. 将UV从[0,1]空间转换到以Pivot为中心的原点空间 // 2. 除以缩放系数 // 3. 转换回[0,1]空间 float2 scaledUV = (v.uv - _Pivot) / _TileScale + _Pivot; // 应用纹理的Tiling和Offset(来自材质球) o.uv = TRANSFORM_TEX(scaledUV, _MainTex); return o; } fixed4 frag (v2f i) : SV_Target { fixed4 col = tex2D(_MainTex, i.uv); return col; } ENDCG } } }代码解析:
_TileScale:这是我们控制缩放的核心。(2,2)表示视觉放大到2倍(因为UV除以2),(0.5,0.5)表示视觉缩小到一半。_Pivot:缩放中心点。默认(0.5,0.5)代表Sprite的中心。这是实现“中心缩放”的关键,避免了瓦片缩放时位置偏移。- 在顶点着色器
vert中,我们对传入的原始UV进行了变换。公式(v.uv - _Pivot) / _TileScale + _Pivot是二维缩放的标准矩阵变换在UV空间的体现。
4.2 创建材质并应用到Tilemap
- 在Project面板,右键上面创建的Shader -> Create -> Material,会自动生成一个使用该Shader的材质球。
- 选中你的Tilemap Renderer组件,将Material属性拖拽替换成我们新建的这个材质球。
- 现在,在Tilemap Renderer的材质属性下方,你应该能看到“Tile Scale”和“Pivot”两个属性。
尝试在编辑器里将“Tile Scale”从(1,1)改为(2,2)。神奇的事情发生了:所有瓦片的图案都变大了,但是!你的网格线(在Scene视图开启Grid显示)纹丝不动,瓦片之间的接缝依然完美。你已经实现了核心效果。
4.3 实现动态与差异化缩放
在编辑器里手动调材质球参数,只能全局缩放。我们想要的是动态的、可编程的、甚至每个瓦片不同的缩放。这就需要用到MaterialPropertyBlock。
MaterialPropertyBlock允许你在运行时修改Renderer的材质属性,而无需创建新的材质实例,这对于性能非常友好。我们写一个简单的脚本DynamicTileScaler.cs,挂载到Tilemap物体上:
using UnityEngine; using UnityEngine.Tilemaps; [RequireComponent(typeof(TilemapRenderer))] public class DynamicTileScaler : MonoBehaviour { private TilemapRenderer tilemapRenderer; private MaterialPropertyBlock propertyBlock; [Header("全局缩放控制")] public Vector2 globalTileScale = Vector2.one; public Vector2 scalePivot = new Vector2(0.5f, 0.5f); [Header("测试用:动态变化")] public bool oscillateScale = false; public float oscillationSpeed = 1.0f; public float oscillationMin = 0.8f; public float oscillationMax = 1.2f; void Start() { tilemapRenderer = GetComponent<TilemapRenderer>(); propertyBlock = new MaterialPropertyBlock(); // 初始获取一次当前的属性块,避免覆盖其他已设置的属性 tilemapRenderer.GetPropertyBlock(propertyBlock); } void Update() { // 获取当前的属性块 tilemapRenderer.GetPropertyBlock(propertyBlock); // 动态计算缩放(示例:呼吸效果) if (oscillateScale) { float scale = Mathf.Lerp(oscillationMin, oscillationMax, (Mathf.Sin(Time.time * oscillationSpeed) + 1f) * 0.5f); globalTileScale = new Vector2(scale, scale); } // 设置Shader属性 propertyBlock.SetVector("_TileScale", globalTileScale); propertyBlock.SetVector("_Pivot", scalePivot); // 应用属性块 tilemapRenderer.SetPropertyBlock(propertyBlock); } // 提供一个公共方法,供其他脚本控制特定Tilemap的缩放 public void SetTileScale(Vector2 scale) { globalTileScale = scale; } }将这个脚本挂到你的Tilemap物体上,运行游戏。勾选Oscillate Scale,你会看到整个Tilemap上的瓦片像在呼吸一样有节奏地放大缩小,而网格系统稳如泰山。
更进一步:差异化缩放如果想让Tilemap上不同位置的瓦片有不同的缩放呢?这需要更精细的控制。思路是:我们不能为每个瓦片单独设置属性块(那样性能太差),但我们可以利用顶点数据。一种高级做法是修改Shader,接受一张Texture2D作为“缩放图”(Scale Map),每个像素的RG通道存储对应世界坐标瓦片的缩放值。然后在顶点或片段着色器中,根据瓦片的世界位置去采样这张缩放图,得到各自的缩放系数。这涉及到将世界坐标传递到Shader以及RenderTexture的使用,实现复杂度较高,通常用于需要大量差异化动态效果的场景(如受冲击波影响的地面)。
5. 避坑指南与性能优化实录
在实际项目中应用这项技术,我踩过不少坑,这里分享给你,希望能帮你省下几个小时甚至几天的调试时间。
5.1 坑一:Shader缩放导致的纹理过滤瑕疵
当你把瓦片放得很大时,可能会发现纹理边缘变得模糊,或者出现锯齿。这是因为放大超出了纹理原始分辨率。解决方案:
- 使用高质量原图:确保你的瓦片Sprite原始尺寸足够大,或者使用
Filter Mode为Point (no filter)来保持像素风格,但这在缩放时会有明显的马赛克感。 - 在Shader中启用Mipmap:对于平滑缩放,确保纹理导入设置中
Generate Mip Maps是开启的,并且在Shader中正确采样。我们的简单Unlit Shader默认会使用纹理设置的过滤模式。 - 考虑使用Substance或程序化纹理:对于需要极度放大的情况,可以考虑使用支持无缝缩放的材质方案。
5.2 坑二:与2D光照和法线贴图的冲突
如果你使用了Unity的2D Renderer with Lights(URP或内置RP的2D光照),我们的自定义Shader可能会破坏光照。因为标准2D光照需要特定的Shader路径和光照模型。解决方案:
- 基于Lit Shader模板修改:不要从Unlit Shader开始,而是复制一个
Sprites/Default或URP下的2D/Sprite-Lit-DefaultShader,在其基础上添加我们的缩放UV逻辑。确保光照计算部分(如法线、漫反射)是在缩放后的UV基础上进行的。 - 仔细处理法线贴图:如果你的瓦片有法线贴图,用于2D光照,那么法线贴图的UV必须和漫反射贴图进行完全相同的缩放变换,否则光照会错位。
5.3 坑三:排序与层级错乱
动态缩放瓦片可能会与Tilemap内其他未缩放的瓦片,或者场景中其他Sprite的渲染排序产生冲突。解决方案:
- 固定材质渲染队列:在我们的Shader中,可以明确指定
"Queue"="Transparent"或"Queue"="Geometry",确保它在一个确定的顺序渲染。 - 利用Tilemap Renderer的排序属性:确保你的Tilemap Renderer上的
Sorting Layer和Order in Layer设置正确,动态缩放不应改变这些基础排序设置。 - 对于方案二(GameObject方案):要手动设置每个生成的SpriteRenderer的
sortingOrder,通常可以设置为TilemapRenderer.orderInLayer + 1之类的值,让它绘制在Tilemap之上。
5.4 性能优化要点
- PropertyBlock是朋友:如前所述,始终使用
MaterialPropertyBlock来修改材质参数,而不是material.SetVector。后者会创建新的材质实例,破坏动态合批,并增加内存开销。 - 缩放变化频率:避免每帧都剧烈变化缩放值。如果缩放是平滑过渡的,考虑在Update中处理;如果是触发式的,只在状态改变时更新一次属性块。
- Shader复杂度:我们的缩放计算放在顶点着色器(
vert)中,这是性能最好的位置。片段着色器(frag)的调用次数远多于顶点,应避免在那里做复杂的每像素UV计算。 - 对于大量差异化缩放:如果必须实现成百上千个瓦片独立缩放,Scale Map纹理方案(一张RGBA32纹理可以存储大量数据)在性能上远优于实例化上千个GameObject。但需要权衡的是,这会增加Shader的采样指令和带宽消耗。
6. 方案横向对比与选型决策表
为了让你在具体项目中能快速做出选择,我将三种核心方案的关键特性总结如下:
| 特性维度 | 方案一:Shader缩放 | 方案二:子GameObject | 方案三:运行时替换Sprite |
|---|---|---|---|
| 核心原理 | 在GPU着色器中变换UV | 用独立GameObject替代Tile | 动态生成并替换Sprite资产 |
| 性能影响 | 极低(GPU计算,保持合批) | 高(增加GameObject和Draw Call) | 中(CPU生成纹理,内存管理负担) |
| 逻辑影响 | 无(网格坐标完全不变) | 有(需额外管理对象与网格对齐) | 无(仍是标准Tile) |
| 动态灵活性 | 高(可每帧变化,可局部控制) | 极高(可附加任意组件与逻辑) | 低(适合有限预定义状态) |
| 实现复杂度 | 中 (需编写/调试Shader) | 低 (使用常规Unity对象) | 中高 (需处理资产创建与缓存) |
| 内存开销 | 低 (仅增加Shader参数) | 高 (每个对象都有开销) | 中 (取决于缓存多少Sprite变体) |
| 最佳适用场景 | 大面积动态地形、环境特效 (如波浪、脉动) | 少量高交互性物体 (如可破坏箱子、机关) | 瓦片需要在几种固定视觉状态间切换 |
我的个人经验是:对于90%需要“动态瓦片缩放”的场景,方案一(Shader方案)都是最优解。它完美地贯彻了“解耦”思想,在效果和性能之间取得了最佳平衡。方案二仅在需要为瓦片附加非常复杂、超出渲染范畴的逻辑时才考虑。方案三则更像一个特定情况下的补丁,除非项目有特殊限制(如必须使用标准Tile碰撞),否则不推荐作为首选。
7. 扩展思考:不只是缩放
掌握了保持网格不变的缩放技术,你的思路可以进一步打开。这套“视觉与逻辑解耦”的思维模型,可以应用到更多Tilemap的动态效果上:
- 动态旋转:在Shader中,除了缩放UV,还可以旋转UV。让瓦片图案旋转,而碰撞体保持原方向。这对于制作旋转的风扇叶片、转动的齿轮地板非常有用。
- 顶点动画:在Shader的顶点着色器中,对瓦片四个顶点的位置进行小幅度的周期偏移,可以实现水面波动、地面柔软起伏的效果,而网格逻辑位置依然是固定的。
- 纹理动画:结合时间变量
_Time,在Shader里让瓦片纹理滚动、闪烁或进行复杂的UV变换,实现熔岩流动、电流传导等效果,所有这些都不会干扰到游戏单位的移动逻辑。
本质上,一旦你通过Shader接管了Tile的最终渲染表现,你就拥有了巨大的创作自由。你可以把Tilemap的网格仅仅当作一个精准的坐标定位系统,而在其之上,构建一个视觉上丰富多彩、动态变化的游戏世界。这,就是技术为设计赋能的一个生动例子。