三亩地 三亩地SAN MU DI · CODE DIARY
ARTICLE DETAIL

日记详情

真实记录编程学习的某一天,欢迎挑你感兴趣的翻一翻。

Unity RTS开发实战:从ECS架构到性能优化的完整指南

Unity RTS开发实战:从ECS架构到性能优化的完整指南

1. 项目概述:为什么Unity RTS开发是块“硬骨头”?

聊到用Unity做游戏,很多人第一反应是做个跑酷、RPG或者2D平台跳跃,上手快,资源也多。但一提到要做个像样的RTS(即时战略游戏),比如《星际争霸》、《帝国时代》那种感觉的,不少开发者心里就开始打鼓了。这感觉就像让你用家用轿车去跑拉力赛,不是不能跑,但底盘、悬挂、引擎全得重新调校。Unity RTS开发,本质上就是在通用引擎上,搭建一套能支撑大规模、高并发、强策略交互的专用“赛车架构”。

我这些年经手和研究的RTS项目不少,从独立小体量到商业级Demo都踩过坑。Unity做RTS,核心矛盾在于:引擎本身是为更通用的游戏类型设计的,其默认的GameObject-Component模型、MonoBehaviour的生命周期管理,在面对动辄数百个单位实时寻路、复杂的资源与科技树逻辑、以及需要极快响应的玩家框选和指令队列时,会显得力不从心。性能瓶颈、代码耦合、状态同步,这“三座大山”是每个RTS开发者都必须正面硬刚的。所以,这个标题“从架构设计到实战优化”点出了精髓——架构是解决“能不能做”和“好不好改”的问题,而优化是解决“能不能玩”和“流不流畅”的问题。两者缺一不可,贯穿始终。

这篇文章,我就以一个过来人的身份,拆解一下用Unity开发RTS的核心脉络。我不会只讲某个开源框架怎么用,而是会结合我自己的实战经验,聊聊在架构选型时背后的权衡,在性能优化上那些“血与泪”的教训,以及如何一步步把一个想法,变成屏幕上那些听你指挥、激烈交锋的兵团。无论你是刚对RTS感兴趣的新手,还是正在某个项目里挣扎的中坚力量,希望这些接地气的分享能给你带来些实实在在的启发。

2. 架构设计:为百团大战打下坚实的地基

做RTS,最怕的就是一开始代码写得爽,后期改不动、跑不动。架构设计的目标,就是建立一个清晰、高效、可扩展的代码组织方式,让游戏逻辑能像乐高一样组合,而不是像一团乱麻。

2.1 核心架构模式选型:ECS vs 传统OOP

这是当前Unity RTS领域最热门,也最需要慎重选择的议题。传统的面向对象(OOP)模式,也就是我们最熟悉的MonoBehaviour挂组件方式,直观易懂。一个Unit类继承MonoBehaviour,上面挂着MovementCombatHealth等组件。对于小规模单位或原型阶段,这没问题。

但当屏幕上同时有500个士兵在移动、寻路、索敌时,问题就来了。Unity的GameObjectMonoBehaviour本身有不小的内存开销,更重要的是,它们的更新是分散的、通过消息驱动的。500个单位的Update循环,意味着500次潜在的虚函数调用、500次可能的缓存未命中。这在CPU层面是非常低效的,尤其是当这些单位大部分在做类似事情(比如朝一个点移动)时。

于是,实体组件系统(ECS)进入了视野。它不是Unity的专属,但在Unity的DOTS(面向数据的技术栈)中得到了强力支持。ECS的核心思想是“数据与行为分离”:

  • 实体(Entity):仅仅是一个ID,代表游戏中的一个“东西”,没有数据也没有行为。
  • 组件(Component):纯粹的数据结构。比如PositionComponentMovementSpeedComponentHealthComponent
  • 系统(System):包含逻辑的函数,它遍历所有拥有特定组件组合的实体,并对它们的数据进行批量操作。比如一个MovementSystem,每帧遍历所有拥有PositionComponentMovementSpeedComponent的实体,根据速度更新它们的位置。

