找回密码
 立即注册
查看: 682|回复: 0

[笔记] 深入了解 Unity DOTS Sample (一): 代码框架 & 工具 & 开发模式

[复制链接]
发表于 2020-12-15 09:53 | 显示全部楼层 |阅读模式
<< 返回目录
代码框架 & 工具

相比于 FPSSample, DOTS Sample 进行了更加 package 化的更动, 无关乎游戏本体的代码放在了 Unity.Sample.Core 等以 Unity.Sample. 命名的文件夹, 而游戏本体则放在 Assets/Scripts 目录下, 另外, 因为 DOTS 官方鼓吹将运行时和编辑时区别对待, 因此几乎每一个功能都分为Runtime 和 Authrong 两个部分, 比如 Unity.Sample.Core 和 Unity.Sample.Core.Authring,  在编辑器里面用到的东西都能在 xxx.Authring 里找到. 下文中我会将类似 Unity.Sample.Core 和 Unity.Sample.Core.Authroing 这样较为独立的存在称之为一个模块, 而 Assets/Scripts 里面的代码称为游戏代码.
首先简述一下每个模块各自主要都干了些什么.
    Core: 如名字所暗示, 这时整个程序的核心部分, 几乎所有其他部分都依赖于它:
      ConfigVars: 用于配置和序列化配置, 许多 FPS 游戏里用于配置游戏画面, 游戏模式, 或者服务器端配置参数这样的功能, 都由它来完成Console: 玩过 CS 之类的游戏都知道游戏里包含一个控制台, 一些高级的命令可以通过控制台而不是图形化 UI 来完成, 这个模块对于高级玩家或者开发人员非常有帮助(尤其是网络驱动的游戏, 经常需要在线上环境进行调试)DebugDisplay, DebugOverlay, GameDebug:  对于网络游戏的开发, 时常需要区分客户端/服务端的日志, 也需要经常在非开发环境查看日志, 该模块可以有效地实现这些功能, 同时还对 Burst 进行了适配, 基本上可以告别原始的 Debug.Log 了.GameTime: 因为需要进行客户端&服务端的同步, Unity 自带的 Time 模块显然无法满足这些需求.PrefabAssetRegistry: 这个模块的作用是将 prefab 的在 asset 数据库里的 guid 以弱引用的方式保存在序列化后的 scene 当中, 以便于后期在运行时可以通过这些引用来拿到 prefab 的引用, 并进行实例化, 这个模块在现阶段算是对 conversion workflow 的 Hack (更多关于这个主题, 可以参考我的这篇译文)
    Game: 这个模块很容易和 Assets/Scripts 文件夹里的 Game 搞混, 两者其实在功能的角色方面区分不大, 但最大的区别是, Game模块几乎适用于大部分游戏, 而 Assets/Scripts/Game 只针对本游戏, 只需要记住这一点, 就能比较容易理解整体的代码结构逻辑了.
      Ability: 这个系统和 unreal 的 gameplay 系统 有点类似, 定义了一套可用于玩法开发的数据结构和生命周期(当然也包括网络的同步), 不单单是游戏角色的技能可以基于此来开发, 几乎任何与"机制"相关的内容都可以放到这里来.Animation&Animation Source: 这个部分的代码相对底层和工具属性, 包括利用 Unity.Animation 包创建 Graph 或者 Rig Attach 等, 原因可能是 Unity.Animation 目前还是非常初级的开发阶段, 提供的 API 都还比较原始, 未来也许会把这些内容集成到 Unity.Animation 包当中去. (更大的可能是提供类似 Animator/State Machine 这样更方便设计师/艺术家的编辑器工具)Audio: 和 PrefabAssetRegistry 有点类似, 音频文件的管理也用了类似的方式, 不过要注意的是, 这个和未来可能发布的 DOTS audio 没有一点关系, 可以看成是 HybridAudio for ECS.Effect: 这个同样可以理解为 HybridVisualEffects for ECS. (VisualEffects目前没有提供ECS Api)GameBootStrap: Entities 包有一套默认的初始化方式, netcode 有一套基于它的初始化定制, 而 GameBootStrap 则是基于 netcode 的初始化定制.Health: 这几乎是绝大部分游戏的通用系统了, 要注意不仅仅游戏的"角色" 可以用到, 游戏里的"物品"也可以用到.HitCollider: 基于 Unity.Physics 的碰撞检测模块实现, 游戏里常见的命中检测, 抛射物, 溅射伤害都可以在这里找到.Part: 该模块抽象用于动画系统的"部分"概念, 比如一个角色在不同摄像机距离下需要显示的动画不同(LOD), 那么不同的 LOD Rig, 就是属于该角色的不同 Part. 同样的, 有个类似PrefabAssetRegistry 的 PartRegistry 用于管理动画相关资源(比如 Rig 就以 blob )的动态创建.Item & Inventory: 通常用于物品&背包的抽象, 比如武器(item)属于(own)某个角色, 同时可以位于某个技能栏(inventory)的空槽(slot). 要注意的是, Item 也可以拥有 Part.Player: 抽象了玩家的概念, 其中包含玩家的输入, 相机 和 GameMode相关数据和处理逻辑.
    BaseCharacter: 与表现层无关的角色相关代码
      Character: 最重要的自然是 CharacterController, 用以处理角色创建, 地面检测, 碰撞集成等, 相关数据最终可以用于驱动动画系统的运行.Abilities: 要注意的是移动相关的代码并不在 Character 里, 而是放在 Abilities 里, 对于一个角色来讲, 如何移动, 攻击方式, 是否死亡都属于 Ability 的一部分, 这样设计可以创建更加灵活的"英雄"配置系统.
    Terraformer: 一个具体的角色实例, 基本只包含动画相关代码, 这样做的原因是, 类似守望先锋这样的英雄类游戏, 即便是相同的"能力", 不同英雄的动画集也完全不同. 另外对于复杂的动画控制需求, AnimatorController 这样的方案会很快让耦合的代码和 StateMachine 乱成一锅粥, 因此这里采用了 AnimSource 的概念, 将不同类型的动画控制(比如角色站立时的IK或者在空中的姿态)分别实现, 然后放置到相应的 AnimSource Stack里面, 最终 mix 后进行"渲染".Editor Tools: 一些实用的编辑器工具, 实用到让人纳闷为什么 Unity 默认不支持这些功能. 诸如剪切粘贴 GameObject, 查询 Asset 的Guid或者依赖等, 这些功能可以在 A2 菜单里面找到.
