资讯动态

Unity Tilemap+Shader+分层数据架构打造高性能SLG大地图

发布时间:2026/8/3 21:05:02 来源:尧图企业网站定制
1. 项目概述当SLG遇上大地图一场技术与艺术的硬仗做SLGSimulation Game模拟策略游戏的兄弟们都懂大地图是项目的灵魂也是开发中最硬的骨头之一。它不像一个简单的UI界面或者一个角色模型改改参数就能看到效果。大地图是一个系统工程从底层的数据结构到顶层的视觉表现环环相扣。最近刚啃完一个SLG大地图项目核心目标就一个在保证性能的前提下实现一个既好看又能承载复杂游戏逻辑的超大地表。我们最终敲定的技术栈是Unity Tilemap 自定义Shader 分层数据架构这条路走下来坑不少但成果也相当扎实。今天就来聊聊我们是怎么把Tilemap这个看似“简单”的2D工具玩出花来最终支撑起一个策略游戏世界的。简单说这个方案要解决几个核心痛点第一视觉表现单一。传统Tilemap就是“贴瓷砖”地表纹理重复感强缺乏层次和细节。第二性能瓶颈。当地图尺寸指数级增长成千上万的Tile Sprite会瞬间压垮Draw Call。第三逻辑与渲染耦合。地表类型、资源点、行军路径、势力范围等游戏逻辑数据如果和渲染强绑定后期维护和扩展就是噩梦。我们的思路很清晰用Tilemap高效组织基础网格数据用Shader实现高级的、动态的地表渲染效果再用一套独立的分层数据架构来管理所有游戏逻辑让渲染归渲染数据归数据。2. 核心思路与架构选型为什么是TilemapShader分层数据在项目初期技术选型上我们有过激烈的讨论。常见的方案有纯3D地形如Terrain、体素Voxel以及我们最终选择的2D Tilemap增强方案。这里详细拆解一下我们为什么这么选。2.1 放弃纯3D地形与体素的原因首先看3D地形系统比如Unity自带的Terrain。它的优势在于能做出非常真实、起伏的地形支持LOD细节层次对于写实风格的RPG或开放世界游戏是首选。但对于大多数SLG游戏特别是偏卡通、俯视视角的SLG我们需要的是一个逻辑上规整的网格世界。每个格子Cell需要承载明确的坐标、归属、资源量等数据。3D地形的连续顶点结构在处理这种离散的格子逻辑时需要额外的网格映射增加了复杂度。而且Terrain系统在超大面积下的内存和绘制开销对于需要同时显示大量单位的SLG来说负担较重。体素方案如《我的世界》灵活性极高但同样面临数据结构和渲染性能的挑战。SLG的地图编辑需要策划频繁调整体素数据的编辑和版本管理比二维网格要复杂得多。更重要的是我们的美术资源是大量的2D精灵Sprite强行塞进3D体素渲染管线美术工作流和性能优化都会变得棘手。2.2 Tilemap的定海神针作用Unity的Tilemap系统本质是一个针对2D网格地图高度优化的渲染与管理框架。它有几个对我们至关重要的优点天然的网格逻辑Tilemap的每个Cell对应一个世界坐标这完美契合了SLG的格子化移动、建造和战斗逻辑。策划在Unity编辑器里就能像摆棋盘一样设计地图直观高效。高效的合批渲染Unity的Tilemap Renderer能够将使用相同Tile素材的连续格子自动合并批次Batch极大地减少了Draw Call。这是性能的基石。成熟的编辑生态有Rule Tile、Random Tile等高级Tile类型可以方便地实现自动拼接的路径、随机变化的草地大大提升了地图编辑的效率和表现力。所以Tilemap作为我们地图的数据基底和基础渲染层是不二之选。它负责定义地图的“骨架”——哪里是平原哪里是山脉哪里是河流。2.3 Shader的魔法赋予地表灵魂然而只用Tilemap地图看起来就像廉价的塑料地板重复感严重缺乏生机。这时就需要Shader出场了。我们的目标不是替换Tilemap而是增强它。我们为Tilemap Material编写了自定义的Shader主要实现以下效果纹理混合与过渡让草地、泥土、沙地等不同地形的边缘自然融合而不是生硬的切割。细节纹理叠加在基础地表上通过第二层、第三层纹理Detail Map叠加碎石、落叶、苔藓等细节打破重复感。动态效果比如水面波纹、草地随风摆动、雪地脚印的逐渐消失。这些效果如果靠生成无数个动画Sprite来实现性能是不可想象的而Shader可以在像素级别高效计算。全局光照与阴影实现一天内的时间变化对地表颜色的影响或者根据地形高度存储在数据层中计算简单的阴影增强立体感。Shader在这里扮演了“化妆师”和“特效师”的角色它基于Tilemap提供的基础信息如Tile的索引或自定义顶点颜色在GPU端进行高效渲染让静态的地图“活”起来。2.4 分层数据架构逻辑与渲染的解耦这是整个架构中最关键的设计。我们不能让游戏逻辑比如这个格子属于哪个玩家、有多少铁矿、是否被战争迷雾覆盖直接去修改Tilemap或Material的属性。那样会导致代码混乱难以调试和扩展。我们设计了一个独立于渲染的“地图数据管理层”。这个层在内存中维护一个与Tilemap网格一一对应的多维数据数组。每一层Layer代表一种逻辑数据地形层存储每个格子的基础地形类型枚举如Grass, Mountain, Water。资源层存储每个格子的资源类型和存量如Gold: 100, Wood: 50。归属层存储格子所属的势力或玩家ID。状态层存储格子的临时状态如是否被选中、是否有行军路径经过、是否处于战争迷雾中。渲染系统Tilemap Shader通过监听数据层的变化来更新视觉表现。例如当某个格子的“归属层”数据发生变化被玩家占领数据层会发出一个事件。渲染系统捕获这个事件然后通过修改该格子对应Tile的颜色Tilemap.SetColor或者向Shader传递一个全局纹理索引来改变其显示如染上玩家颜色。这样一来游戏逻辑只管操作纯粹的数据渲染逻辑只管根据数据画画两者通过清晰的事件接口通信彻底解耦。实操心得分层架构前期设计多花了一周但在后期加功能时比如突然要加一个“污染度”系统优势尽显。我们只需要新建一个“污染度数据层”并在Shader中增加对污染度数据的采样和表现逻辑即可原有代码几乎不用动。3. 从Tilemap到Shader地表渲染的实战细节理论说完我们来点硬的。看看怎么一步步把Tilemap和Shader拧在一起工作。3.1 Tilemap的数据准备与组织我们不是简单地把美术切好的地形块拖进Tile Palette就完事了。为了给Shader传递信息我们对Tile进行了编码。首先我们创建了一个TerrainTile类继承自TileBase。在这个类里我们不仅存放了用于显示的Sprite还添加了几个自定义属性TerrainTypeID: 一个整数代表地形类型0草地1泥土2沙地…。这个ID是关键。BlendWeight: 一个0-1的值用于控制该Tile在边缘混合时的权重。Walkable: 布尔值直接供寻路逻辑使用。在Unity编辑器中我们可以为每种地形配置不同的TerrainTile。当策划在Tilemap上绘制时他实际上是在设置每个格子的TerrainTypeID。接下来我们需要把这些ID信息传递给Shader。最直接的方法是利用Tilemap.SetColor。虽然叫SetColor但我们可以“盗用”顶点颜色Vertex Color的RGBA通道来编码数据。例如我们可以用R通道存储TerrainTypeID归一化到0-1之间。在Tilemap绘制或变更时我们遍历网格根据每个格子的TerrainTypeID计算出一个颜色并设置上去。// 示例代码根据TerrainTypeID设置Tile颜色编码信息 public void EncodeTileDataToColor(Tilemap tilemap) { BoundsInt bounds tilemap.cellBounds; foreach (var pos in bounds.allPositionsWithin) { TileBase tile tilemap.GetTile(pos); if (tile is TerrainTile terrainTile) { // 将TerrainTypeID (例如 2) 编码到R通道 float encodedValue terrainTile.TerrainTypeID / 255.0f; // 假设ID范围0-255 Color dataColor new Color(encodedValue, 0, 0, 1); // GBA通道可留作他用 tilemap.SetColor(pos, dataColor); } } }3.2 自定义Shader的编写要点有了编码数据的顶点颜色Shader端就可以读取并解码了。我们写一个Surface Shader相对简单或Unlit Shader性能更高来实现。核心思路如下采样基础纹理Shader中有一个主纹理_MainTex它可能是一张包含多种地形小图块的图集Texture Atlas。解码地形ID从输入结构Input中的顶点颜色vertColor的R通道读取值反算出TerrainTypeID。纹理数组与混合我们使用纹理数组Texture2DArray来存储不同地形的大尺寸无缝纹理。根据解码出的ID我们可以从纹理数组中采样出对应的草地、泥土等纹理。// 在Shader中声明纹理数组 UNITY_DECLARE_TEX2DARRAY(_TerrainTexArray); float _TerrainCount; // 地形种类数 ... // 根据编码的ID计算数组索引和混合权重 float rawID vertColor.r * 255.0; int index1 floor(rawID); int index2 min(index1 1, _TerrainCount - 1); float blendFactor frac(rawID); // 利用小数部分做混合 float4 tex1 UNITY_SAMPLE_TEX2DARRAY(_TerrainTexArray, float3(uv, index1)); float4 tex2 UNITY_SAMPLE_TEX2DARRAY(_TerrainTexArray, float3(uv, index2)); float4 finalColor lerp(tex1, tex2, blendFactor);细节叠加与动态计算在混合后的基础颜色上可以再采样一张细节噪声图乘以一个颜色以uv * _DetailScale进行采样后叠加模拟细节。动态效果如草动可以通过时间变量_Time和正弦函数扰动uv来实现。3.3 性能优化关键合批与纹理管理这套方案性能的核心在于保持Tilemap的合批不被破坏。以下几点至关重要统一的Material整个Tilemap尽量使用同一个Material实例。所有变化都通过MaterialPropertyBlock来传递参数如全局时间_Time或者像我们上面做的通过顶点颜色编码每个格子的差异信息。绝对不要为不同格子动态创建或替换Material。纹理数组的优势使用Texture2DArray将所有地形纹理合并到一个GPU对象中。这样Shader在不同格子间采样不同地形时不需要切换纹理资源极大地有利于GPU的合批与缓存。控制数据更新频率不是每一帧都去遍历整个Tilemap更新顶点颜色。只有当地形发生变化的区域通常由数据层变更事件触发才局部更新Tilemap.SetColor。对于动态效果如全局的风吹草动通过Shader的全局参数每帧更新即可。踩坑记录最初我们尝试为每种地形创建不同的Tilemap图层Tilemap Layer想通过图层顺序控制混合。这确实破坏了合批因为不同Layer被视为不同的Renderer。最终我们回归到单Tilemap数据编码的方案Draw Call从上百个降到了个位数。4. 分层数据架构的设计与实现渲染搞定了我们来看看后台的数据大脑是怎么工作的。4.1 数据层的定义与存储我们定义了一个MapDataManager单例类负责管理所有数据层。核心是一个Dictionarystring, IMapDataLayer键是层名如“Terrain”,“Ownership”值是该层数据的接口。每层数据由一个二维数组或者更高效的扁平化数组T[,]实现。数组尺寸与Tilemap的网格尺寸完全一致。我们为不同类型的数据定义了不同的层ValueMapLayerT: 用于存储枚举或数值如地形ID、资源量。FlagMapLayer: 用于存储布尔状态标志如是否可见、是否可通行。ObjectMapLayerT: 用于存储指向游戏对象如建筑、单位的引用。public interface IMapDataLayer { void SetValue(int x, int y, object value); object GetValue(int x, int y); event ActionDataLayerChangedArgs OnDataChanged; // 关键变更事件 } public class ValueMapLayerT : IMapDataLayer where T : struct { private T[,] _dataGrid; public event ActionDataLayerChangedArgs OnDataChanged; public void SetValue(int x, int y, T value) { if (!EqualityComparerT.Default.Equals(_dataGrid[x, y], value)) { _dataGrid[x, y] value; OnDataChanged?.Invoke(new DataLayerChangedArgs(x, y, value)); } } // ... GetValue 等其他方法 }4.2 逻辑与渲染的通信桥梁当游戏逻辑例如一个采矿技能需要改变某个格子的资源量时它不会直接去找到那个格子的Tile或者GameObject而是调用MapDataManager.Instance.GetLayerValueMapLayerint(“Resource_Ore”).SetValue(gridX, gridY, newValue);SetValue方法在内部修改数组数据并触发OnDataChanged事件。渲染系统或其他关心此变化的系统如寻路、UI提示会预先订阅它们感兴趣的数据层事件。例如一个TilemapRendererSystem会订阅“Ownership”层的变化事件。当事件触发时系统根据新的玩家ID计算出对应的颜色然后调用Tilemap.SetColor来更新对应格子的视觉表现。public class OwnershipVisualSystem { public void OnOwnershipDataChanged(DataLayerChangedArgs args) { int playerId (int)args.NewValue; Color playerColor GetPlayerColor(playerId); // 从配置中获取颜色 _tilemap.SetColor(new Vector3Int(args.X, args.Y, 0), playerColor); // 注意这里可以优化为批量更新而不是每次事件都调用SetColor } }4.3 数据层的扩展与查询这种架构的扩展性极佳。当需要新增一个“格子温度”系统时在MapDataManager中注册一个新的ValueMapLayerfloat名为“Temperature”。温度模拟逻辑系统会更新这个层的数据。Shader可以订阅OnDataChanged或者每帧通过MaterialPropertyBlock传递整个温度图的纹理需要将数据层烘焙到一张RenderTexture来实现根据温度变化地表颜色的效果比如高温显示为红色沙漠低温显示为蓝色冻土。对于游戏逻辑的查询也非常高效。AI需要判断一片区域是否适合驻扎它可以快速从“Terrain”、“Ownership”、“Resource”多层数据中读取信息进行综合分析而无需与任何游戏对象交互。5. 实战中遇到的典型问题与解决方案5.1 性能问题大面积地图更新卡顿问题当地图初始化或者发生大规模地形改变如使用“超级武器”瞬间改变一大片地形时遍历所有格子调用Tilemap.SetTile或SetColor会造成主线程卡顿。解决方案分帧处理将更新操作放入协程Coroutine每帧只更新一定数量的格子例如1000个直到全部完成。虽然总时间变长但避免了帧率骤降。使用JobSystemBurst对于纯粹的数据计算如根据噪声图生成地形ID数组可以将计算逻辑放到C# Job中利用多核并行和Burst编译器优化极大提升速度。计算出的结果数组再在主线程中应用给Tilemap。RenderTexture烘焙对于需要传递给Shader的全局数据如战争迷雾图、势力范围图我们不在每个格子上操作而是将数据层的数据一次性烘焙到一张低分辨率的RenderTexture上。Shader通过采样这张纹理来获取信息避免了逐格子的CPU-GPU通信。5.2 视觉问题Tilemap边缘接缝与Shader抖动问题使用Rule Tile时自动生成的边缘Tile在Shader混合后可能出现接缝。动态效果如草动在低分辨率或相机移动时出现抖动Aliasing。解决方案接缝问题确保所有用于混合的基础纹理Texture2DArray中的纹理都是无缝衔接Seamless/Tileable的。在Photoshop中使用偏移滤镜制作。在Shader中对纹理采样的UV使用世界坐标worldPos.xz而非模型UV可以避免因Tilemap网格划分导致的接缝。抖动问题在Shader中对动态效果的UV计算加上抗锯齿处理。例如使用fwidth函数计算UV导数的近似值对动态偏移进行平滑。同时确保相机投影设置为正交Orthographic这对于2D SLG是标准做法能避免透视导致的远处闪烁。5.3 工作流问题策划与程序协作效率问题策划想要频繁调整地图布局和地形分布如果每次修改都需要程序手动编码或执行复杂工具效率低下。解决方案我们开发了一个简单的地图编辑器扩展。在Unity Editor中我们自定义了一个工具窗口策划可以通过画笔、填充、噪声生成等工具直接绘制“地形层”数据。这个工具窗口背后操作的其实就是MapDataManager的内存数据。绘制完成后点击一个“烘焙到Tilemap”按钮扩展程序会自动根据最新的地形数据调用编码函数更新整个Tilemap的顶点颜色。这样策划就拥有了一个所见即所得的编辑环境而程序维护的则是核心数据和工具链。5.4 内存问题超大地图的数据开销问题一个1000x1000的地图如果每层数据都是一个int类型的二维数组一层就是4MB。10层就是40MB。对于WebGL或移动平台内存压力很大。解决方案数据压缩对于枚举型数据使用byte甚至bit来存储。例如8种地形状态用3个bit就够了我们可以用1个byte存储2-3种不同的枚举数据。稀疏存储很多数据层是稀疏的比如资源只分布在特定格子。可以使用稀疏数据结构如DictionaryVector2Int, T只存储有值的格子。流式加载将大地图分块Chunk。只加载玩家视野内及附近区域的数据层到内存中。当玩家移动时动态加载和卸载数据块。这对于Tilemap渲染和Shader同样需要配套的流式加载支持。这套从Tilemap到Shader再到分层数据架构的方案经过我们项目的实战检验在表现力、性能和可维护性上取得了很好的平衡。它可能不是所有SLG的唯一解但对于需要复杂逻辑和优秀视觉表现的网格化策略游戏无疑是一条值得深入探索的路径。技术的选择永远服务于产品需求理解每一层技术的优势和边界并将它们有效地组合起来才是工程实践中最有意思的部分。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价