为什么ECS对RTS有巨大吸引力?

  1. 性能碾压:数据是连续存储在内存中的(SoA,结构数组),系统进行批量处理时,CPU缓存命中率极高,可以充分利用SIMD指令进行并行计算。同样是移动500个单位,ECS可能只需要几个紧密的循环就完成了,效率提升一个数量级不是梦。
  2. 逻辑清晰:系统职责单一,MovementSystem只负责移动,AttackSystem只负责攻击。数据就是简单的结构体,没有复杂的继承和多态。代码的可测试性和可维护性大大增强。
  3. 天然适合多线程:因为系统处理的是纯数据,且彼此之间通过组件读写来隐式通信,很容易将不同系统的计算任务分发到多个CPU核心上。

但是,ECS的“坑”也很明显:

  • 学习曲线陡峭:你需要彻底转变思维模式,从“对象有什么行为”变成“系统处理什么数据”。
  • 与Unity现有生态割裂:UI系统、动画系统(Mecanim)、物理引擎(PhysX)等,与ECS的集成并不总是那么顺畅,往往需要“桥接”代码,增加了复杂度。
  • 开发工具链不成熟:调试可视化、编辑器集成体验,相比成熟的GameObject模式要弱一些。

我的实战建议是:对于中小型、单位数量在200以内的RTS,或者项目团队对ECS不熟悉时,采用改良的传统OOP架构是完全可行的。关键在于要做好“数据与逻辑的分离”。例如,你可以创建一个纯C#的UnitData类来存放单位的生命、攻击力、速度等核心数据,而MonoBehaviourUnitView只负责根据UnitData来更新显示、播放动画。逻辑计算在GameManager或专门的UnitSystem中以列表形式批量进行。这可以看作是一种“轻量级ECS”思想,能在获得一定性能提升的同时,保持开发的便利性。

对于追求极致性能、目标支持千人以上单位同屏的硬核RTS,拥抱DOTS/ECS是必然选择。可以从核心的性能瓶颈模块(如移动、寻路)开始逐步迁移,而不是全盘推翻。

2.2 事件驱动与数据驱动:降低模块间的“耦合度”

无论用OOP还是ECS,模块间如何通信都是个大问题。最糟糕的做法是A组件直接持有B组件的引用,然后直接调用B.DoSomething()。这会让代码像蜘蛛网一样缠在一起,改一处而动全身。

事件驱动(Event-Driven)是解耦的利器。它的核心是一个中央的“事件管理器”(EventManager)。当游戏中的某个事情发生时(比如“单位被创建”、“资源被采集”、“建筑被摧毁”),负责的模块不直接去通知其他模块,而是向EventManager发布(Publish)一个事件。关心这个事件的其他模块,则提前向EventManager订阅(Subscribe)了该事件。当事件发布时,所有订阅者都会收到通知并执行自己的回调函数。

// 定义事件类 public class UnitCreatedEvent { public UnitData UnitData; public Vector3 SpawnPosition; } // 在某处发布事件(如兵营) EventManager.Instance.Publish(new UnitCreatedEvent { UnitData = soldierData, SpawnPosition = barracks.transform.position }); // 在其他模块订阅事件(如音效管理器、成就系统) void Start() { EventManager.Instance.Subscribe<UnitCreatedEvent>(OnUnitCreated); } void OnUnitCreated(UnitCreatedEvent evt) { PlaySpawnSound(evt.SpawnPosition); CheckAchievement("FirstUnit", evt.UnitData.Type); }

这样做的好处是,发布事件的模块完全不知道谁会对这个事件感兴趣。音效管理器、UI控制器、成就系统、录像系统都可以独立地订阅它们需要的事件,彼此之间没有直接依赖。系统扩展性极强,新增功能时,通常只需要订阅已有事件即可。

数据驱动(Data-Driven)则是另一个维度的解耦,旨在将游戏内容与代码逻辑分离。具体实现就是大量使用ScriptableObject。你可以把单位的属性(血量、攻击力、造价)、科技树的效果、技能的数据、甚至AI的行为权重,都做成ScriptableObject资产。