可以看到, 这些近似于 package 的内容使得拿来主义变得更加容易, 甚至不排除未来部分模块将被打造为官方的 package 的可能性.
游戏相关代码(Scripts 目录)
这里包含了游戏相关的代码, 如果你要开发 FPS 游戏, 那么这里的部分内容很容易可以拿来复用,  依然按照目录简述一下主要内容:
    Game: 玩法和UI相关的内容都在这里.
      Character: 角色 UI 的内容, 包括血量, 技能等等, 由于目前 UI 模块并不支持 ECS, 因此这里的实现主要是使用 Hybrid 模式.Chat: 聊天模块, 和上面的 Character UI 类似, 也使用 hybrid 模式实现.Core: 包含统计游戏状态的 GameStatistics, 和用于管理 Scene 的 LevelManager.Frontend: 从 FPSSample 里面遗留下来但并未被使用或者 DOTS 化的代码, 比如菜单界面的 UI 相关的内容.GameMode: FPS游戏经常会有各种模式, 比如死斗, 站点, 救援等等, 这里实现了 GameMode 常见的两种模式, 以及计分板, 队伍等逻辑.Movable: 已无用的FPSSample 的遗留代码, 用来实现移动平台.ServerCamera: 已无用的FPSSample 的遗留代码, 用来实现服务端可以观战的摄像头.Teleporter: 传送机制Turret: 无用代码, 用于实现炮台机制.Main: 这是目前最重要的部分, 游戏的初始化和游戏循环在此实现, 前文提到的 GameBootStrap便是调用此处的 Game.cs 来启动游戏的. 同时一个从 FPSSample遗留下来的 GameLoop 概念依然保留了一部分, 因此这部分的实现并不够 DOTS.
    Networking: 这里大部分的代码都是遗留代码, 相关内容已经转移到 Unity.Netcode 或者Unity.Transport 里, 下面只说说有用的部分.
      Generated: netcode 生成的代码, 这个目录可以自由指定.V2: 基于 Unity.Netcode 网络相关的代码, 包括RPC 定义, 连接管理等内容.
    Render: 大部分内容也不再适用, 如果你需要像大多FPS游戏那样允许玩家精细控制画面设定, 那么 (基于 HDRP 的) RenderSetting 会比较有用.Utils: 一些实用代码, 比如窗口控制, 朴素的状态机实现, 循环列表等, 很多内容也属于遗留项.
