1. 项目概述:当棋盘上的棋子需要“呼吸感”
在开发2D棋盘类游戏,比如自走棋、战棋或者一些策略游戏时,我们经常会用到Unity的Tilemap系统来构建规整的网格地图。Tilemap高效、易用,是处理网格化地图的不二之选。但不知道你有没有遇到过这样的需求:当玩家选中一个单位(棋子),或者某个单位触发特殊状态(如“巨大化”、“强化”)时,你希望这个棋子能有一个醒目的、动态的缩放效果,以增强视觉反馈和游戏表现力。
最初,你可能会想到直接去修改这个棋子Tile的Transform.localScale。这很直观,对吧?但在Tilemap的世界里,事情没那么简单。Tilemap中的每个瓦片(Tile)并不是一个独立的GameObject,它们是由Tilemap Renderer批量渲染的。你无法直接获取并修改其中某一个特定位置瓦片的Transform。直接修改整个Tilemap的缩放,又会影响到棋盘上所有的格子,这显然不是我们想要的。
那么,如何精准地控制棋盘上某一个特定格子里“棋子”的大小呢?这就是本次实战要解决的核心问题。我们将深入Tilemap的渲染底层,借助Matrix4x4(四维矩阵)这个强大的数学工具,实现针对单个或多个瓦片的动态缩放。这种方法不依赖额外的GameObject,性能开销极低,并且能与Tilemap系统无缝集成,是实现高级棋盘游戏视觉效果的关键技巧。
2. 核心原理:Matrix4x4与自定义瓦片渲染
要理解解决方案,我们得先拆解一下Unity Tilemap的渲染流程。Tilemap Renderer在绘制每一个瓦片时,并不是简单地把一张图片贴上去。对于每一个瓦片单元,系统内部会计算一个“变换矩阵”,这个矩阵决定了这个瓦片最终在世界空间中的位置、旋转和缩放。默认情况下,这个矩阵只包含位置信息(即该瓦片在网格中的坐标),缩放和旋转都是单位值(即1和0)。
Matrix4x4是计算机图形学中用于描述线性变换(平移、旋转、缩放、错切等)的标准工具。在Unity中,一个Matrix4x4可以完整定义一个从局部空间到世界空间的变换。我们的突破口就在这里:我们可以为Tilemap中指定的瓦片,替换掉它默认的变换矩阵,注入一个包含了自定义缩放(甚至旋转)信息的新矩阵。
2.1 技术选型:为何是Matrix4x4?
你可能会问,还有其他方法吗?比如:
- 在Tile上叠加一个SpriteRenderer的GameObject:这能解决问题,但破坏了Tilemap的批处理优势,当棋子数量成百上千时,Draw Call会暴增,性能堪忧。
- 使用Tilemap的Animation Tile:可以制作缩放动画,但这是预定义的、静态的,很难根据游戏逻辑(如选中、Buff状态)进行动态、实时的控制。
- 修改Tile的Sprite:换成一张更大的图片,这不够灵活,无法实现平滑的过渡动画。
相比之下,使用Matrix4x4修改渲染矩阵的方案具有显著优势:
- 高性能:它仍在Tilemap的渲染管线内,享受合批优化,仅增加了极少的CPU计算开销。
- 高灵活度:缩放值(
scale)可以随时通过代码动态计算和修改,轻松实现动画、插值。 - 功能强大:矩阵不仅能处理缩放,稍加扩展就能支持以任意点为中心的旋转,为游戏特效(如攻击旋转、眩晕效果)打开大门。
注意:这种方法本质上是“欺骗”渲染器。Tilemap的碰撞体(Tilemap Collider 2D)和网格坐标(
cell位置)不会因为我们的矩阵变换而改变。一个被放大了2倍的棋子,其逻辑位置和碰撞边界仍然在原单元格内。这是需要特别注意的,我们会在后续章节讨论如何同步处理逻辑与视觉。
2.2 Matrix4x4基础:构造一个缩放矩阵
一个标准的缩放矩阵非常简单。假设我们要在X轴和Y轴上进行均匀缩放,缩放系数为s,那么对应的4x4矩阵如下:
[ s, 0, 0, 0 ] [ 0, s, 0, 0 ] [ 0, 0, s, 0 ] [ 0, 0, 0, 1 ]在Unity中,我们可以使用Matrix4x4.Scale(Vector3 scale)这个便捷方法来创建缩放矩阵。例如,Matrix4x4.Scale(new Vector3(2f, 2f, 1f))会创建一个在XY平面放大2倍的矩阵(Z轴保持1,因为我们是2D)。
然而,仅仅有缩放矩阵还不够。这个缩放是以世界原点为中心的。如果我们直接应用,棋子会从网格原点开始放大,位置就错乱了。因此,我们需要的是一个“平移-缩放-平移回”的复合变换,也就是以棋子自身中心点为缩放原点。
- 先将棋子“平移”到世界原点(减去其单元格中心位置)。
- 在世界原点进行缩放。
- 再“平移”回原来的位置。
这个过程用矩阵乘法表示就是:T * S * T_inv。在Unity中,我们可以通过Matrix4x4.TRS方法一次性创建这个复合矩阵。TRS代表Translation(平移)、Rotation(旋转)、Scale(缩放)。
// 假设cellPosition是世界空间中某个单元格的中心点坐标 Vector3 cellCenterWorldPos = tilemap.GetCellCenterWorld(cellPosition); float scaleFactor = 2.0f; Matrix4x4 customMatrix = Matrix4x4.TRS( cellCenterWorldPos, // 平移:位置(缩放后的位置,其实最终位置没变,但TRS内部会处理) Quaternion.identity, // 旋转:无 new Vector3(scaleFactor, scaleFactor, 1f) // 缩放 ); // 这里需要理解:TRS中的平移参数,是指执行完缩放和旋转后,物体最终的原点位置。 // 对于以自身中心缩放,位置参数就是cellCenterWorldPos,缩放中心自然就是该位置。3. 实战架构:自定义Tile与渲染器拓展
理解了原理,我们来搭建实现框架。我们不会直接修改Unity内置的Tilemap渲染流程,而是通过创建一个自定义的Tile类和一个配套的Tilemap渲染器拓展脚本来协作完成。
3.1 创建可缩放瓦片(ScalableTile)
首先,我们需要一种特殊的Tile,它能够携带一个“缩放系数”的信息。Unity允许我们创建继承自TileBase的自定义Tile类。
using UnityEngine; using UnityEngine.Tilemaps; [CreateAssetMenu(fileName = "New Scalable Tile", menuName = "2D/Tiles/Scalable Tile")] public class ScalableTile : TileBase { public Sprite sprite; // 瓦片显示的精灵 public Color color = Color.white; // 颜色 [Range(0.1f, 5f)] public float baseScale = 1.0f; // 基础缩放,用于在编辑器中调整大小 // 注意:这个baseScale是编辑器用的,运行时动态缩放由另一个系统控制。 public override void GetTileData(Vector3Int position, ITilemap tilemap, ref TileData tileData) { // 这是TileBase的核心方法,用于向Tilemap提供渲染数据。 tileData.sprite = sprite; tileData.color = color; tileData.flags = TileFlags.LockColor; // 锁定颜色,避免被Tilemap覆盖 tileData.colliderType = Tile.ColliderType.Sprite; // 设置碰撞体类型 // 这里不直接设置transform,因为我们需要每帧动态计算。 } }这个ScalableTile和普通Tile几乎一样,只是多了一个baseScale用于在Unity编辑器里预设一个大小。真正的动态缩放系数不会存储在这里,因为Tile是ScriptableObject资产,同一资产被多个格子使用时,修改其字段会影响所有实例。动态数据应该存储在运行时脚本中。
3.2 核心控制器:TileScaleController
我们将创建一个名为TileScaleController的MonoBehaviour脚本,把它挂载到你的Tilemap GameObject上。它是整个缩放系统的大脑,负责:
- 管理哪些格子上的棋子需要缩放以及缩放倍数。
- 在每帧渲染前,将计算好的变换矩阵设置给Tilemap。
using System.Collections.Generic; using UnityEngine; using UnityEngine.Tilemaps; public class TileScaleController : MonoBehaviour { private Tilemap _targetTilemap; private Dictionary<Vector3Int, float> _scaleData = new Dictionary<Vector3Int, float>(); private Dictionary<Vector3Int, Matrix4x4> _matrixCache = new Dictionary<Vector3Int, Matrix4x4>(); private bool _isDirty = false; // 标记数据是否变化,用于优化 void Start() { _targetTilemap = GetComponent<Tilemap>(); if (_targetTilemap == null) { Debug.LogError("TileScaleController must be attached to a GameObject with a Tilemap component."); enabled = false; return; } // 关键:订阅Tilemap在发送给渲染器之前的事件 _targetTilemap.GetComponent<TilemapRenderer>().RegisterSpriteBatchCallback(UpdateTileMatrices); } void OnDestroy() { if (_targetTilemap != null) { var renderer = _targetTilemap.GetComponent<TilemapRenderer>(); if (renderer != null) { renderer.UnregisterSpriteBatchCallback(UpdateTileMatrices); } } } // 公共方法:设置或更新某个格子的缩放 public void SetTileScale(Vector3Int cellPosition, float scale) { if (_targetTilemap == null) return; // 检查该位置是否是我们定义的可缩放Tile TileBase tile = _targetTilemap.GetTile(cellPosition); if (!(tile is ScalableTile)) return; scale = Mathf.Max(0.1f, scale); // 防止缩放过小 if (_scaleData.ContainsKey(cellPosition)) { if (Mathf.Approximately(_scaleData[cellPosition], scale)) return; _scaleData[cellPosition] = scale; } else { _scaleData.Add(cellPosition, scale); } _isDirty = true; // 标记数据已更新 _matrixCache.Remove(cellPosition); // 清除该位置的缓存矩阵,下次重新计算 } // 公共方法:获取某个格子的缩放 public float GetTileScale(Vector3Int cellPosition) { return _scaleData.TryGetValue(cellPosition, out float scale) ? scale : 1.0f; } // 公共方法:清除某个格子的缩放(恢复为1) public void ClearTileScale(Vector3Int cellPosition) { if (_scaleData.Remove(cellPosition)) { _isDirty = true; _matrixCache.Remove(cellPosition); } } // 核心回调:在Tilemap渲染前被调用,用于注入自定义矩阵 private void UpdateTileMatrices(SpriteBatch spriteBatch) { if (!_isDirty && _matrixCache.Count > 0) return; // 数据无变化且已有缓存,跳过 // 获取Tilemap内部用于渲染的“区块”数据 var chunkData = spriteBatch.GetChunkData(); // 这里是一个简化说明。实际我们需要遍历所有受影响的格子。 // 更高效的实现是只更新_isDirty为true时涉及的格子。 // 以下为概念性循环: foreach (var kvp in _scaleData) { Vector3Int cell = kvp.Key; float scale = kvp.Value; if (scale == 1.0f) continue; // 缩放为1,无需特殊处理 // 计算世界空间中心点 Vector3 worldPos = _targetTilemap.GetCellCenterWorld(cell); // 创建以自身为中心缩放的变换矩阵 Matrix4x4 customMatrix = Matrix4x4.TRS( worldPos, Quaternion.identity, new Vector3(scale, scale, 1f) ); // 关键步骤:将这个自定义矩阵设置给这个格子对应的渲染数据。 // 我们需要找到spriteBatch中对应这个cell的渲染项。 // 此处涉及对SpriteBatch内部数据的查找和修改,需要用到更底层的方法。 _matrixCache[cell] = customMatrix; // 缓存起来 // 实际设置矩阵的代码需要在SpriteBatch的API中操作(见下一节) } _isDirty = false; } }上面的代码勾勒出了框架,但最关键的一步——UpdateTileMatrices中如何将矩阵设置给渲染器——我们用了注释。因为直接操作SpriteBatch是相对底层的操作,Unity的公开API没有直接提供这个方法。我们需要用到反射或者自定义Shader来实现。这里提供两种主流且更实用的实现路径。
4. 完整代码实现:两种实战方案
由于直接通过C#代码注入矩阵到内置渲染流程比较晦涩,我提供两种经过实践验证、更清晰的方案。方案一利用MaterialPropertyBlock,方案二使用自定义渲染通道。
4.1 方案一:使用MaterialPropertyBlock传递缩放数据(推荐)
这个方案的核心思想是:不直接修改每个瓦片的变换矩阵,而是通过Shader,根据瓦片的世界位置,动态计算缩放。我们在控制器里计算好每个需要缩放的格子的“缩放值”和“位置”,然后通过MaterialPropertyBlock以数组形式传递给Tilemap的材质。Shader根据当前渲染的像素是否在目标格子范围内,来决定是否应用缩放。
步骤1:修改TileScaleController
using System.Collections.Generic; using UnityEngine; using UnityEngine.Tilemaps; public class TileScaleController : MonoBehaviour { public Tilemap targetTilemap; private MaterialPropertyBlock _propertyBlock; private static readonly int ScaleDataArrayID = Shader.PropertyToID("_ScaleData"); private static readonly int ScaleDataLengthID = Shader.PropertyToID("_ScaleDataLength"); private static readonly int CellSizeID = Shader.PropertyToID("_CellSize"); [System.Serializable] public struct TileScaleInfo { public Vector3 worldPosition; // 格子中心世界坐标 public float scale; } public List<TileScaleInfo> activeScaledTiles = new List<TileScaleInfo>(); private Dictionary<Vector3Int, int> _tileIndexMap = new Dictionary<Vector3Int, int>(); void Start() { if (targetTilemap == null) targetTilemap = GetComponent<Tilemap>(); _propertyBlock = new MaterialPropertyBlock(); targetTilemap.GetPropertyBlock(_propertyBlock); // 获取现有的 UpdateShaderData(); } void Update() { // 每帧更新MaterialPropertyBlock,确保数据同步 if (targetTilemap.HasPropertyBlock()) { targetTilemap.GetPropertyBlock(_propertyBlock); } UpdateShaderData(); targetTilemap.SetPropertyBlock(_propertyBlock); } public void SetTileScale(Vector3Int cellPosition, float scale) { Vector3 worldPos = targetTilemap.GetCellCenterWorld(cellPosition); if (_tileIndexMap.TryGetValue(cellPosition, out int index)) { // 更新已有项 var info = activeScaledTiles[index]; info.scale = scale; activeScaledTiles[index] = info; } else { // 新增项 activeScaledTiles.Add(new TileScaleInfo { worldPosition = worldPos, scale = scale }); _tileIndexMap[cellPosition] = activeScaledTiles.Count - 1; } } public void ClearTileScale(Vector3Int cellPosition) { if (_tileIndexMap.TryGetValue(cellPosition, out int index)) { // 简单做法:将缩放设为1,而不是移除,避免数组索引错乱。 // 更健壮的做法是使用List并管理索引,这里为简化用前者。 var info = activeScaledTiles[index]; info.scale = 1.0f; activeScaledTiles[index] = info; _tileIndexMap.Remove(cellPosition); } } private void UpdateShaderData() { if (_propertyBlock == null) return; // 将List数据转换为Shader能接受的数组 Vector4[] scaleDataArray = new Vector4[activeScaledTiles.Count]; for (int i = 0; i < activeScaledTiles.Count; i++) { var info = activeScaledTiles[i]; // Vector4的xyzw存储: (世界位置.x, 世界位置.y, 缩放, 0) scaleDataArray[i] = new Vector4(info.worldPosition.x, info.worldPosition.y, info.scale, 0); } _propertyBlock.SetVectorArray(ScaleDataArrayID, scaleDataArray); _propertyBlock.SetInt(ScaleDataLengthID, scaleDataArray.Length); // 传递格子大小,用于在Shader中判断像素属于哪个格子 Vector3 cellSize = targetTilemap.cellSize; _propertyBlock.SetVector(CellSizeID, new Vector4(cellSize.x, cellSize.y, 0, 0)); } }步骤2:编写自定义Shader(Unlit Shader Graph 或 Surface Shader)
这里以Shader Graph为例,说明原理。你需要创建一个Unlit Shader Graph。
暴露参数:在Graph的Blackboard中,创建三个属性:
_ScaleData(类型:Vector4 Array)_ScaleDataLength(类型:Integer)_CellSize(类型:Vector2)
流程设计:
- 获取像素世界位置:使用
Position节点(空间选择World)。 - 循环判断:使用
For Loop节点,循环次数为_ScaleDataLength。在循环体内:- 通过
Array Element节点,用循环的Index获取_ScaleData数组中的一项(一个Vector4)。 - 从该项中提取
.xy作为目标格子中心世界坐标,提取.z作为缩放系数。 - 计算当前像素世界位置与目标格子中心的绝对距离(
Absolute(Distance))。 - 判断这个距离是否小于半个格子大小(
_CellSize * 0.5)。这里可以稍微加点容差。 - 如果判断为真,则说明当前像素位于需要缩放的格子内。我们可以计算一个基于距离的平滑权重(可选),然后利用这个权重,对
UV坐标进行反向缩放(因为放大图像实际是缩小UV采样范围),或者更直接地,计算一个偏移向量。
- 通过
- 应用UV变换:将计算出的UV偏移或缩放因子,应用到
Texture Sample节点的UV输入上。
- 获取像素世界位置:使用
实操心得:在Shader中精确匹配格子边界可能因为浮点数精度产生接缝。一个常见的技巧是,在判断时使用
step或smoothstep函数,并给予一个很小的容差值(如0.01),或者直接基于像素世界位置对齐到网格坐标再进行判断,这样更精确。
方案一优缺点:
- 优点:逻辑清晰,与Tilemap标准渲染流程兼容性好,不需要深入底层渲染API。
- 缺点:需要在Shader中做每像素的距离判断,如果需要缩放的格子很多,可能会增加Shader计算复杂度。传递数组有长度限制(具体取决于平台和Unity版本)。
4.2 方案二:使用CommandBuffer进行后期缩放(更灵活)
方案二思路完全不同:我们不在Tilemap渲染时修改每个瓦片,而是在整个Tilemap渲染到一张中间纹理后,再通过一个后期处理,对纹理上特定的区域(对应棋盘格子)进行缩放。这更像一个全屏后处理效果,但只针对特定坐标。
步骤1:创建缩放后期处理脚本
using UnityEngine; using UnityEngine.Rendering; public class TileScalePostProcessing : MonoBehaviour { public TileScaleController scaleController; // 关联之前的控制器 public Material scaleMaterial; // 一个特殊的材质,用于屏幕局部缩放 private CommandBuffer _commandBuffer; private Camera _mainCamera; void OnEnable() { _mainCamera = Camera.main; if (_mainCamera == null) return; _commandBuffer = new CommandBuffer { name = "Tile Scale Overlay" }; // 假设scaleMaterial有一个Pass,它能读取`_ScaledTiles`等纹理并做处理 // 这里需要scaleController把缩放格子信息渲染到一张RenderTexture上 _commandBuffer.Blit(null, BuiltinRenderTextureType.CameraTarget, scaleMaterial); _mainCamera.AddCommandBuffer(CameraEvent.AfterForwardOpaque, _commandBuffer); } void OnDisable() { if (_mainCamera != null && _commandBuffer != null) { _mainCamera.RemoveCommandBuffer(CameraEvent.AfterForwardOpaque, _commandBuffer); } _commandBuffer?.Release(); } void Update() { // 每帧更新scaleMaterial所需的参数,例如传递scaleController.activeScaledTiles数组 if (scaleMaterial != null && scaleController != null) { // 将缩放信息(位置、缩放值)打包到一个Texture2D或ComputeBuffer传递给Shader // 这部分是数据传输的具体实现,略复杂 } } }步骤2:编写后期处理Shader
这个Shader是一个全屏Shader。它接收:
- 一张由Tilemap渲染的
_MainTex(即Camera的源纹理)。 - 一个包含了所有缩放格子信息(位置、半径、缩放强度)的缓冲区。
在Fragment Shader中,对于每一个屏幕像素:
- 将其UV转换为世界坐标。
- 遍历所有缩放格子信息。
- 如果该像素在世界坐标上位于某个缩放格子的影响范围内,则计算一个新的UV坐标。这个新UV是相对于该格子中心进行缩放的(
(originalUV - centerUV) / scale + centerUV)。 - 用新的UV去采样
_MainTex,得到缩放后的颜色。
方案二优缺点:
- 优点:极其灵活,可以实现各种复杂的屏幕空间变形效果,不局限于Tilemap,任何SpriteRenderer都能生效。性能开销相对恒定(与屏幕分辨率相关,与缩放格子数量关系不大)。
- 缺点:实现更复杂,涉及CommandBuffer、RenderTexture管理,需要处理渲染顺序和透明混合问题。如果多个物体叠加,后期处理可能难以区分层级。
4.3 方案选择与代码整合建议
对于大多数2D棋盘游戏,方案一(MaterialPropertyBlock + Shader)是更推荐的选择。它更贴近Tilemap的渲染本质,性能可预测,且不会引入后期处理可能带来的渲染顺序问题。方案二更适合需要全局、非线性变形特效的场景。
这里给出方案一的简化版整合示例,假设我们使用一个预设好的Shader,它已经实现了根据_ScaleDataArray进行格子内缩放的功能。
// 附加到Tilemap上的完整控制器简化版 using System.Collections.Generic; using UnityEngine; using UnityEngine.Tilemaps; [RequireComponent(typeof(Tilemap))] public class SimpleTileScaleController : MonoBehaviour { public Material scaledTileMaterial; // 使用了我们自定义Shader的材质球 private Tilemap _tilemap; private MaterialPropertyBlock _mpb; private List<Vector4> _scaleDataList = new List<Vector4>(); private int _scaleDataID; private int _dataLengthID; void Awake() { _tilemap = GetComponent<Tilemap>(); _mpb = new MaterialPropertyBlock(); _scaleDataID = Shader.PropertyToID("_ScaleData"); _dataLengthID = Shader.PropertyToID("_DataLength"); // 确保Tilemap使用我们的特殊材质 var renderer = _tilemap.GetComponent<TilemapRenderer>(); if (scaledTileMaterial != null) { renderer.material = scaledTileMaterial; } } void LateUpdate() { UpdatePropertyBlock(); } public void AddOrUpdateScale(Vector3Int cell, float scale) { Vector3 worldPos = _tilemap.GetCellCenterWorld(cell); // 查找是否已存在 for(int i = 0; i < _scaleDataList.Count; i++) { Vector4 data = _scaleDataList[i]; if (Vector2.Distance(new Vector2(data.x, data.y), new Vector2(worldPos.x, worldPos.y)) < 0.1f) { data.z = scale; _scaleDataList[i] = data; return; } } // 新增 _scaleDataList.Add(new Vector4(worldPos.x, worldPos.y, scale, 0)); } public void RemoveScale(Vector3Int cell) { Vector3 worldPos = _tilemap.GetCellCenterWorld(cell); for (int i = _scaleDataList.Count - 1; i >= 0; i--) { Vector4 data = _scaleDataList[i]; if (Vector2.Distance(new Vector2(data.x, data.y), new Vector2(worldPos.x, worldPos.y)) < 0.1f) { _scaleDataList.RemoveAt(i); break; } } } private void UpdatePropertyBlock() { if (_tilemap == null) return; _tilemap.GetPropertyBlock(_mpb); _mpb.SetInt(_dataLengthID, _scaleDataList.Count); if (_scaleDataList.Count > 0) { _mpb.SetVectorArray(_scaleDataID, _scaleDataList.ToArray()); } _tilemap.SetPropertyBlock(_mpb); } }这个简化版在LateUpdate中持续更新属性块。在实际项目中,你应该在缩放值发生变化时才调用UpdatePropertyBlock(),以避免不必要的每帧更新。
5. 应用场景与效果扩展
实现了基础缩放后,你可以轻松地将它融入到游戏逻辑中,创造出丰富的交互效果。
5.1 动态交互效果
棋子选中高亮:当玩家点击或鼠标悬停在一个单位上时,调用
SetTileScale(cell, 1.2f),使其微微放大,配合颜色变化,提示选中状态。取消选中时调用ClearTileScale。// 在玩家的输入控制脚本中 void HandleClick(Vector3 worldPos) { Vector3Int cell = _gameGridTilemap.WorldToCell(worldPos); if (IsValidUnitCell(cell)) { // 先清除上一个选中的 if (_lastSelectedCell != null) _scaleController.ClearTileScale(_lastSelectedCell.Value); // 放大当前选中的 _scaleController.SetTileScale(cell, 1.2f); _lastSelectedCell = cell; } }技能范围指示器:当玩家选择一个技能时,计算出技能影响的范围格子列表,然后遍历这些格子,将其中的棋子瓦片放大并改变颜色(通过修改Tilemap的
SetColor或Shader中的颜色参数),清晰展示作用目标。public void HighlightArea(List<Vector3Int> areaCells, float scale, Color tintColor) { foreach (var cell in areaCells) { _scaleController.SetTileScale(cell, scale); _tilemap.SetColor(cell, tintColor); // 同时修改颜色 } }状态变化反馈:当单位获得“巨大化”Buff时,持续将其缩放设为
1.5f;当处于“缩小”Debuff时,设为0.8f。Buff结束时恢复为1.0f。
5.2 结合动画系统
单纯的跳变缩放会很生硬。我们可以结合Mathf.Lerp或DOTween等动画插件,实现平滑的缩放动画。
using DG.Tweening; // 使用DOTween插件 public void AnimateTileScale(Vector3Int cell, float targetScale, float duration) { float currentScale = _scaleController.GetTileScale(cell); DOTween.To(() => currentScale, x => _scaleController.SetTileScale(cell, x), targetScale, duration) .SetEase(Ease.OutBack); // 添加一个回弹效果 }这样,棋子的放大缩小就会带有平滑的动画,视觉体验大幅提升。
5.3 视觉与逻辑的同步问题
正如之前提到的,这只是视觉缩放。棋子的逻辑位置(cell)和碰撞体(如果Tilemap Collider 2D使用了此瓦片)并没有改变。这通常是我们期望的——棋子逻辑上还在那个格子里,只是看起来大了。
但有些情况下你需要同步:
- 自定义碰撞检测:如果你需要根据放大后的视觉大小进行更精确的碰撞(如鼠标点选),你不能再依赖格子坐标。你需要根据缩放后的瓦片世界边界(
单元格中心 ± (单元格大小 * 缩放系数 / 2))来进行自定义的物理检测(如Physics2D.OverlapPoint)。 - UI附着:如果棋子头顶有血条、名字等UI,你需要让UI跟随棋子视觉中心移动。这时需要计算缩放后瓦片的视觉中心世界坐标,它其实和逻辑中心(
GetCellCenterWorld)是一样的,因为我们是均匀缩放。但如果你未来实现了非均匀缩放或旋转,就需要用矩阵变换去计算顶点的世界位置了。
6. 性能优化与常见问题排查
6.1 性能优化要点
- 减少每帧更新:只在缩放数据真正发生变化时更新
MaterialPropertyBlock或CommandBuffer。使用_isDirty这样的标志位来控制。 - 控制数据量:方案一中传递给Shader的数组不宜过大。如果一屏内需要动态缩放的棋子很多(比如超过50个),需要考虑分帧更新或使用更高效的数据结构(如ComputeBuffer)。
- Shader优化:
- 在Shader的循环中尽早使用
break,一旦找到匹配的格子就跳出循环。 - 考虑将格子信息按照空间分区(如网格)传递给Shader,减少每个像素需要遍历的数量。
- 对于移动设备,复杂的逐像素判断可能会成为瓶颈,需进行性能测试。
- 在Shader的循环中尽早使用
- 批处理破坏:使用
MaterialPropertyBlock本身不会破坏Tilemap的静态合批,但如果你为Tilemap设置了不同的材质实例(material instance),则可能会造成批处理中断。确保所有使用此缩放系统的Tilemap尽量共享材质实例。
6.2 常见问题与解决方案
问题1:缩放后瓦片边缘出现接缝或闪烁。
- 原因:Shader中判断像素是否属于某个格子的逻辑不精确,存在浮点数误差,导致边缘像素在“属于”和“不属于”之间摇摆。
- 解决:在距离判断时增加一个小的容差(
epsilon),例如0.005。或者,更稳健的方法是,将像素的世界坐标反向转换为单元格坐标(WorldToCell),然后判断该单元格坐标是否在需要缩放的列表中,这样判断是离散且精确的。但这需要在Shader中访问Tilemap的变换信息,实现稍复杂。
问题2:缩放时,瓦片看起来被“拉伸”了,而不是以中心点放大。
- 原因:缩放矩阵的原点不对。确保你使用的是
Matrix4x4.TRS,并且平移参数(位置)是该格子中心的世界坐标。如果你错误地使用了格子左下角或其他点作为原点,就会出现拉伸。 - 解决:再次确认
worldPos的计算是tilemap.GetCellCenterWorld(cellPosition)。
问题3:在移动设备上帧率明显下降。
- 原因:每帧更新的格子太多,或Shader计算过于复杂。
- 解决:
- 进行性能剖析(Profiler),查看是CPU端(更新数据)还是GPU端(Shader)的瓶颈。
- 限制同时具有动态缩放效果的棋子数量。
- 简化Shader,减少循环次数和复杂运算。可以考虑将缩放效果“烘焙”成简单的Sprite动画,对于固定模式的缩放(如选中态),用Animation Tile替代动态计算。
问题4:缩放效果对某些自定义的Tile(如Rule Tile、Animated Tile)不生效。
- 原因:自定义Tile可能重写了
GetTileData方法,返回了不同的Sprite或数据。我们的缩放系统依赖于特定的Tile类型(如ScalableTile)或通过位置信息判断。 - 解决:在
SetTileScale方法中,不要检查Tile类型,或者扩展检查逻辑,将你需要支持的所有Tile类型都加入白名单。更好的设计是,不依赖Tile类型,而是维护一个独立于Tile数据的“格子状态字典”,只关心位置。
问题5:如何实现非均匀缩放(只拉宽或拉高)?
- 解决:非常简单。在创建缩放矩阵或传递缩放数据时,将
Vector3的x和y分量设置为不同的值即可。例如new Vector3(1.5f, 1.0f, 1f)表示只横向放大1.5倍。在Shader中处理时,也需要分别对UV的x和y分量进行不同的缩放计算。