[CreateAssetMenu(fileName = "NewUnitData", menuName = "RTS/UnitData")] public class UnitData : ScriptableObject { public string unitName; public GameObject prefab; public int maxHealth; public int damage; public float attackRange; public float moveSpeed; public int mineralCost; public int gasCost; // ... 更多属性 }

在Inspector面板里配置好这些数据资产,然后在代码中引用它们。这样做的好处是:

  1. 策划友好:数值平衡、内容调整不再需要程序员修改代码、重新编译。策划或设计师直接在Unity编辑器里拖拖拽拽就能完成。
  2. 内容可扩展:制作新的单位或技能,本质上就是创建新的ScriptableObject资产,代码层面可能不需要任何改动。
  3. 便于测试和Mod支持:你可以轻松地创建多套数据资产(如“平衡性测试版”、“疯狂模式”),或者让玩家通过加载外部资产文件来制作Mod。

在实际项目中,事件驱动和数据驱动通常是结合使用的ScriptableObject定义了“是什么”(数据),而事件系统定义了“发生了什么”(状态变化),两者共同构成了一个灵活、可配置的游戏逻辑骨架。

2.3 状态管理:谁在掌控游戏的世界?

RTS游戏有复杂的全局状态:游戏是正在运行、暂停还是已结束?当前是白天还是黑夜?玩家的资源数量是多少?科技研发到了哪一级?这些状态必须有一个清晰、统一的来源进行管理,避免散落在各处,导致状态不一致。

通常会设计一个顶层的GameManagerGameState单例(Singleton)。它不负责具体的战斗逻辑,而是作为游戏规则的“裁判”和全局数据的“仓库”。

public class GameManager : MonoBehaviour { public static GameManager Instance { get; private set; } public enum GamePhase { Preparation, Battle, Ended } public GamePhase CurrentPhase { get; private set; } // 玩家数据(可以是一个列表,支持多人) public PlayerData localPlayerData; public List<PlayerData> aiPlayersData; // 游戏规则 public int victoryConditionResourceAmount = 10000; public float gameTimeLimit = 1800f; // 30分钟 // 全局事件 public event Action<GamePhase> OnGamePhaseChanged; public event Action<PlayerData> OnPlayerDefeated; private void Awake() { if (Instance != null && Instance != this) Destroy(this); else Instance = this; } public void StartBattlePhase() { CurrentPhase = GamePhase.Battle; OnGamePhaseChanged?.Invoke(CurrentPhase); // 触发其他初始化,如生成初始单位等 } public void CheckVictoryConditions() { // 检查资源、摧毁所有建筑等条件 if (localPlayerData.totalResources >= victoryConditionResourceAmount) { EndGame(localPlayerData); } } // ... 其他方法 }

GameManager通过发布事件(或直接调用方法)来驱动游戏流程。其他系统,如UI系统监听OnGamePhaseChanged来更新界面显示,AI系统在Battle阶段开始激活,经济系统根据当前阶段调整资源产出速率。

注意:单例模式要慎用,避免变成“上帝对象”(God Object),什么逻辑都往里塞。GameManager应该只管理最顶层的、跨系统的状态和规则。具体的单位管理、资源收集等,应该有自己专属的管理器(如UnitManagerResourceManager),它们可以被GameManager引用或通过服务定位器(Service Locator)访问。

3. 核心模块实战拆解:让想法落地

架构搭好了,接下来就是往里面填充血肉。RTS有几个标志性的模块,每一个都值得深入探讨。

3.1 单位选择与编队:玩家的“手”和“脑”

框选是RTS玩家最基础、最频繁的操作。实现一个流畅、准确的框选系统,是良好体验的第一步。

框选实现:通常使用一个半透明的UI面板(Image组件)作为选框。在InputOnDrag事件中:

  1. 记录鼠标按下时的屏幕坐标(起点)。
  2. 在拖拽过程中,实时计算当前鼠标坐标与起点的差值,确定选框的矩形区域。
  3. 将这个屏幕坐标矩形,通过Camera.ScreenPointToRay转换成世界空间中的射线,或者更精确地,使用Camera.ViewportPointToRay并结合视锥体计算,来与场景中的单位进行碰撞检测。

关键优化点:

  • 分层检测:不要对所有物体进行射线检测。为可选择的单位设置特定的Layer(如“Selectable”),在射线检测时指定层掩码,能大幅提升效率。
  • 物理 vs 图形:对于精确到单位模型的选取,可以使用Physics.RaycastPhysics.BoxCast。但对于大范围框选,对每个单位进行物理检测开销很大。更高效的做法是:获取所有“可选单位”的列表,计算每个单位在屏幕上的投影点(Camera.WorldToScreenPoint),然后判断该点是否在选框的屏幕矩形内。这是纯数学计算,比物理检测快得多。
  • 多线程:如果单位数量巨大(>1000),可以将屏幕坐标转换和包含判断放到JobSystem中并行处理。

编队系统:编队(Ctrl+1, Ctrl+2...)的本质是保存一个单位ID的列表。当玩家按下编队快捷键时,将当前选中的单位ID列表存储到对应的编队槽位中。当玩家按下数字键时,从编队槽位中取出ID列表,在UnitManager中查找对应的单位实体,并将它们设置为当前选中状态。

这里的一个细节是单位死亡处理。编队列表中保存的ID,在单位死亡后可能变成无效的。因此,在激活编队时,需要过滤掉已经死亡或不存在的单位。同时,单位死亡时,也应该通知所有编队管理器,将自己从相关的编队列表中移除。

3.2 寻路与移动:让兵团智能地动起来

Unity内置的NavMesh系统对于中小型地图和一般AI寻路来说是很好的选择。但对于RTS中成百上千个单位的集群移动,直接使用会有性能问题,并且缺乏RTS特有的“阵型”概念。

NavMesh高级用法:

  • Agent分层:你可以为不同大小的单位(步兵、坦克、巨人)设置不同的NavMeshAgent半径和高度,并烘焙相应的NavMesh。这样小单位能走的狭窄通道,大单位会自动绕开。
  • 局部避障(Local Avoidance):启用NavMeshAgentobstacleAvoidanceType,可以让单位在移动时彼此避开,防止堆叠。但对于极高密度的单位群,这个计算开销会很大。
  • 分离式寻路:对于大兵团移动,一个常见的优化是“分层寻路”。先为整个兵团计算一个粗略的、基于网格(Grid)或航点(Waypoint)的全局路径。然后每个单位再基于这个全局路径,使用NavMesh进行精细的、带避障的局部移动。这能有效减少大量单位同时计算复杂NavMesh路径的开销。

RTS特色阵型移动:玩家框选一堆单位并移动时,希望它们能保持阵型(如一字排开、方阵、楔形阵)。这不能靠NavMesh自动完成,需要额外逻辑:

  1. 计算阵型锚点:通常以玩家右键点击的目标点,或者选中单位的平均中心点作为阵型锚点。
  2. 分配阵型位置:根据阵型类型(如方阵),计算每个单位在阵型中的相对目标位置(本地坐标)。
  3. 转换到世界空间:将阵型锚点的旋转和位置,应用到每个单位的相对目标位置上,得到其最终的世界坐标目标点。
  4. 分派移动命令:将计算好的目标点,逐个或分批地设置给每个单位的移动系统(或NavMeshAgent.destination)。

这里最大的挑战是避免拥堵和死锁。当单位们朝着密集的阵型点移动时,很容易卡在一起。解决方案包括:为每个目标点增加一个小的随机偏移;引入“软”的碰撞体积,允许单位在一定程度内重叠;或者实现更复杂的基于“流场”(Flow Field)的移动算法,让单位像流体一样自然散开。

3.3 经济与建造系统:游戏的策略引擎

RTS的策略深度,很大程度上由经济和建造系统决定。这个系统需要稳定、可预测,并且能清晰地反映给玩家。

资源系统设计:资源(如金币、木材、人口)通常由一个ResourceManager管理。它维护一个资源字典,并提供安全的增加(AddResource)、消耗(TrySpendResource)和查询(GetResource)接口。

public class ResourceManager : MonoBehaviour { private Dictionary<ResourceType, int> _resources = new Dictionary<ResourceType, int>(); public event Action<ResourceType, int, int> OnResourceChanged; // 类型,旧值,新值 public bool TrySpendResources(Dictionary<ResourceType, int> cost) { // 1. 检查资源是否足够 foreach(var kvp in cost) { if(GetResource(kvp.Key) < kvp.Value) return false; } // 2. 如果足够,则扣除 foreach(var kvp in cost) { _resources[kvp.Key] -= kvp.Value; OnResourceChanged?.Invoke(kvp.Key, _resources[kvp.Key] + kvp.Value, _resources[kvp.Key]); } return true; } }

关键点:所有消耗资源的地方(建造、训练、研发)都必须通过TrySpendResources来操作,确保原子性(要么全部成功扣除,要么全部失败),避免出现资源被部分扣除的中间状态。

建造队列与生产队列:建筑和单位的训练不是瞬间完成的,需要一个队列来管理。每个生产建筑(兵营、工厂)或主基地都应该维护自己的队列。

public class ProductionQueue : MonoBehaviour { public Queue<ProductionItem> queue = new Queue<ProductionItem>(); public ProductionItem currentItem; public float progress; // 当前项目进度 0-1 void Update() { if(currentItem == null && queue.Count > 0) { StartNextItem(); } if(currentItem != null) { progress += Time.deltaTime / currentItem.productionTime; if(progress >= 1f) { CompleteCurrentItem(); } } } void StartNextItem() { if(!resourceManager.TrySpendResources(queue.Peek().cost)) return; currentItem = queue.Dequeue(); progress = 0f; // 发布事件:开始生产XXX } }

UI界面需要实时监听这些队列的变化,更新进度条和列表显示。这里又是事件驱动架构发挥价值的地方:ProductionQueue在开始生产、完成生产、队列变化时发布事件,UI控制器订阅这些事件并更新界面。

3.4 AI设计与实现:打造有挑战的对手

RTS的AI是另一个深水区。简单的“脚本AI”很容易被玩家摸透,而完全基于机器学习的AI又过于复杂。对于大多数项目,行为树(Behavior Tree)是一个在表现力和可控性之间取得良好平衡的选择。

行为树由各种节点组成:

  • 组合节点(Composite):控制子节点的执行顺序,如Sequence(顺序执行所有子节点,直到一个失败)、Selector(顺序执行子节点,直到一个成功)。
  • 装饰节点(Decorator):修改单个子节点的行为,如Inverter(取反结果)、Repeater(重复执行)、Cooldown(冷却时间)。
  • 条件节点(Condition):检查某个条件是否成立,如HasEnemyInSightIsLowOnHealth
  • 行动节点(Action):执行具体的行为,如MoveToTargetAttackGatherResources

一个简单的“攻击敌人”的行为可能是一棵这样的树:

Selector (尝试不同的策略) ├── Sequence (优先攻击可见敌人) │ ├── Condition: HasEnemyInSight │ └── Action: Attack └── Sequence (没有敌人就巡逻) ├── Action: MoveToRandomPatrolPoint └── Decorator: Repeater (无限循环)

在Unity中实现行为树:你可以自己编写节点类的框架,也可以使用开源的库(如NodeCanvasBehavior Bricks)。核心是每帧从根节点开始“Tick”(执行)整棵树。节点返回SuccessFailureRunning状态。Running表示该行为需要持续多帧(如移动)。

AI性能优化:

  • 分帧更新:不要所有AI单位都在同一帧更新行为树。可以将AI单位分成若干组,每帧只更新其中一组。这能平滑CPU开销,避免帧率尖刺。
  • 层次化AI:将AI分为战略层和战术层。战略层(玩家级别)负责宏观决策(在哪里扩张、研发什么科技),更新频率很低(比如每10秒一次)。战术层(单位或小队级别)负责微观操作(移动、攻击),更新频率高。战略层通过设置“目标”或“指令”来驱动战术层。
  • 感知系统:AI如何“知道”周围环境?不要每帧让每个AI单位用物理检测去扫描周围。可以建立一个集中的PerceptionSystem(感知系统),它每帧或每几帧更新一次全局的“感知数据”(如所有单位的位置、阵营)。AI单位查询这个系统来获取信息,这比各自为战高效得多。

4. 性能优化实战:从“能跑”到“流畅”

架构和功能实现了,但如果游戏跑起来像幻灯片,一切等于零。RTS的性能优化是一场持久战。

4.1 渲染优化:征服“显卡危机”

RTS场景通常包含大量单位、建筑和复杂地形。

  • 批处理(Batching):这是最重要的优化。确保使用相同材质球(Material)的单位模型能够进行动态批处理(小模型)或静态批处理(不会移动的环境物体)。对于大量相同的单位(如一群士兵),GPU Instancing是神器。它允许你用一次Draw Call绘制无数个相同网格的实例,性能提升巨大。在Unity中,只需在材质的Inspector中勾选Enable GPU Instancing,并在渲染单位的Shader中支持实例化属性即可。
  • LOD(Level of Detail):为你的单位、建筑模型制作多个细节层次的版本(高模、中模、低模)。根据物体与摄像机的距离,动态切换不同的模型。对于远处密密麻麻的单位,一个简单的低面数方块可能就足够了。
  • 遮挡剔除(Occlusion Culling):Unity的遮挡剔除可以防止摄像机看不到的物体被渲染。对于室内建筑或复杂地形非常有效。需要手动设置遮挡区域(Occlusion Area)并烘焙。
  • 纹理与着色器:使用纹理图集(Texture Atlas)来减少材质球数量。简化Shader,避免在移动端或低配PC上使用过于复杂的实时光照和阴影。对于RTS常见的“战争迷雾”(Fog of War)效果,可以考虑使用基于高度图或网格的简单Shader,而不是全屏后处理。

4.2 CPU逻辑优化:解放主线程

游戏卡顿很多时候不是显卡的锅,而是CPU算不过来了。

  • 对象池(Object Pooling):单位的创建(Instantiate)和销毁(Destroy)是昂贵的操作。对于频繁生成和消失的对象(如子弹、特效、死亡的单位),一定要使用对象池。在游戏初始化时预先创建一批对象并禁用,需要时从池中取出激活,用完后放回池中并禁用,而不是销毁。
  • 分帧处理:将密集的计算任务分摊到多帧完成。例如,有1000个单位需要寻路更新,不要在同一帧计算所有路径。可以每帧只计算20个单位的路径,用5帧完成一轮更新。虽然单个单位的响应略有延迟,但整体帧率会平滑很多。Unity的Coroutine或自己维护一个索引计数器都可以实现。
  • 善用Jobs System & Burst Compiler:这是Unity DOTS的核心优势。如果你采用了ECS架构,那么可以很自然地将移动计算、寻路计算、伤害计算等纯数据操作封装成IJob,让JobSystem调度到多个CPU核心上并行执行,并用Burst Compiler编译成高度优化的本地代码。即使不用完整的ECS,对于一些独立的、计算密集的模块(如流体模拟、粒子系统计算),也可以考虑用Jobs来加速。
  • 避免在Update中做昂贵操作:如FindGameObjectsWithTagGetComponent(尤其是每帧调用)、复杂的物理查询(如OverlapSphere)。这些操作的结果应该被缓存起来。

4.3 内存与资源管理:杜绝“隐形杀手”

内存泄漏和资源加载卡顿是体验杀手。

  • Addressable Assets System:Unity的Addressable系统是现代资源管理的首选。它提供了异步加载、依赖管理、内存分析和远程更新(热更)的能力。将你的单位预制体、音效、纹理等标记为Addressable,通过标签或地址进行异步加载,可以完美解决资源加载卡顿和内存管理问题。
  • 清晰的资源生命周期:明确每个资源应该在何时加载、何时卸载。场景切换时,卸载掉所有不再需要的资源。对于全局常用的资源(如UI图标、基础音效),可以使用“永不释放”的标签将其常驻内存。
  • 分析工具是朋友:定期使用Unity Profiler(特别是Memory和CPU模块)和Frame Debugger。Profiler能告诉你每一帧CPU时间花在了哪里,内存中有什么。Frame Debugger能让你看清每一帧的Draw Call是如何产生的。不要凭感觉优化,要用数据说话。

5. 常见问题与排查实录:那些年我踩过的坑

理论说再多,不如看看实际开发中会遇到哪些妖魔鬼怪。这里分享几个让我记忆犹新的“坑”。

5.1 单位选择“飘忽不定”,尤其是UI覆盖时

问题描述:框选或点击选择单位时,有时会选不中,或者选中了后面的物体,特别是在有UI界面(如建造菜单)覆盖在游戏画面上时。

根因分析:Unity的事件系统(EventSystem)默认会处理UI事件。当鼠标点击在UI元素上时,事件会被UI拦截,不会继续传递到场景中的物体上。此外,用于检测单位选择的射线(Raycast)可能因为碰撞体大小、层级设置不当而失败。

解决方案

  1. UI屏蔽处理:在开始处理游戏世界的点击(如OnPointerDown)时,首先检查EventSystem.current.IsPointerOverGameObject()。如果返回true,说明当前指针在UI上,应直接返回,不处理游戏世界的选择逻辑。
  2. 精确的射线检测
    • 对于点击选择:使用Physics.RaycastAll(非Raycast)获取所有命中点,然后按距离排序,从中筛选出属于“Selectable”层且未被其他物体(如地形、不可选建筑)完全遮挡的第一个单位。
    • 对于框选:如前所述,采用屏幕空间坐标判断法,避免使用大量物理检测。
  3. 调整碰撞体:确保单位的碰撞体(如CapsuleCollider)大小合适,既能被轻松点到,又不会在密集时互相干扰。可以考虑为选择功能单独设置一个比视觉模型稍大的“选择碰撞体”。

5.2 大量单位移动时严重卡顿,甚至“鬼畜”

问题描述:当命令上百个单位同时向一个点移动时,游戏帧率骤降,并且单位们开始不规律地抖动、旋转,无法到达目的地。

根因分析:这是典型的“拥塞”和“计算爆炸”问题。所有单位同时计算复杂的NavMesh路径,CPU不堪重负。同时,大量NavMeshAgent在极小区域内尝试彼此避障,导致寻路目标点被不断刷新,陷入死循环。

解决方案

  1. 集群路径规划(Cluster Pathfinding):不要为每个单位单独寻路。将位置相近的单位视为一个“集群”,只为这个集群计算一条主路径(比如从集群中心到目标区域边缘)。集群内的每个单位再以这条主路径为参考,结合简单的局部避障(或甚至不用避障,仅做分离)向目标移动。
  2. 降低避障频率和精度:调高NavMeshAgentavoidancePriority(让一些单位更“谦让”),或者直接对大规模集群移动关闭局部避障,依靠阵型逻辑来保持间距。
  3. 流场(Flow Field)寻路:对于超大规模单位的移动,这是一个更高级的解决方案。它预先将地图划分为网格,计算每个网格到达目标点的“成本”和“方向”,形成一个向量场。单位移动时,只需查询自己所在网格的方向向量即可,计算开销极低,且天然支持群体平滑移动。虽然Unity没有内置,但有开源实现可以参考。
  4. 分帧更新寻路:这是必须做的。将单位分成N组,每帧只更新一组的最终路径。

5.3 网络同步不同步,玩家间状态“裂开”

问题描述:在多人对战模式下,不同玩家看到的单位位置、血量不一致,或者指令执行有延迟,严重时导致游戏无法进行。

根因分析:RTS的网络同步是公认的难题,因为它要求高度的实时性和确定性。常见的“锁步同步”(Lockstep)要求所有玩家的机器以完全相同的顺序执行完全相同的指令,任何延迟或指令顺序错乱都会导致不同步。

解决方案与取舍

  • 确定性模拟:这是锁步同步的基石。确保游戏逻辑在所有客户端上运行的结果完全一致。这意味着不能使用浮点数的直接比较(有精度误差),不能使用UnityEngine.Random(使用自定义的确定性随机数生成器),所有物理模拟(如果用到)也必须是确定性的。
  • 指令缓冲与延迟补偿:为了容纳网络延迟,客户端不会立即执行收到的指令,而是将其放入一个缓冲区,等待所有玩家的指令都到齐后,在同一个逻辑帧一起执行。这会给操作带来固有的延迟感。为了改善体验,可以采用“客户端预测”:本地玩家输入指令后,客户端立即在本地模拟效果(如单位开始移动),如果后续服务器权威状态与预测不一致,再进行平滑纠正或回滚。
  • 状态同步作为补充:对于非核心的、视觉效果为主的状态(如单位的精确旋转、攻击动画的细微时间差),可以采用低频率的状态同步来弥补,而不强求严格的确定性。但这会增加网络带宽和复杂度。
  • 选择合适的网络框架:Unity自带的UNET已过时,建议使用专业的第三方框架,如Photon FusionMirrorFish-Networking,或者基于UDP自己实现一套同步协议。这些框架通常提供了更完善的RTS同步解决方案和工具。

5.4 战争迷雾(Fog of War)性能开销大

问题描述:为了实现战争迷雾效果(已探索但当前无视野的区域变暗,未探索区域全黑),使用了渲染纹理(Render Texture)或后期处理,导致Draw Call增加,GPU压力大。

根因分析:每帧动态更新战争迷雾纹理(根据单位视野)涉及到对一张纹理的读写,如果单位很多、视野更新频繁,开销确实不小。全屏的后处理Shader也会增加GPU负担。

优化方案

  1. 降低纹理分辨率:战争迷雾不需要和屏幕分辨率一样高。使用一张256x256或512x512的纹理通常就够了,在Shader中采样时进行双线性过滤,视觉上可以接受。
  2. 降低更新频率:不需要每帧都更新整个战争迷雾纹理。可以每2-3帧更新一次,或者将地图划分为网格,只更新视野内单位所在的网格区域。
  3. 使用更高效的算法:从“每个单位画一个圆形”的叠加方式,改为基于网格的“刷格子”算法。将地图划分为一个粗粒度的逻辑网格,每个格子记录其“探索度”和“可见度”。单位视野只需影响其周围的格子。渲染时,根据格子的状态来混合颜色。这从像素级的片元着色器计算,变成了格子级的逻辑计算,CPU负担可能增加,但GPU负担大幅下降,且更可控。
  4. 考虑烘焙静态视野:对于固定不动的、提供视野的建筑(如瞭望塔),其视野范围是固定的,可以预先计算并“烘焙”到迷雾纹理中,运行时无需重复计算。

开发RTS就像指挥一场战役,架构是你的战略蓝图,每个模块是你的兵种,而性能优化则是你的后勤补给线。任何一个环节出问题,都可能让整场“战役”崩溃。这个过程充满挑战,但当你看到自己设计的单位在战场上听从调遣、激烈交锋时,那种成就感是无与伦比的。我的经验是,不要试图一开始就做出一个完整的《星际争霸》,从一个最小可行产品(MVP)开始——比如只有一个兵种、一种资源、一张小地图,先把选择、移动、攻击这个核心循环跑通,然后像搭积木一样,一个个地加入经济、建造、科技、AI等模块。在这个过程中,持续地用Profiler测量性能,用玩家测试体验,不断迭代和优化。记住,一个流畅、响应迅速但功能简单的RTS,远比一个功能繁多但卡顿不堪的半成品更有价值。

← 返回列表