开发模式

这里先预先介绍一下在 DOTS 下开发的常见编程模式, 这样有助于理解项目具体功能的设计过程.
ComponentSystemGroup
Netcode 覆盖了 entities 默认提供的 SystemGroup, 你可以随需取用他们, 不过更好的办法是按照大的逻辑模块来自行定义所需 SystemGroup, 比如 DOTS Sample 里的  Abilities 模块就定义了如下的 SystemGroup 结构:
[DisableAutoCreation]
public class AbilityUpdateSystemGroup : ManualComponentSystemGroup

[UpdateInGroup(typeof(AbilityUpdateSystemGroup))]
[DisableAutoCreation]
public class BehaviourRequestPhase : ManualComponentSystemGroup
[UpdateInGroup(typeof(AbilityUpdateSystemGroup))]
[UpdateAfter(typeof(BehaviourRequestPhase))]
[DisableAutoCreation]
public class MovementUpdatePhase : ManualComponentSystemGroup

[UpdateInGroup(typeof(AbilityUpdateSystemGroup))]
[UpdateAfter(typeof(MovementUpdatePhase))]
[DisableAutoCreation]
public class MovementResolvePhase : ManualComponentSystemGroup

[UpdateInGroup(typeof(AbilityUpdateSystemGroup))]
[UpdateAfter(typeof(MovementResolvePhase))]
[DisableAutoCreation]
public class AbilityPreparePhase : ManualComponentSystemGroup

[UpdateInGroup(typeof(AbilityUpdateSystemGroup))]
[UpdateAfter(typeof(AbilityPreparePhase))]
[DisableAutoCreation]
public class AbilityUpdatePhase : ManualComponentSystemGroup
可以清晰的看到代码执行的顺序, 同时以模块化的方式避免使用传统 Unity 的 Script Execution Order 带来的全局耦合和混乱, [DisableAutoCreation]则让你能精细的控制相关系统执行的环境(比如区别对待client/server)
功能模块
以角色移动功能简化后的代码为例来说明功能模块通常的实现方案, 具体的解释附在注释当中:
// 这里的 class 主要是充当命名空间的功能.
// 一个功能模块通常不止一个 ComponentSystem 或者 Data 结构, 这样就避免了他们的名称过长的问题.
public class AbilityMovement
{

    // 通常移动功能会允许设计师在编辑器里调整移动速度等参数, 需要导出到编辑器的 IComponentData 都命名为 Settings 并支持序列化.
    [Serializable]
    public struct Settings : IComponentData
    {
        ...
    }
    // 将 PredictedState 和 InterpolatedState 区别开来有两个目的
    //   1. netcode 的 ghost 默认设置粒度是基于 IComponentData, 这可以免去
    //   2. 从名称上可以提示你在实现时要不要增加客户端预测的检测代码.
    public struct PredictedState : IComponentData
    {
        [GhostDefaultField]
        public LocoState locoState;
        ...
        // 别忘了 C# 里可以自由地在 struct 添加方法
        public bool IsOnGround()
        {
            ...
        }
    }

    public struct InterpolatedState : IComponentData
    {
        ...
    }
    // 多个 System 在代码里的位置相同, 但是执行顺序则由 Group 来确定.
    [UpdateInGroup(typeof(BehaviourRequestPhase))]
    // 虽然这里指定了 DisableAutoCreation, 但你在初始化过程并不需要初始化该系统, 而只需要初始化 BehaviourRequestPhase 即可
    [DisableAutoCreation]
    [AlwaysSynchronizeSystem]
    public class IdleUpdate : JobComponentSystem
    {
        protected override JobHandle OnUpdate(JobHandle inputDeps)
        {

        }
    }

    [UpdateInGroup(typeof(MovementUpdatePhase))]
    [DisableAutoCreation]
    [AlwaysSynchronizeSystem]
    public class ActiveUpdate : JobComponentSystem
    {
        protected override JobHandle OnUpdate(JobHandle inputDeps)
        {
            inputDeps.Complete();

            var updateJob = new UpdateJob
            {
                time = GetEntityQuery(ComponentType.ReadOnly<GlobalGameTime>()).GetSingleton<GlobalGameTime>().gameTime,
                // 尽可能使用不同的 entity 来"存储"数据, 使用 GetComponentDataFromEntity 来访问 entity 之间的 "关系", 这样能有效地拆分耦合的代码.
                playerControlledStateFromEntity = GetComponentDataFromEntity<PlayerControlled.State>(true),
                ...
            };
            // 本模块只需要简单声明 Settings, 引用其他模块的数据结构时则需要使用 "全名"
            Entities
                .ForEach((ref Ability.EnabledAbility activeAbility, ref Ability.AbilityStateActive stateActive, ref Settings settings, ref PredictedState predictedState) =>
            {
                ...
            }).Run();

            return default;
        }
    }
}
从这个简短的例子就可以看到 ECS 架构的优势所在, 和 MonoBehaviour 相比, 整个过程中, 我完全不用关心我的 GameObject 是谁? 需要如何设置回调函数? 是否有其他代码访问我的类产生副作用? 所有的代码只需要关心两件事: 1. 系统需要读写什么数据 2. 系统的执行顺序是什么
数据的初始化通常交给编辑器端的 GameObject/Prefab, 这和传统的 Unity 开发没什么两样, 这被称为 conversion workflow, 通常你需要编写一个这样的 Authoring Component:
using Unity.Entities;
using UnityEngine;

// 现目前的 SubScene Workflow 还不够完善, 当你无法触发自动 Convert 的时候, 可以尝试更改这里的版本号
[ConverterVersion("filod", 20)]
public class AbilityMovementAuthoring : MonoBehaviour, IConvertGameObjectToEntity
{
    // 这里你可以预设一些数据
    public AbilityMovement.Settings settings = new AbilityMovement.Settings {
        playerSpeed = 5,
        ...
    };
    public void Convert(Entity entity, EntityManager dstManager, GameObjectConversionSystem conversionSystem)
    {
        // GameObject 和 Entity 并非一一对应的关系, 你可以自由设计运行时的 Entity 结构
        // Authoring 组件的设计则主要考量设计师的方便.
        dstManager.AddComponentData(entity, new Ability.AbilityTag { Value = Ability.AbilityTagValue.Movement });
        dstManager.AddComponentData(entity, settings);
        // 这些数据无需暴露给策划
        dstManager.AddComponentData(entity, new AbilityMovement.PredictedState());
        dstManager.AddComponentData(entity, new AbilityMovement.InterpolatedState());
// setName 可以方便在 Entity Debugger 查看和调试, 但是在最终构建中并无用处
#if UNITY_EDITOR
        dstManager.SetName(entity,name);
#endif
    }
}
在代码结构上 Authoring 组件和 Runtime 组件是完全分离的, 这是因为在构建后的程序里, 你完全用不上Authoring 组件的 Assembly, 因为这些都被存储在 Entities Cache 里了.
至此, DOTSSample 的整体概念已经介绍完毕, 后续文章里, 我会详细介绍重要的模块的设计和实现过程.
懒得打字嘛,点击右侧快捷回复 【右侧内容,后台自定义】
您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

小黑屋|手机版|Unity开发者联盟 ( 粤ICP备20003399号 )

GMT+8, 2024-12-23 12:26 , Processed in 0.093018 second(s), 25 queries .

Powered by Discuz! X3.5 Licensed

© 2001-2024 Discuz! Team.

快速回复 返回顶部 返回列表