咖啡丶七

自律给我自由

创建状态类

状态基类选择

共有两个类使用了IState接口,另外其中一个类还衍生出了一个专门供角色使用的子类

  • State:非常纯粹的使用IState,无其他任何操作。适用于一些简单的FSM。
  • StateBehaviour:除了使用Istate外,还继承了MonoBehaviour。适合需要在Inspector上序列化的FSM。
    • CharacterState:继承自StateBehaviour,因为角色的状态大多数都无法打断自己(无法从Idle再次进入Idle),该类针对这个问题做了特殊处理

如果需要创建角色FSM的话,毫无疑问的就需要继承CharacterState

代码示例

状态类里的逻辑越简单越好,只需要做状态类的逻辑判断,不要做状态转换

使用方法
1
2
3
4
5
6
7
public class MoveState : CharacterState
{
private void OnEnable()
{
Debug.Log("播放Move动画");
}
}
最好不要做切换逻辑

状态类只用处理自己状态内的逻辑,不要做转换逻辑(转换逻辑应该在Brain中判断)。除非是特殊的事件状态(如攻击、死亡)结束后可以使用TrySetDefaultState返回默认状态。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class IdleState : CharacterState
{
private void OnEnable()
{
Debug.Log("播放Idle动画");
}

private void Update()
{
// 不要出现如下状态转换的逻辑,这种逻辑应该在PlayerBrain里做
// if (Input.GetKeyDown(KeyCode.Space))
// {
// _stateMachine.TrySetState(State.Air);
// }
}
}
特殊事件

优先级与强制转换使用场景

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class AttackState : CharacterState
{
[SerializeField]
private PlayerBrain1 _brain;
private int _animationTime = 1000;
private void OnEnable()
{
Debug.Log("播放Attack动画");
AnimationDelay();
}

// 将攻击状态设置为中优先级,避免被其他状态打断
public override CharacterStatePriority Priority
=> CharacterStatePriority.Medium;

private async void AnimationDelay()
{
await Task.Delay(_animationTime); // 模拟动画播放
_brain.StateMachine.ForceSetDefaultState(); // 在动画播放完毕后强制设置成默认状态
}
}

创建Brain类

用来控制Player在应该进入什么状态

StateMachine基类选择

有两个可供选择,分别对应着后缀为1和2的文件

  • StateMachine1:在进入状态时需传入状态类这个对象实例。
  • StateMachine2:在进入状态时只用输入对应的枚举。对序列化友好,但是需要额外的精力去维护。

对于Player状态机这两个都可以

StateMachine1示例

注意事项:在Update中转换时,必须要使用if等逻辑判断处理好进入状态的顺序,不要出现成功进入A状态后继续转换,导致进入了B状态。

错误示范:

1
2
3
4
5
6
7
private void Update()
{
if (在空中)
FSM.TrySetState(State.Air); // 角色在空中,成功进入了空中状态
FSM.TrySetState(State.Idle); // 但是还会继续执行Update,导致进入Idle状态。
// 最终表现在游戏中的结果是玩家疯狂在这两个状态中切换
}

正确示范:

1
2
3
4
5
6
7
private void Update()
{
if (在空中)
FSM.TrySetState(State.Air); // 角色在空中,成功进入了空中状态
else
FSM.TrySetState(State.Idle); // 角色不在空中,则进入Idle状态
}

源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
public sealed class PlayerBrain1 : MonoBehaviour
{
public StateMachine<CharacterState>.WithDefault StateMachine = new();

[SerializeField] private CharacterState _idleState;
[SerializeField] private CharacterState _moveState;
[SerializeField] private CharacterState _attackState;


private void Start()
{
// 初始化状态机,需要选择默认状态,在使用ForceSetDefaultState等方法时会进入该状态
StateMachine.InitializeAfterDeserialize(_idleState);
}

private void Update()
{
UpdateMovement();
UpdateAttackAction();
}

private void UpdateMovement()
{
if (Input.GetAxis("Horizontal") != 0)
{
// 两种方法进入状态
StateMachine.TrySetState(_moveState);
// _stateMachine.TrySetState(_moveState);
}
else
{
StateMachine.TrySetState(_idleState);
}
}

private void UpdateAttackAction()
{
if (!Input.GetMouseButtonDown(0)) return;
StateMachine.TrySetState(_attackState);
}
}

StateMachine2示例

状态机的使用方法上面已经说的很清楚了,这里就只说一下使用枚举与类实例的区别

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
public sealed class PlayerBrain2 : MonoBehaviour
{
private enum State { Idle, Move }

[SerializeField]
private StateMachine<State, CharacterState>.WithDefault _stateMachine = new();

[SerializeField] private CharacterState _idleState;
[SerializeField] private CharacterState _moveState;

private void Awake()
{
// 需要映射枚举与状态的关系
_stateMachine.AddRange(
new [] { State.Idle , State.Move },
new [] { _idleState, _moveState});
}

private void Start()
{
// 可以使用枚举初始化
_stateMachine.InitializeAfterDeserialize(State.Idle);
}

private void Update()
{
UpdateMovement();
}

private void UpdateMovement()
{
if (Input.GetAxis("Horizontal") > .1f)
_stateMachine.TrySetState(State.Move); // 可以使用枚举做状态转换,而不需要类的实例
else
_stateMachine.TrySetState(State.Idle);
}
}


多线程

WebGL环境不支持多线程,因此:

  • 不能使用:System.ThreadingTask.Run()之类的多线程操作
  • 不能使用:Burst编译的JobSystem也是多线程

多线程编程异步编程是两个不同的概念

WebGL支持异步编程

  • 可以使用:asyncTask(只要没有使用Tak.Run()将任务调到新的线程上就OK)
  • 可以使用:IEnumerator协程
  • 可以使用:UniTask是完全在PlayerLoop上运行的,没有使用Thread(除了UniTask.RunUniTask.SwitchToThreadPool

文件系统访问限制

WebGL是运行在浏览器中的,没有访问本地系统的权限:

  • 不能使用:System.IO访问本地文件
  • 可以使用:PlayerPrefs.SetString(),使用后务必在使用PlayerPrefs.Save();确保保存。
  • 可以使用:WWWUnityWebRequest加载远程资源

数值精度

  • JavaScript 只有一种数值类型 Number(64位双精度浮点数),用来表示所有的数值,包括整数和浮点数

  • Unity的float(32位单精度浮点数),在WebGL上运行时,float的精度还是会维持在32位

  • GPU在移动设备,尤其是低端设备上会降级浮点数的精度。当float降级成16位时,对该数据的影响就非常大了

拓展:

  • 精度降级不一定只发生在WebGL上,会发生在所有GPU计算上,尤其是涉及图形渲染
  • 通常分为16/32/64三个等级精度
  • 16位浮点数:
    • 规则:
      • 1位符号位,表示正数或负数
      • 5位指数位,表示数值的范围
      • 10位尾数位,表示数值的精度
    • 范围:
      • 最小正数:0.000061
      • 最大正数:65504
  • 32位范围:1.175×10^−38~`3.4×10^38`
  • 64尾范围:2.225×10^−308~`1.8×10^308`

缺失功能和插件

  • 部分插件无法使用,如Magica Cloth 2,插件是否支持WebGL具体查看插件官方文档
  • 不支持本地系统API,如访问操作系统功能、摄像头等
    • System.DateTime函数可能会表现的不同
    • System.Reflection执行效率较低
    • 不支持的API在打包时能看到警告

音频限制

  • 格式:WebGL支持的音频格式有限,建议使用 .ogg.mp3 格式

  • 延迟:由于浏览器的音频API限制,播放音频时可能有延迟,特别是首次播放音频。

调试

  • 可以使用F12查看Debug.Log日志,但无法调试

内存管理

浏览器分配的内存有限

  • 优化代码:
    • 避免过多的动态分配内存,防止内存泄漏
    • 手动释放不需要的资源(如Texture, Mesh, List等)
  • 资产:使用更小的纹理、网格和其他资源

性能优化

  • 减少Draw Calls
  • 限制帧率,以减少CPU负担

打包设置

参考:

Unity使用WebGL发布项目和注意事项

核心代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public struct StateChange<TState> : IDisposable
where TState : class, IState
{
[ThreadStatic] // 标记为线程静态
public static StateChange<TState> _current;

private StateMachine<TState> _stateMachine;
private TState _previousState;
private TState _nextState;

internal StateChange(StateMachine<TState> stateMachine, TState previousState, TState nextState)
{
this = _current; // 嵌套循环中上一层的 StateChange 暂存在 this 中,方便在 using 结束时复原

_current._stateMachine = stateMachine;
_current._previousState = previousState;
_current._nextState = nextState;
}

public void Dispose()
{
_current = this; // 在退出 using 时复原嵌套循环上一层的 StateChange
}
}

简介

  • 使用:该结构体只有在转换状态的时候才会创建,并且在转换完成后销毁(但身为静态成员的_current依然存在)

  • 作用:

    1. 规范状态转换方法:在调用IState的方法时,都需要在该结构体的作用域中。换言之,可以在IState的方法中访问上一个状态和下一个状态

    2. 主要作用:在执行嵌套循环时,能正确的返回上一层StateChange的数据


使用示例

下面将根据示例来展示StateChange的工作原理

规范状态转换方法

StateMachine中已经封装好了StateChange的创建,所以我们可以直接在IState的方法中访问StateChange的数据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class AttackState : State
{
StateMachine<State>.WithDefault _stateMachine;
public AttackState(StateMachine<State>.WithDefault stateMachine)
{
_stateMachine = stateMachine;
}

public override void OnEnterState()
{
// 输出: 从 IdleState 进入 AttackState
Debug.Log($"从 {_stateMachine.PreviousState} 进入 {_stateMachine.NextState}");
}
}

嵌套访问

假设当前从FallState->RunState为方便测试,以下代码并不规范

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class RunState : State
{
// 在进入 RunState 时,可能需要判断是否应该返回 DefaultState 或者进入 HitState
public override void OnEnterState() // 第一层嵌套,FallState -> RunState
{
Debug.Log($"第一层嵌套开始 {_stateMachine.NextState}"); // 输出:第一层嵌套开始 RunState

if (true)
{
_stateMachine.TrySetDefaultState(); // 第二层嵌套一号,RunState -> DefaultState
}

// 输出:第二层嵌套结束,继续第一次嵌套 RunState
Debug.Log($"第二层嵌套结束,继续第一次嵌套 {_stateMachine.NextState}");

if (true)
{
_stateMachine.TrySetState(_brain.hitState); // 第二层嵌套二号,RunState -> HitState
}
}
}

上方的嵌套可以表示成

1
2
3
4
5
6
7
8
9
10
using (new StateChange<TState>(stateMachine, FallState, RunState))
{
using (new StateChange<TState>(stateMachine, RunState, DefaultState))
{
}

using (new StateChange<TState>(stateMachine, RunState, HitState))
{
}
}

状态的改变顺序

  1. FallState -> RunState
  2. RunState -> DefaultState
  3. DefaultState -> HitState
  • 最终的状态为HitState
  • 其实这是一个错误示范,若TrySetDefaultState返回true,则应该return

如果在进入其他状态后没有return就会出现一个奇怪的问题:

第二层嵌套一号已经退出RunState了,但是不使用renturn,继续执行第一层时,_stateMachine.PreviousState会变回成RunState

注意

  • 在成功进入其他状态后需要return
  • 为了方便研究工作原理,我的所有状态继承的并不是StateBehaviour,而是最基础的State,如果查看StateBehaviour源码就能发现:真正进入状态才会执行的是OnEnable()方法,而不是OnEnterState()(该方法可以看做是一个工具人,用来判断是否满足进入状态的条件,实际上并没有进入)
    所以如果以上状态继承的是StateBehaviour,发生上面的改变顺序时,并不会执行DefaultState.OnEnable()

嵌套访问实现原理

主要通过using作用域、 StateChange()构造函数和Dispose()实现

假设当前正在执行上面示例的第二层嵌套一号

1
2
3
4
5
6
7
8
9
10
11
public void ForceSetState(TState state)
{
using (new StateChange<TState>(this, CurrentState, state)) //执行构造函数: 将 FallState -> RunState 暂存在this中
{
CurrentState?.OnExitState();

_currentState = state;

state?.OnEnterState();
} // 执行Dispose: 将 FallState -> RunState 复原
}

构造函数:

1
2
3
4
5
6
7
8
internal StateChange(StateMachine<TState> stateMachine, TState previousState, TState nextState)
{
this = _current; // 将 FallState -> RunState 暂存在this中

_current._stateMachine = stateMachine; // 开始第二层嵌套一号
_current._previousState = previousState;
_current._nextState = nextState;
}

Dispose

1
2
3
4
public void Dispose()
{
_current = this; // 将 FallState -> RunState 复原
}

总结

从这次的学习中,已经非常熟悉这套状态机的工作原理了,最需要注意的如下:

  1. 如果继承的是StateBehaviour,规范的写法为
    • OnEnterState()用来判断能否进入状态
    • OnEnable()才是进入状态后真正需要执行的代码
  2. 如果成功切换状态则需要return,避免再执行后续代码,导致又退出刚进入的状态
  3. 虽然Animancer已经将StateChange封装好了,但我们还是得搞清楚它工作的作用:
    • IState的方法中,可以访问改变状态的参数
    • 在嵌套访问IState时,返回上一层嵌套可以复原状态

spine在Unity中的案例演示

预览

设置混合

当需要

1
2
3
TrackEntry shootTrack = skeletonAnimation.AnimationState.SetAnimation(1, shoot, false);
shootTrack.AttachmentThreshold = 1f; // 表示shoot在与其他动画融合时,依然会完整运行自己的动画
shootTrack.MixDuration = 0f; // 表示进入shoot不需要融合

跟随物体销毁取消订阅

最稳定取消订阅的方法

官方给的方法,但是不确定说明版本。使用下面的也没问题

1
2
3
4
5
6
7
8
9
private void DisposableSubscribe()
{
// better performance
var d = Disposable.CreateBuilder();
Observable.EveryUpdate().Subscribe().AddTo(ref d);
Observable.EveryUpdate().Subscribe().AddTo(ref d);
Observable.EveryUpdate().Subscribe().AddTo(ref d);
d.RegisterTo(this.destroyCancellationToken); // Build and Register
}

单个事件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
```





#### 多个事件

```c#
private DisposableBag d; // 监听的事件较多时可以使用DisposableBag统一处理

private void OnDestroy()
{
subscription?.Dispose();
d.Dispose();
cts?.Dispose();
}

UI

如果是使用UI的订阅方式会自定取消订阅

1
exitButton.OnClickAsObservable().Subscribe(_ => ExitGame());

环境配置

包体

参考第一篇

项目设置

  • RUP-HighFidelity-Renderer => Rendering => Rendering Path 设置为 Forward+
  • Project Settings => Player => Resolution and Presentation => Resolution => 勾选 Run In Background
  • Project Settings => Player => Other Settings => Configuration => Scripting Backend 设置为 IL2CPP
  • Project Settings => Player => Other Settings => Configuration => 勾选 Use incremental GC
  • Project Settings => Multiplayer => Create 默认设置就OK
  • Preferences => Entities => Baking => Scene View Mode 设置为 Runtime Data
  • Create => Unity Physics => Physics Category Names => 自定义名称(可参考文章的射线碰撞检测图片)

夺回生成世界的控制权

在下载NetCode包体之后,启动游戏时Entities会生成ClientWorld和ServerWorld两个世界。
但玩家进入游戏的流程是:先进入主菜单,再进入游戏。在主菜单的时候不用生成ServerWorld,所以我们需要拿到控制权。

Overide Automatic Netcode Bootstrap组件挂载到场景中的任意物体上并选择Disable Automatic Bootstrap
因为我们要控制所有场景的世界生成,所以所有场景都需要挂载

挂载之后,启动场景时只会生成Default World
然后我们从主菜单进入游戏的时候删除掉Default World,生成Client WorldServer World就OK了。


删除默认世界

进入游戏场景时删除Default World,正好在切换场景的时候删除掉所有内容

1
2
3
4
5
6
7
8
9
10
11
// 遍历所有Entity并删除
foreach (var world in World.All)
{
if (world.Flags == WorldFlags.Game)
{
world.Dispose();
break;
}
}
// 上面只能删除Entity,并不会删除GameObject的,所以需要使用Single
SceneManager.LoadScene(1, LoadSceneMode.Single);

创建服务端和客户端世界

服务端:

1
2
3
4
5
6
7
8
9
10
11
private void StartServer()
{
var serverWorld = ClientServerBootstrap.CreateServerWorld("Coffee Server World"); // 创建服务端世界

// 使服务器监听7979端口
var serverEndpoint = NetworkEndpoint.AnyIpv4.WithPort(7979);
{
using var networkDriverQuery = serverWorld.EntityManager.CreateEntityQuery(ComponentType.ReadWrite<NetworkStreamDriver>());
networkDriverQuery.GetSingletonRW<NetworkStreamDriver>().ValueRW.Listen(serverEndpoint);
}
}

获取的NetworkStreamDriver组件为NetCode的特殊IComponentData

客户端:

1
2
3
4
5
6
7
8
9
10
11
public enum TeamType : byte
{
None = 0,
Blue = 1,
Red = 2,
AutoAssign = byte.MaxValue
}
public struct ClientTeamSelect : IComponentData
{
public TeamType Value;
}

创建客户端的同时向客户端世界中注入ClientTeamSelect组件,表面自己选择的队伍

再由ClientRequestGameEntrySystem,捕获进行下一步处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
private void StartClient()
{
var clientWorld = ClientServerBootstrap.CreateClientWorld("Coffee Client World"); // 创建客户端世界

var connectionEndpoint = NetworkEndpoint.Parse("127.0.0.1", 7979); // 将用户输入转换成网络地址
{
// 根据端口连接服务端
using var networkDriverQuery = clientWorld.EntityManager.CreateEntityQuery(ComponentType.ReadWrite<NetworkStreamDriver>());
networkDriverQuery.GetSingletonRW<NetworkStreamDriver>().ValueRW.Connect(clientWorld.EntityManager, connectionEndpoint);
}

// 将对Default World世界的操作同步到Client World,以免每次操作都要使用Client的API
World.DefaultGameObjectInjectionWorld = clientWorld;

var team = _teamDropdown.value switch // 设置传递给Entities的信息
{
0 => TeamType.AutoAssign,
1 => TeamType.Blue,
2 => TeamType.Red,
_ => TeamType.None
};

// 将用户在GameObject中选择的队伍传递到Entities中,!!!注意:这里并没有向服务端发送请求
var teamRequestEntity = clientWorld.EntityManager.CreateEntity();
clientWorld.EntityManager.AddComponentData(teamRequestEntity, new ClientTeamSelect()
{
Value = team
});
}
  • 注意World.DefaultGameObjectInjectionWorld = clientWorld;这个很重要,可以直接将对Default World世界的操作同步到Client World,以免每次操作都要使用Client的API。我们已经删除掉默认世界了,所以不会有重复的多余操作
  • 需要注意的是,这里并没有向服务器发送信息,只是将GameObject的信息传递到Entities的客户端中

客户端向服务器发送进入游戏请求

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// 只在客户端运行
[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation | WorldSystemFilterFlags.ThinClientSimulation)]
public partial struct ClientRequestGameEntrySystem : ISystem
{
private EntityQuery _pendingNetworkIdQuery;

public void OnCreate(ref SystemState state)
{
// 捕获有`NetworkId`且没有`NetworkStreamInGame`的实体
// "NetworkStreamInGame"是unity为我们准备的一个统一标签,表示该client已经处理过登录了
var builder = new EntityQueryBuilder(Allocator.Temp).WithAll<NetworkId>().WithNone<NetworkStreamInGame>();
_pendingNetworkIdQuery = state.GetEntityQuery(builder);
state.RequireForUpdate(_pendingNetworkIdQuery);
state.RequireForUpdate<ClientTeamSelect>(); // 获取在登入界面创建的组件
}

public void OnUpdate(ref SystemState state)
{
// 获取在登入界面创建的组件
var requestedTeam = SystemAPI.GetSingleton<ClientTeamSelect>().Value;

var ecb = new EntityCommandBuffer(Allocator.Temp);
var pendingNetworkIds = _pendingNetworkIdQuery.ToEntityArray(Allocator.Temp);

foreach (Entity pendingNetworkId in pendingNetworkIds)// 肯定是只有一个的,所以也使用pendingNetworkIds[0]
{
ecb.AddComponent<NetworkStreamInGame>(pendingNetworkId); // 将物体标记为进入游戏,该组件是一个Tag

// 向服务器发送请求,这里才是真正向服务器发送请求
var requestTeamEntity = ecb.CreateEntity();
ecb.AddComponent(requestTeamEntity, new MobaTeamRequest() { Value = requestedTeam });
ecb.AddComponent(requestTeamEntity, new SendRpcCommandRequest() { TargetConnection = pendingNetworkId });
}

ecb.Playback(state.EntityManager); // 等待缓冲器完成所有任务
}
}

只在客户端运行:

[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation | WorldSystemFilterFlags.ThinClientSimulation)]

发送数据包:

1
2
3
var requestTeamEntity = ecb.CreateEntity();
ecb.AddComponent(requestTeamEntity, new MobaTeamRequest() { Value = requestedTeam });
ecb.AddComponent(requestTeamEntity, new SendRpcCommandRequest() { TargetConnection = pendingNetworkId });
  • MobaTeamRequest为信息内容,虽然是使用AddComponent添加,但其实是IRpcCommand,而不是IComponentData

    1
    2
    3
    4
    public struct MobaTeamRequest : IRpcCommand
    {
    public TeamType Value;
    }
  • SendRpcCommandRequest为NetCode特殊组件

    • 将Entity传递过去,即上图的NetworkConnectiuon
    • 与服务器的ReceiveRpcCommandRequest对应
    1
    2
    3
    4
    5
    6
    7
    namespace Unity.NetCode
    {
    public struct SendRpcCommandRequest : IComponentData, IQueryTypeParameter
    {
    public Entity TargetConnection;
    }
    }

服务端接收玩家进入请求创建网络连接并创建幽灵系统的Entity

如果这里看不懂的话可以先看下面的RPC和Ghost

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]      // 只在服务端运行
public partial struct ServerProcessGameEntryRequestSystem : ISystem
{
public void OnCreate(ref SystemState state)
{
state.RequireForUpdate<MobaPrefabs>();
var builder = new EntityQueryBuilder(Allocator.Temp).WithAll<MobaTeamRequest, ReceiveRpcCommandRequest>();
state.RequireForUpdate(state.GetEntityQuery(builder)); // 获取到信息包才执行update
}

public void OnDestroy(ref SystemState state) { }

public void OnUpdate(ref SystemState state)
{
var ecb = new EntityCommandBuffer(Allocator.Temp);
var championPrefab = SystemAPI.GetSingleton<MobaPrefabs>().Champion; // 获取角色预制体

foreach (var (teamRequest, requestSource, requestEntity)
in SystemAPI.Query<MobaTeamRequest, ReceiveRpcCommandRequest>().WithEntityAccess())
{
ecb.DestroyEntity(requestEntity); // 客户端发送的请求包,但这里需要手动销毁。
// requestSource.SourceConnection为客户端存放在服务端上的连接器,每个客户端就会存放一个
ecb.AddComponent<NetworkStreamInGame>(requestSource.SourceConnection);

// 数据处理
var requestedTeamType = teamRequest.Value;

if (requestedTeamType == TeamType.AutoAssign)
{
requestedTeamType = TeamType.Blue;
}

float3 spawnPosition = new float3(0, 1, 0);

switch (requestedTeamType)
{
case TeamType.Blue:
spawnPosition = new float3(-50f, 1f, -50f);
break;
case TeamType.Red:
spawnPosition = new float3(50f, 1f, -50f);
break;
default:
continue;
}
// 获取客户端ID
var clientId = SystemAPI.GetComponent<NetworkId>(requestSource.SourceConnection).Value;

// Debug.Log($"{clientId} {requestedTeamType.ToString()}");

var newChamp = ecb.Instantiate(championPrefab); // 实例化角色
ecb.SetName(newChamp, "Champion"); // 方便测试,设置名称
var newTransform = LocalTransform.FromPosition(spawnPosition);
ecb.SetComponent(newChamp, newTransform);
// NetworkId是幽灵数据,即完全由服务端控制,设置该数值后clientId客户端对应的Entity的GhostOwnerIsLocal将被标记为true,GhostOwnerIsLocal同样也只能被服务端控制
ecb.SetComponent(newChamp, new GhostOwner() { NetworkId = clientId });
ecb.SetComponent(newChamp, new MobaTeam() { Value = requestedTeamType }); // 设置队伍

// 当玩家断线重连时,会摧毁旧的Entity,并创建新的Entity
ecb.AppendToBuffer(requestSource.SourceConnection, new LinkedEntityGroup(){ Value = newChamp });
}

ecb.Playback(state.EntityManager);
}
}

这里我们可以查看到数据包长啥样

接受数据包:

1
2
3
4
5
6
7
8
9
10
foreach (var (teamRequest, requestSource, requestEntity) 
in SystemAPI.Query<MobaTeamRequest, ReceiveRpcCommandRequest>().WithEntityAccess())
{
ecb.DestroyEntity(requestEntity); // 客户端发送的请求包,但这里需要手动销毁。是同一个实体?
// requestSource.SourceConnection为客户端存放在服务端上的连接器,每个客户端就会存放一个
ecb.AddComponent<NetworkStreamInGame>(requestSource.SourceConnection);
// ...
// 处理数据
// ...
}

创建角色:

1
2
3
4
5
6
7
var newChamp = ecb.Instantiate(championPrefab);         // 实例化角色
ecb.SetComponent(newChamp, new GhostOwner() { NetworkId = clientId }); // 设置该实例化角色控制的客户端ID
// 当玩家断线重连时,系统将摧毁旧的服务端Entities,并创建新的服务端Entities
ecb.AppendToBuffer(requestSource.SourceConnection, new LinkedEntityGroup(){ Value = newChamp });
// ...
// 其他设置,如position、队伍、名称等,设置名称后可以直接在`Hierarchy`窗口看到
// ...
  • GhostOwner:将实体的控制权交给clientId客户端

    1
    2
    3
    4
    5
    6
    7
    8
    9
    namespace Unity.NetCode
    {
    [DontSupportPrefabOverrides]
    [GhostComponent(SendDataForChildEntity = true)]
    public struct GhostOwner : IComponentData
    {
    [GhostField] public int NetworkId;
    }
    }
  • 将实例化保存在LinkedEntityGroup中,是为了统一管理该客户端在服务端的一切事物

    • 如果该客户端退出游戏或断线,就摧毁掉该一切与它有关的事物
    • 如果重连,就重新创建列表的物体(感觉应该可以控制哪些是要生成的,哪些是不需要的?)

RPC小结

在学习的前先搞清楚一个问题,客户端和服务端连接的方式就只是靠NetworkConnection,Ghost系统也只不过是建立在NetworkConnection上面封装好的工具而已,最终都是需要通过NetworkConnection在服务端和客户端传递信息。

发送数据模版:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 下面代码可能无法通过编译,但只是想简单的告诉你发送RPC的方法就是:
// 创建一个实体,并在其上面添加实现`IRpcCommand`接口的数据组件,和`SendRpcCommandRequest`的特殊组件就OK了
// 其他的系统会自动帮你完成
public struct LoadLevelRPC : IRpcCommand
{
public int LevelIndex;
}
private void LoadNewLevel(int levelIndex)
{
var rpcEntity = EntityManager.CreateEntity(typeof(LoadLevelRPC), typeof(SendRpcCommandRequest));
EntityManager.SetComponentData(rpcEntity, new LoadLevelRpc()
{
LevelIndex = levelIndex
});
}

这里的SendRpcCommandRequest没有指明对象,可以使用new SendRpcCommandRequest() { TargetConnection = pendingNetworkId }指定客户端ID:

发送者为客户端:

  • 客户端只能发送给服务端,所以有没有指定都是一样的。但为了Debug可以设置成自己的Id

发送者为服务端:

  • 未指定TargetConnection :发送给所有客户端
  • 指定TargetConnection :发送给指定的客户端

接受数据模版

1
2
3
4
5
6
7
// 捕获unity转换成ReceiveRpcCommandRequest的实体
foreach (var (levelToLoad, rpcEntity) in
SystemAPI.Query<LoadLevelRPC>().WithAll<ReceiveRpcCommandRequest>().WithEntityAccess())
{
ecb.DestroyEntity(rpcEntity); // 删除数据包
LoadLevel(levelToLoad.LevelIndex); // 数据处理
}

最重要的一点是在捕获到数据包后一定要删除掉该实体。要不然这个数据一直存在,服务端就以为是客户端在不停的发送数据。

流程梳理

传递的枚举TeamType数据一共有三个组件,別搞混了

  • 保存在Client World和Server World的ClientTeamSelectMobaTeam两个都是IcompoentData,是普通组件,不是用来RPC传递数据的

    为什么不直接使用ClientTeamSelect(思维整理,可不看):

    • ClientTeamSelect不会挂载到任何实例化对象上(也就是说不会挂载在Ghost上)。他会就这样永远孤独的漂浮在Client World中,如果想删除它也可以。
    • MobaTeam中数据使用[GhostField]修饰了,所以客户端会给对应的Ghost上添加MobaTeam组件,并且同步该数据。
    • 所以我们没有使用ClientTeamSelect来当做客户端的队伍,要不然我们还需要额外的精力去同步。
  • MobaTeamRequestRPCIRpcCommand通信用的,可以看做的临时中转站

发送数据步骤:

  1. Client World创建一个entity,在上面添加需要传递的数据组件和SendRpcCommandRequest组件
  2. NetCode捕获到到该entity后并删掉,同时在Server World还原这个entity(只将SendRpcCommandRequest改为了ReceiveRpcCommandRequest,其他数据完全一样)。这一步骤是NetCode完全自动化完成的
  3. 程序员在Server World使用ReceiveRpcCommandRequest手动捕获系统还原的entity,成功获取到数据

扩展测试实验(可不看):

关于这两个数据包到底是不是同一个Entity的问题:
直接使用代码获取到这两个Entity的IndexVersionHashCode就知道了

客户端:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public void OnUpdate(ref SystemState state)
{
Entity requestTeamEntity = Entity.Null;
// 输出刚创建时的对象信息,以防狸猫换太子
Debug.Log($"Send 1 : {requestTeamEntity == Entity.Null} Index: {requestTeamEntity.Index} Version: {requestTeamEntity.Version} HashCode: {requestTeamEntity.GetHashCode()}");
var ecb = new EntityCommandBuffer(Allocator.Temp);
foreach (Entity pendingNetworkId in pendingNetworkIds)
{
requestTeamEntity = ecb.CreateEntity();
ecb.AddComponent(requestTeamEntity, new MobaTeamRequest() { Value = requestedTeam });
ecb.AddComponent(requestTeamEntity, new SendRpcCommandRequest() { TargetConnection = pendingNetworkId });
}

ecb.Playback(state.EntityManager);
Debug.Log($"Send 1 : {requestTeamEntity == Entity.Null} Index: {requestTeamEntity.Index} Version: {requestTeamEntity.Version} HashCode: {requestTeamEntity.GetHashCode()}");
}

(不重要,可以跳过不看)代码为什么写成这样,要输出次:为了确认Entity是在缓存器执行完之后的信息,所以将其放在了最后面。然后又因为requestTeamEntity是定义在foreach中的,所以得现在最上面创建一个空的Entity,这样才能在.Playback()后面使用该Entity,要不然编译都不通过,并且从输出上来看,我们确实是输出了正确的Entity信息

拓展:经过实际上的测试,发现requestTeamEntity = ecb.CreateEntity();在这一步创建空物体的时候就已经确定了该物体IndexVersionHashCode了,所以在任意地方输出都可以

服务端:直接在捕获后输出信息

1
2
3
4
foreach (var (teamRequest, requestSource, requestEntity) 
in SystemAPI.Query<MobaTeamRequestRPC, ReceiveRpcCommandRequest>().WithEntityAccess())
{
Debug.Log($"Receive 0 : Index: {requestEntity.Index} Version: {requestEntity.Version} HashCode: {requestEntity.GetHashCode()}");

为了实验的严谨性,该玩家是同时创建了客户端和服务端,这两个世界都是在他这一个程序上工作的。

但是从输出结果可以看出来,两个Entity确实不是同一个。

并且如果仔细想想,确实两个世界都不一样,怎么也不太可能是同一个Entitiy


Ghost设置

在初始化角色前,先介绍一下客户端和服务端是怎么同步的——幽灵组件GhostAuthoringComponent

需要实现服务器与客户端同步的物体,就需要挂载幽灵组件,需要注意的点:

  • 幽灵系统的作用:将两个世界里创建ICompnentData复制一次到对面世界。仅仅只是复制,复制完后他们就没有任何联系了,甚至可以手动删除其中一个世界的组件。除非使用幽灵属性标记。
  • 幽灵数据[GhostField]作用:将服务器的数据映射显示到客户端上,客户端并不能直接修改被标记为[GhostField]的数据。就算修改了,也会被系统快照强制覆盖掉
  • GhostAuthoringComponent脚本必须挂载在预制体上,幽灵Entity通常是在运行的时候生成,而不是场景一开始就存在。当然,既然是Entity那就只能存在于子场景Eneities World中

可能会有一个误区:[GhostField]属性只是告诉系统这个数据的控制权应该完全交给服务器。

  • 如果你给服务器的Entity添加IComponentData,就算这个组件中没有[GhostField],因为幽灵系统的存在,系统也会给客户端的Ghost添加该组件
  • 更不要觉得如果一个IComponentData中有的数据有[GhostField]有的数据没有,系统就不会复制未被标记的数据。幽灵系统复制的IComponentData复制的是整个组件,而不是单独是数据。

脚本可以强行挂载在场景中一开始就存在的实体。这么做的话所有的配置都只能在预制体Asset本体上设置,场景中的每个实例化无法单独更改。

配置:

  • Importance:数据优先级,数值越大越优先发送
  • SupportedGhostModes:幽灵支持的模式,选择适合的模式能提供更好的优化,运行时无法更改此值
  • DefaultGhostMode:幽灵同步模式
    • Interpolated:轻量级,不在客户端上执行模拟。但他们的值是从最近几个快照的插值,此模式时间轴要慢于服务器。与玩家关联不大的,如防御塔、小兵、水晶基地等。
    • Predicted:完全由客户端预测。这种预测既昂贵又不权威,但它确实允许预测的幽灵更准确地与物理交互,并且它确实将他们的时间轴与当前客户端对齐。与玩家交互强相关的才需要设置成这个,如英雄,飞行道具等。
    • OwnerPredicted:由Ghost Owner(即服务端)预测,并且还会差值其他的客户端。是上面两个模式的折中方案。
  • OptimizationMode:优化模式,针对是否会频繁的传递数据的优化
    • Dynamic:希望Ghost经常更改(即每帧)时使用。优化快照的大小,不执行更改检查,执行delta-compression(增量压缩)
    • Static:优化不经常改变的Ghosts。节省带宽,但是需要额外的CPU周期来执行检查是否有数据更改,如果更改了就发送数据给服务器。如果设置不对给经常改变的Ghost了,将增加带宽和cpu成本
  • HasOwner:控制权是否是在玩家手上的,必须指定NetwordId的值
  • SupportAutoCommandTarget:勾选后将标记为[GhostField]的数据自动生成缓冲数据发送给服务器

预测组

凡是涉及到数值,并且与玩家、战斗强相关的system都需要预测,使数据更平滑,而不是一个梯度一个梯度的更改数值。

关于”平滑”指的是什么可以参考第二篇文章的Tick的讲解

使用[UpdateInGroup(typeof(PredictedSimulationSystemGroup))]属性,表示该system运行在模拟组内,该组客户端和服务端都会运行。

注意:

放置在该组后,system处理的entity如果是设置了Ghost的阈值体,那么DefaultGhostMode必须设置成Predicted。不然会出现很奇怪的BUG:虽然在systems、inspector窗口上该entitiy没有任何问题,但是客户端的所有system都只能在游戏开始的短暂一瞬间(13个update左右)捕获到该entity

1
2
3
4
5
6
7
8
9
10
11
12
[UpdateInGroup(typeof(PredictedSimulationSystemGroup))]
public partial class RespawnChampSystem : SystemBase
{
protected override void OnUpdate()
{
foreach (var respawnBuffer in SystemAPI
.Query<DynamicBuffer<RespawnBufferElement>>().WithAll<RespawnTickCount, Simulate>())
{
Debug.Log($"{(isServer? "Server" : "Client")}, 成功捕获到物体");
}
}
}

如果将DefaultGhostMode设置成Interpolated就会像下方这样出现很奇怪的BUG

组件

再重复一遍,客户端和服务端连接的方式就只是靠NetworkConnection,Ghost系统也只不过是建立在NetworkConnection上面封装好的工具而已,最终都是需要通过NetworkConnection在服务端和客户端传递信息。

GhostOwnerGhostOwnerIsLocal

这两个组件在main entity和ghost entity上都绑定了

GhostOwner只有一个幽灵数据,由服务端控制,表示该entity是属于那个客户端的

1
2
3
4
5
6
[DontSupportPrefabOverrides]
[GhostComponent(SendDataForChildEntity = true)]
public struct GhostOwner : IComponentData
{
[GhostField] public int NetworkId;
}

GhostOwnerIsLocal表面上看起来虽然是一个简单的可开关的标签,但其实是一个Ghost标签,客户端无法更改这个标签。其实该组件就是为了让客户端能快速定位到自己控制的Ghost entity而存在的。如果还嫌不够快的话,可以在自己创建一个单例标签。

1
public struct GhostOwnerIsLocal : IComponentData, IEnableableComponent { }

两个组件通力合作,在服务端上设置GhostOwner.NetworkId = 2之后,那么

  • 对于该客户端而言:ghost entity的GhostOwner.NetworkId = 2GhostOwnerIsLocal才是true,其他都是false
  • 对于服务端而言:所有的GhostOwnerIsLocal都是true

属性

字段属性
  • [GhostField]:完全由系统控制,系统会同步数值
Struct组件属性
  • [GhostComponent()]:常用于IcommandDataIBufferElementData等。可设置参数如下:
    • PrefabType:设置该组件是否需要烘焙到客户端上,例如:
      • GhostPrefabType.AllPredictedPredicted模式的实体,客户端和服务端都会烘焙;InterpolatedOwner Predicted不会烘焙客户端
      • GhostPrefabType.All(默认选项):所有类型,客户端、服务端都会烘焙
    • OwnerSendType:控制数据需要发送给那些客户端。对无服务没有影响,仅仅只是控制是否会 发送给客户端而已,比如计算的一些中间值就可以不用同步给客户端
      • SendToNonOwner:表示该数据只会发送给非拥有者的其他客户端
      • None:表示所有客户端都不会发送。适合一些中转数据,这类数据对客户端没影响,客户端也不需要这种数据,以减少带宽

初始化角色

前言

在给物体添加组件时先思考一下,这个客户端和服务端是否都需要用到这个组件

可以创建三个初始化system:客户端、服务端和共同的。例如,输入系统不需要在服务端运行,那么就只用将其在客户端system上初始化就OK了

如果使用了Ghost,那么在Authoring中烘焙的所有组件都同时会存在在客户端和服务端上

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// [UpdateInGroup(typeof(SimulationSystemGroup), OrderFirst = true)]	// 共同初始化
// [WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation | WorldSystemFilterFlags.ThinClientSimulation)] // 只在客户端上初始化
// [WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)] // 只在服务端运行
public partial struct InitializeCharacterSystem : ISystem
{
public void OnUpdate(ref SystemState state)
{
var ecb = new EntityCommandBuffer(Allocator.Temp);
foreach (var (physicsMass, mobaTeam, newCharacterEntity) in SystemAPI.Query<RefRW<PhysicsMass>, MobaTeam>().WithAll<NewChampTag>().WithEntityAccess())
{
// ...
// 初始化角色
// ...
ecb.RemoveComponent<NewChampTag>(newCharacterEntity); // 非常经典的"工具人"标签
}
ecb.Playback(state.EntityManager);
}
}

物理

添加下面两个组件

  • Rigibody

    • Use Gravity:为了避免服务端和客户端不同步,我们不能使用物理系统。
    • Is Kinematic:启用后物理系统将不能施加力来移动或旋转物体,而只能改变Transform来移动旋转物体
    1
    physicsMass.ValueRW.InverseInertia = float3.zero;      // 设置惯性为无限
  • Physcis Shape:自定义物体包围盒,通常是在预制体的时候设置好。该组件需要在Package ManageUnity PhysicsSamples中下载Custom Physics Authoring

颜色

在烘焙的时候添加URPMaterialPropertyBaseColor组件或者直接在Inpector面板添加

1
2
3
4
5
6
7
8
var teamColor = mobaTeam.Value switch
{
TeamType.Blue => new float4(0, 0, 1, 1),
TeamType.Red => new float4(1, 0, 0, 1),
_ => new float4(1)
};

ecb.SetComponent(newCharacterEntity, new URPMaterialPropertyBaseColor{ Value = teamColor }); // 设置颜色

输入

  • 客户端唯一拥有的权限就是输入
  • 但判断你输入的内容是否有效合法,决定权还是在服务器
  • 输入数据是客户端和服务器之间的非常频繁传递的数据
  • 有一个专门用来传递的组件IInputComponentData,比ICommandData要好用

ICommandData

  • 如果需要频繁的从客户端向服务器发送数据应该使用ICommandData而不是RPCICommandData有优化

  • 必须是从客户端发送到服务器。客户端只需要执行赋值操作,unity会自动将数据同步到服务器。用来控制实体的命令,或是保存状态,如技能CD、伤害等凡是与时间相关的

  • 类似于一个Dynamic Buffer动态缓冲器,他将保存最后64NetWorkTick的数据

  • 默认情况下不会从服务器复制到所有客户端,需要使用GhostComponent属性设置(例如:技能恢复时间,其他玩家不需要知道你的技能什么时候恢复)。因为ICommandData的工作方式,不建议设置为SendToOwnerType.SendToOwner,将被视为错误并并忽略

1
2
3
4
public struct DamageThisTick : ICommandData{
public NetworkTick Tick {get; set;}
public int Value
}

通过测试发现:

  • 在计算并发送数据前可以使用if (state.WorldUnmanaged.IsServer()) continue;,从而提高服务器性能。当然就算没有使用这个,功能也能照常正常使用
  • .AddCommandData()添加数据后,客户端会得到一组数据,但服务端会得到四十多个数据(但系统其实也只是判断了一次,因为他们都有相同的Tick),但并不影响结果。unity这么做的原因应该是担心服务器没有接受到数据

IInputComponentData

“继承”IComponentData,且不需要设置Tick,系统自动完成

InputEvent是存储在IInputComponentData中的输入事件,一般用来传递按钮事件。服务器一般为 60帧,如果客户端为120帧,服务器很有可能检测不到玩家的输入,InputEvent针对这个问题进行的优化。

使用方法

从玩家输入到游戏中显示效果,需要两个系统来实现效果,拿移动来举例

  • MoveInputSystem:在客户端上运行,获取玩家输入
    • 添加到GhostInputSystemGroup中,该Group只存在于Client World中
    • 需要监听输入,并将输入转换成IInputComponentData组件上的数据,存放在该客户端GhostOwnerIsLocal的Ghost Entity上
    • unity会自动同步该数据,即使你没有使用[GhostField]修饰数据
  • MoveSystem:同时在客户端和服务端上运行,执行玩家的命令
    • 添加到PredictedSimulationSystemGroup中,客户端和服务端都会运行,并且会模拟预测数据
    • unity将数据从客户端同步到服务端之后,就可以捕获到数据并执行命令了
传递普通数据:

设置需要传递的数据

1
2
3
4
5
6
// [GhostComponent(PrefabType = GhostPrefabType.AllPredicted)]		// 可以优化需要传递的对象
public struct ChampMoveTargetPosition : IInputComponentData
{
// [GhostField(Quantization = 0)] // 设置精度,0表示全精度;1表示整数;10表示0.1f
public float3 Value; // IInputComponentData会自动将自身的数据修饰为[GhostField]
}

将system添加到GhostInputSystemGroup中并。然后设置数值,系统会自动帮我们实现同步

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
[UpdateInGroup(typeof(GhostInputSystemGroup))]  // 添加到输入组,该组只存在于Client中
public partial class ChampMoveInputSystem : SystemBase
{
protected override void OnStartRunning()
{
// 以委托事件的形式监听输出
PlayerInputSystem.Instance.SelectMovePositionEvent += OnSelectMovePosition;
}

protected override void OnStopRunning()
{
PlayerInputSystem.Instance.SelectMovePositionEvent -= OnSelectMovePosition;
}

private void OnSelectMovePosition(InputAction.CallbackContext obj)
{
var champEntity = SystemAPI.GetSingletonEntity<OwnerChampTag>();
EntityManager.SetComponentData(champEntity, new ChampMoveTargetPosition()
{
Value = closestHit.Position // 设置数值,unity会自动将其同步到main entity上
});
}
}
传递InputEvent数据

定义IInputComponentDataInputEvent,并将其烘焙到Entitiy上

1
2
3
4
5
6
// [GhostComponent(PrefabType = GhostPrefabType.AllPredicted)]		// 可以优化需要传递的对象
public struct AbilityInput : IInputComponentData
{
// [GhostField]
public InputEvent AoeAbility; // IInputComponentData会自动将自身的数据修饰为[GhostField]
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
[UpdateInGroup(typeof(GhostInputSystemGroup))]  // 添加到输入组,该组只存在于Client中
public partial class AbilityInputSystem : SystemBase
{
protected override void OnUpdate()
{
var newAbilityInput = new AbilityInput(); // 因为是结构体是值类型,所以不会有GC;

if (PlayerInputSystem.Instance.QKeyWasPressedThisFrame) // 监听输入
{
newAbilityInput.AoeAbility.Set(); // Set()表示加一,即被按下了
}

foreach (var abilityInput in SystemAPI.Query<RefRW<AbilityInput>>())
{
abilityInput.ValueRW = newAbilityInput; // 设置数值,unity会自动将其同步到main entity上
}
}
}

InputEvent类似一个计数器,客户端使用Set()之后。
服务端检测到其大于0,那么就会判定为输入了,同时将其复原成0。
这样就避免了服务器帧率没有客户端高,而引发的监听不到输入的问题了。
InputEvent常用在类似按钮的触发事件,移动摇杆这类常用的移动输入直接使用数值就OK了

这里说个题外话:关于var newAbilityInput = new AbilityInput();

  • 因为结构体是值类型,所以在OnUpdate执行完后会直接释放掉,不涉及垃圾回收(也就是GC)
  • 由于栈的分配效率非常高效,即使OnUpdate每帧都在调用,数据量不大的话也不会对性能有影响

射线碰撞检测

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
private void OnSelectMovePosition()
{
var collisionWorld = SystemAPI.GetSingleton<PhysicsWorldSingleton>().CollisionWorld; // 获取物理系统
var cameraEntity = SystemAPI.GetSingletonEntity<MainCameraTag>();
var mainCamera = EntityManager.GetComponentObject<MainCamera>(cameraEntity).Value; // 获取相机位置

var mousePosition = Input.mousePosition; // 屏幕像素坐标
mousePosition.z = 100f; // 屏幕坐标的Z表示屏幕距离,100表示屏幕前方100米
var worldPosition = mainCamera.ScreenToWorldPoint(mousePosition); // 转换成世界坐标

// 设置射线的起点、终点和碰撞规则
var selectionInput = new RaycastInput()
{
Start = mainCamera.transform.position,
End = worldPosition, // BelongTo射线属于第五层 CollidesWith射线将与第一层碰撞
Filter = new CollisionFilter() { BelongsTo = 1 << 5, CollidesWith = 1 << 0 };
};
if (collisionWorld.CastRay(selectionInput, out RaycastHit closestHit)) // 使用Entity的碰撞世界进行射线检测
{ // RaycastHit看起来和GameObject的长的一样,但其实不是同一个类。但是用法类似
var champEntity = SystemAPI.GetSingletonEntity<OwnerChampTag>();
EntityManager.SetComponentData(champEntity, new ChampMoveTargetPosition()
{
Value = closestHit.Position // 使用碰撞点
});
}
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 球形碰撞
var hits = new NativeList<DistanceHit>(Allocator.TempJob);
if (CollisionWorld.OverlapSphere(transform.Position, targetRadius.Value, ref hits, CollisionFilter))
{
var closestDistance = float.MaxValue;
var closestEntity = Entity.Null;
foreach (var hit in hits)
{
if (hit.Distance < closestDistance) // 只需要距离玩家最近的目标
{
closestDistance = hit.Distance;
closestEntity = hit.Entity;
}
}
targetEntity.Value = closestEntity;
}
hits.Dispose();

销毁

步骤:

  1. 在预制体上烘焙DestoryOnTimer预设物体多久后销毁

  2. 客户端和服务端都会运行的InitializeDestoryOnTimerSystem捕获拥有DestoryOnTimer组件的实体,并计算该实体在多少Tick的时候销毁,将计算的事件存储在DestroyAtTick的Ghost数据上

    • 服务器计算时间不能以deltaTime(不够稳定)或frame(客户端和服务端会不一致)
    • 而是使用ServerTick,说简单点其实频率,默认为60Tick/秒,与普通的帧还不太一样
  3. 客户端和服务端都会运行的DestroyOnTimerSystem捕获拥有DestroyAtTick组件的实体,并判断该实体是否已达到销毁时间,达到销毁时间后挂载DestroyEntityTag标签

    • 该system只是添加标签,并不是直接销毁
    • 为什么不直接销毁?因为凡是涉及到客户端表现的都需要做预测,也就是得放置在PredictedSimulationSystemGroup
  4. 放置在PredictedSimulationSystemGroup(预测组——服务端和客户端都会预测)中的DestroyEntitySystem捕获拥有DestroyEntityTag标签的实体进行处理。暂时没有很好的办法使客户端和服务端同时进行销毁,当前的做法如下:

    • 服务端:直接摧毁
    • 客户端:将物体移动到不可见的位置,并等待服务器同步

    从下图可看出,有一半的次数客户端要晚于服务端摧毁,并且实际运用起来可能不止一半

共涉及两个组件、一个标签、三个系统

1
2
3
4
public struct DestroyAtTick : IComponentData
{
[GhostField] public NetworkTick Value; // 应在服务器的第几Tick销毁
}
1
public struct DestroyEntityTag : IComponentData { }		// 达到了被销毁时间的entitiy

步骤一:预设烘焙

1
2
3
4
public struct DestroyOnTimer : IComponentData
{
public float Value; // 烘焙预设的销毁时间
}

步骤二:计算销毁时间

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 默认客户端和服务端都会执行,计算多少Tick销毁,并添加DestroyAtTick
public partial struct InitializeDestroyOnTimerSystem : ISystem
{
public void OnUpdate(ref SystemState state)
{
var ecb = new EntityCommandBuffer(Allocator.Temp);
// 获取服务器帧率:默认60
var simulationTickRate = NetCodeConfig.Global.ClientServerTickRate.SimulationTickRate;
var currentTick = SystemAPI.GetSingleton<NetworkTime>().ServerTick; // 当前服务器运行多少Tick

foreach (var (destroyOnTimer, entity) in SystemAPI.Query<DestroyOnTimer>().WithNone<DestroyAtTick>()
.WithEntityAccess()) // 捕获被预设为销毁的物体
{
var lifetimeInTicks = (uint)(destroyOnTimer.Value * simulationTickRate);
var targetTick = currentTick;
targetTick.Add(lifetimeInTicks); // 设置该entity应该在服务器的第几帧销毁
ecb.AddComponent(entity, new DestroyAtTick { Value = targetTick });
}

ecb.Playback(state.EntityManager);
}
}

步骤三:判断是否达到销毁Tick

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 默认客户端和服务端都会执行,判断物体是否达到销毁Tick,并添加DestroyEntityTag
public partial struct DestroyOnTimerSystem : ISystem
{
public void OnUpdate(ref SystemState state)
{
// 在结束的时候添加标签
var ecbSingleton = SystemAPI.GetSingleton<EndSimulationEntityCommandBufferSystem.Singleton>();
var ecb = ecbSingleton.CreateCommandBuffer(state.WorldUnmanaged);

var currentTick = SystemAPI.GetSingleton<NetworkTime>().ServerTick;

foreach (var (destroyAtTick, entity) in SystemAPI.Query<DestroyAtTick>().WithAll<Simulate>()
.WithNone<DestroyEntityTag>().WithEntityAccess()) // 捕获有销毁时间的物体
{
if (currentTick.Equals(destroyAtTick.Value) || currentTick.IsNewerThan(destroyAtTick.Value))
ecb.AddComponent<DestroyEntityTag>(entity); // Tick大于等于当前服务器Tick就添加标签
}
}
}

步骤四:处理服务端和客户端

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 预测,客户端和服务端都会预测
[UpdateInGroup(typeof(PredictedSimulationSystemGroup), OrderLast = true)]
public partial struct DestroyEntitySystem : ISystem
{
public void OnUpdate(ref SystemState state)
{
var networkTime = SystemAPI.GetSingleton<NetworkTime>();

// 在开始的时候销毁
var ecbSingleton = SystemAPI.GetSingleton<BeginSimulationEntityCommandBufferSystem.Singleton>();
var ecb = ecbSingleton.CreateCommandBuffer(state.WorldUnmanaged);

foreach (var (transform, entity) in SystemAPI.Query<RefRW<LocalTransform>>()
.WithAll<DestroyEntityTag, Simulate>().WithEntityAccess())
{
if (state.World.IsServer()) // 服务器直接销毁
ecb.DestroyEntity(entity);
else // 客户端移动到不可见位置,等待服务器同步
transform.ValueRW.Position = new float3(999f, 999f, 999f);
}
}
}

伤害

步骤:

  1. 预设烘焙
    • 可造成伤害实体(飞行物、刀剑等):
      • DamageOnTrigger:设置伤害量
      • AlreadyDamagedEntity:存储造成伤害的entity,防止重复造成伤害
    • 可受伤的实体(英雄、防御塔、小兵等):
      • DamageBufferElement:存储伤害值
      • DamageThisTick:记录在服务器的多少帧受到多少伤害,并将伤害应用到实体上
  2. 监听碰撞:使用ITriggerEventsJob监听碰撞,并执行以下处理
    • 将伤害值添加到受击者的DamageBufferElement伤害缓存池中
    • 将受击者添加到攻击者的AlreadyDamagedEntity中,防止重复计算伤害
  3. 计算伤害:捕获DamageBufferElement并将其数值和当前Tick存储在DamageThisTick
  4. 实施伤害:捕获DamageThisTick并判断是否是当前帧,只有是当前帧的伤害才实施

共涉及4个组件和三个系统,所有系统都是在PredictedSimulationSystemGroup预测组上运行的

第一步:预设烘焙

1
2
3
4
public struct DamageOnTrigger : IComponentData
{
public int Value; // 烘焙到可造成伤害的实体上,设置其伤害量
}
1
2
3
4
public struct AlreadyDamagedEntity : IBufferElementData
{
public Entity Value; // 烘焙到可造成伤害的实体上,记录伤害过的实体,避免重复伤害
}
1
2
3
4
5
[GhostComponent(PrefabType = GhostPrefabType.AllPredicted)]
public struct DamageBufferElement : IBufferElementData
{
public int Value; // 烘焙到可受伤的对象上,表示该物体是可受伤的。该组件只是暂存伤害,并不直接应用
}
1
2
3
4
5
6
7
// 发送给除entity控制者以外的所有其他客户端Ghost entity
[GhostComponent(PrefabType = GhostPrefabType.AllPredicted, OwnerSendType = SendToOwnerType.SendToNonOwner)]
public struct DamageThisTick : ICommandData // 烘焙到可受伤的实体上
{
public NetworkTick Tick { get; set; }
public int Value;
}

第二步:监听碰撞

注意:使用的是ITriggerEventsJob而不是普通的IJobEntity

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
[UpdateInGroup(typeof(PhysicsSystemGroup))]     // 涉及到物体碰撞(即客户端表现),所以放在PhysicsSystemGroup之中
[UpdateAfter(typeof(PhysicsSimulationGroup))]
public partial struct DamageOnTriggerSystem : ISystem
{
public void OnUpdate(ref SystemState state)
{
var simulationSingleton = SystemAPI.GetSingleton<SimulationSingleton>();
state.Dependency = job.Schedule(simulationSingleton, state.Dependency);*/
bool isClient = state.World.IsClient();
var ecbSingleton = SystemAPI.GetSingleton<EndSimulationEntityCommandBufferSystem.Singleton>();
var damageOnTriggerJob = new DamageOnTriggerJob
{
DamageOnTriggerLookup = SystemAPI.GetComponentLookup<DamageOnTrigger>(true),
TeamLookup = SystemAPI.GetComponentLookup<MobaTeam>(true),
AlreadyDamagedLookup = SystemAPI.GetBufferLookup<AlreadyDamagedEntity>(true),
DamageBufferLookup = SystemAPI.GetBufferLookup<DamageBufferElement>(true),
ECB = ecbSingleton.CreateCommandBuffer(state.WorldUnmanaged),
isClient = isClient,
};
var simulationSingleton = SystemAPI.GetSingleton<SimulationSingleton>();
state.Dependency = damageOnTriggerJob.Schedule(simulationSingleton, state.Dependency);
}
}
public struct DamageOnTriggerJob : ITriggerEventsJob // 不是普通的IJobEntity
{ // 队伍
[ReadOnly] public ComponentLookup<MobaTeam> TeamLookup;
// 伤害量,该组件是被烘焙在可造成伤害的实体上
[ReadOnly] public ComponentLookup<DamageOnTrigger> DamageOnTriggerLookup;
// 受到多少伤害,该组件是被烘焙在可受到伤害的实体上
[ReadOnly] public BufferLookup<DamageBufferElement> DamageBufferLookup;
// 已经受到过伤害的实体,避免重复伤害
[ReadOnly] public BufferLookup<AlreadyDamagedEntity> AlreadyDamagedLookup;
public EntityCommandBuffer ECB;

public void Execute(TriggerEvent triggerEvent)
{
Entity damageDealingEntity; // 伤害来源,即飞行物
Entity damageReceivingEntity; // 受击者

// 判断两个实体,哪个是伤害来源,哪个是受击者
if (DamageBufferLookup.HasBuffer(triggerEvent.EntityA) &&
DamageOnTriggerLookup.HasComponent(triggerEvent.EntityB))
{
damageReceivingEntity = triggerEvent.EntityA;
damageDealingEntity = triggerEvent.EntityB;
}
else if (DamageOnTriggerLookup.HasComponent(triggerEvent.EntityA) &&
DamageBufferLookup.HasBuffer(triggerEvent.EntityB))
{
damageDealingEntity = triggerEvent.EntityA;
damageReceivingEntity = triggerEvent.EntityB;
}
else return; // 如果碰撞的两个没有伤害组件直接返回

if (TeamLookup.TryGetComponent(damageDealingEntity, out var damageDealingTeam) &&
TeamLookup.TryGetComponent(damageReceivingEntity, out var damageReceivingTeam))
{
if (damageDealingTeam.Value == damageReceivingTeam.Value) return; // 没有友伤
}

var alreadyDamagedBuffer = AlreadyDamagedLookup[damageDealingEntity];

// 这里客户端有一个BUG,由于ECB没有及时的将enitiy添加到队列中,会导致重复添加伤害,但服务端是好的
foreach (AlreadyDamagedEntity alreadyDamagedEntity in alreadyDamagedBuffer)
if (alreadyDamagedEntity.Value.Equals(damageReceivingEntity)) return; // 判断是否已经造成过伤害了

// 将伤害量缓冲到受击者实体上
var damageOnTrigger = DamageOnTriggerLookup[damageDealingEntity];
ECB.AppendToBuffer(damageReceivingEntity, new DamageBufferElement {Value = damageOnTrigger.Value });
// 将受击者保存到伤害来源实体上
ECB.AppendToBuffer(damageDealingEntity, new AlreadyDamagedEntity { Value = damageReceivingEntity });
}
}

第三步:计算伤害

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
[UpdateInGroup(typeof(PredictedSimulationSystemGroup), OrderLast = true)]
public partial struct CalculateFrameDamageSystem : ISystem
{
public void OnUpdate(ref SystemState state)
{
var currentTick = SystemAPI.GetSingleton<NetworkTime>().ServerTick;

foreach (var (damageBuffer, damageThisTickBuffer) in SystemAPI
.Query<DynamicBuffer<DamageBufferElement>, DynamicBuffer<DamageThisTick>>()
.WithAll<Simulate>())
{
if (damageBuffer.IsEmpty) // 如果为空
{
// 配合下方的GetDataAtTick方法,需要不停的赋值
damageThisTickBuffer.AddCommandData(new DamageThisTick { Tick = currentTick, Value = 0 });
}
else
{
var totalDamage = 0;
// 获取最靠近当前帧的上一帧的伤害,因为上面一直在设置为0,所以大多数这里捕获的值都是0
// TODO: 讲道理,并不知道这么做有什么用?
if (damageThisTickBuffer.GetDataAtTick(currentTick, out var damageThisTick))
{
totalDamage = damageThisTick.Value; // 加上最近一帧的伤害
}

foreach (var damage in damageBuffer)
{
totalDamage += damage.Value; // 累加缓冲器中的伤害值
}

// 将伤害
damageThisTickBuffer.AddCommandData(new DamageThisTick
{ Tick = currentTick, Value = totalDamage });
damageBuffer.Clear();
}
}
}
}

第四步:实施伤害

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
[UpdateInGroup(typeof(PredictedSimulationSystemGroup), OrderLast = true)]
[UpdateAfter(typeof(CalculateFrameDamageSystem))] // 在计算伤害量之后执行
public partial struct ApplyDamageSystem : ISystem
{
public void OnUpdate(ref SystemState state)
{
var currentTick = SystemAPI.GetSingleton<NetworkTime>().ServerTick;
var ecb = new EntityCommandBuffer(Allocator.Temp);

foreach (var (currentHitPoints, damageThisTickBuffer, entity) in SystemAPI
.Query<RefRW<CurrentHitPoints>, DynamicBuffer<DamageThisTick>>().WithAll<Simulate>()
.WithEntityAccess())
{
if (!damageThisTickBuffer.GetDataAtTick(currentTick, out DamageThisTick damageThisTick)) continue;
// 过滤掉不是当前帧的伤害,因为本系统是在计算伤害系统之后运行的,所以这个是可行的
if (damageThisTick.Tick != currentTick) continue;
currentHitPoints.ValueRW.Value -= damageThisTick.Value;

if (currentHitPoints.ValueRO.Value <= 0)
{
ecb.AddComponent<DestroyEntityTag>(entity); // 如果血量低于0就添加销毁标签
}
}
ecb.Playback(state.EntityManager);
}
}

技能CD

在释放技能前判断是否达到CD

1
2
3
4
public struct AbilityCooldownTicks : IComponentData
{
public uint AoeAbility; // 烘焙预设技能CD
}
1
2
3
4
5
6
[GhostComponent(PrefabType = GhostPrefabType.AllPredicted)]
public struct AbilityCooldownTargetTicks : ICommandData
{
public NetworkTick Tick { get; set; }
public NetworkTick AoeAbility; // 存储技能在多少Tick结束CD
}

为了避免服务器跳帧,还需要将前几Tick的数据来比较

下面的代码可能会让人很困惑,为什么要多此两举的,既要将释放技能的时机向后移动1Tick,又要在释放技能的时候把前几Tick的数据来做比较。但这种方法是经过社区测试,而得出的一个相对较好的判断技能CD的方式。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
var isOnCooldown = true;
var curTargetTicks = new AbilityCooldownTargetTicks();

// networkTime.SimulationStepBatchSize表示务器Tick步长,通常为1(即正常状态)
// 使用for循环的原因是为了避免服务器卡顿,而跳过了cd检测,进而永远都无法解除冷却了
for (var i = 0u; i < networkTime.SimulationStepBatchSize; i++)
{
var testTick = currentTick;
testTick.Subtract(i);


if (!aoe.CooldownTargetTicks.GetDataAtTick(testTick, out curTargetTicks)) // 获取上一次释放技能的Tick
{
curTargetTicks.AoeAbility = NetworkTick.Invalid; // 是第一次释放技能,就设置为无效
}

// 如果值无效 或者 达到目标Tick,就说明CD好了
if (curTargetTicks.AoeAbility == NetworkTick.Invalid || // 无效,即第一次释放技能
!curTargetTicks.AoeAbility.IsNewerThan(currentTick)) // 当前Tick大于目标Tick,说明CD过了
{
isOnCooldown = false;
break;
}
}

if (isOnCooldown) continue;


if (输入检测)
{
// (技能释放逻辑)...

// ICommandData只用在客户端做处理,unity会自动将数据发送给服务端
if (state.WorldUnmanaged.IsServer()) continue;
var newCooldownTargetTick = currentTick;
newCooldownTargetTick.Add(aoe.CooldownTicks);
curTargetTicks.AoeAbility = newCooldownTargetTick;

var nextTick = currentTick; // 为什么要记录到下一个Tick:
nextTick.Add(1u); // 当在添加ICommandData的时候,我们需要将他的执行的Tick+1
curTargetTicks.Tick = nextTick; // 要不然会导致,客户端释放了技能,但是服务端并没有同步
// 虽然看起来很怪,但这确实是可行的
aoe.CooldownTargetTicks.AddCommandData(curTargetTicks);
}

UI

ICleanupComponentData

与普通的组件类似,但有一些特殊规则

  • 无法烘焙
  • 当你Destroy包含cleanup的entity时,并不会真正的删除他,而是会移除掉他身上所有非cleanup的组件。除非你将cleanup也移除,才能真正的destroy该netity

通常用在不同系统的销毁处理上,比如角色和血条他们两并不是父子关系(一个在Entity,一个在GameObject),而是引用关系

  • 未使用cleanup:在销毁掉角色时并不会销毁掉血条,反而移除掉了血条的引用,使我们无法找到血条的引用

  • 使用cleanup:在删除掉角色时,血条的组件还是会被保存下来,血条system就可以根据这个引用找到并删除掉血条了

除非我们在删除entity前将血条也删除了,但是这样代码不易维护,并且该entity并不一定有血条

血条系统

组件
1
2
3
4
public struct HealthBarOffset : IComponentData
{
public float3 Value; // 预设烘焙血条相对entity的位置偏移
}
烘焙

每个可受伤的角色都烘焙一个位置偏差值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class HealthBarUIReference : ICleanupComponentData		// 无法烘焙
{ // 注意,因为我们的数据是引用类型,不是值类型,所以组件也只能是class
public GameObject Value; // 保存GameObject世界中的血条的引用
}
public class HitPointAuthoring : MonoBehaviour // 可以和之前的受伤烘焙写在一起
{
public int maxHitPoints;
public Vector3 healthBarOffset;

private class Baker : Baker<HitPointAuthoring>
{
public override void Bake(HitPointAuthoring authoring)
{
var entity = GetEntity(TransformUsageFlags.Dynamic);
AddComponent(entity, new CurrentHitPoints{Value = authoring.maxHitPoints});
AddComponent(entity, new MaxHitPoints{Value = authoring.maxHitPoints});
AddComponent<DamageBufferElement>(entity);
AddComponent<DamageThisTick>(entity);
AddComponent(entity, new HealthBarOffset() { Value = authoring.healthBarOffset }); // 位置偏移
}
}
}

直接在场景创建一个单例,保存血条预制体,方便我们创建血条

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class UIPrefabs : IComponentData
{ // 注意,因为我们的数据是引用类型,不是值类型,所以组件也只能是class
public GameObject HealthBar; // 保存预制体,方便我们创建血条
}

public GameObject healthBarPrefab;

private class Baker : Baker<MobaPrefabsAuthoring>
{
public override void Bake(MobaPrefabsAuthoring authoring)
{
var prefabContainerEntity = GetEntity(TransformUsageFlags.None);
AddComponentObject(prefabContainerEntity, new UIPrefabs() // 注意:这里使用的是AddComponentObject
{
HealthBar = authoring.healthBarPrefab
});
}
}
  • HealthBarUIReference:场景中每个可受伤的entity都有用一个该组件
  • UIPrefabs:该组件整个场景中只有一个,保存UI的预制体方便创建UI
系统
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
[UpdateAfter(typeof(TransformSystemGroup))]   		// 在玩家移动后的进行位置设置
[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)]
public partial struct HealthBarSystem : ISystem
{
public void OnUpdate(ref SystemState state)
{
var ecbSingleton = SystemAPI.GetSingleton<EndSimulationEntityCommandBufferSystem.Singleton>();
var ecb = ecbSingleton.CreateCommandBuffer(state.WorldUnmanaged);

// 添加血条
foreach (var (transform, healthBarOffset, maxHitPoints, entity) in SystemAPI.Query<LocalTransform, HealthBarOffset, MaxHitPoints>()
.WithNone<HealthBarUIReference>().WithEntityAccess())
{
// 使用ManagedAPI可以获取到class组件
var healthBarPrefab = SystemAPI.ManagedAPI.GetSingleton<UIPrefabs>().HealthBar;
var spawnPosition = transform.Position + healthBarOffset.Value;
// Object.Instantiate是创造在GameObject中,而不是Entity中
var newHealthBar = Object.Instantiate(healthBarPrefab, spawnPosition, quaternion.identity);
SetHealthBar(newHealthBar, maxHitPoints.Value, maxHitPoints.Value);
// 将生成的血条的引用添加到Entity上,注意:这里添加的并不是本身而是引用。
// 也就是说在摧毁掉entity的时候并不会将血条也摧毁掉
ecb.AddComponent(entity, new HealthBarUIReference() { Value = newHealthBar });
}

// 更新血条的位置和值
foreach (var (transform, healthBarOffset, currentHitPoints, maxHitPoints, healthBarUI) in SystemAPI
.Query<LocalTransform, HealthBarOffset, CurrentHitPoints, MaxHitPoints, HealthBarUIReference>())
{
var healthBarPosition = transform.Position + healthBarOffset.Value;
healthBarUI.Value.transform.position = healthBarPosition;
SetHealthBar(healthBarUI.Value, currentHitPoints.Value, maxHitPoints.Value);
}

// 当角色死亡时,移除血条。HealthBarUIReference是cleanup类型,在销毁玩家时,该组件会被保留下来
foreach (var (healthBarUI, entity) in SystemAPI.Query<HealthBarUIReference>()
.WithNone<LocalTransform>().WithEntityAccess())
{
Object.Destroy(healthBarUI.Value);
ecb.RemoveComponent<HealthBarUIReference>(entity);
}
}
// 设置血条进度
private void SetHealthBar(GameObject healthBarCanvasObject, int curHitPoints, int maxHitPoints)
{
var healthBarSlider = healthBarCanvasObject.GetComponentInChildren<Slider>();
healthBarSlider.minValue = 0;
healthBarSlider.maxValue = maxHitPoints;
healthBarSlider.value = curHitPoints;
}
}

技能冷却

使用单例控制图标的进度,这里就展示代码了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)]
public partial struct AbilityCooldownUISystem : ISystem
{
public void OnUpdate(ref SystemState state)
{
var currentTick = SystemAPI.GetSingleton<NetworkTime>().ServerTick;
var abilityCooldownUIController = AbilityCooldownUIController.Instance; // UI控制单例

foreach (var (cooldownTargetTicks, abilityCooldownTicks) in SystemAPI
.Query<DynamicBuffer<AbilityCooldownTargetTicks>, AbilityCooldownTicks>())
{
// 如果是第一次释放技能
if (!cooldownTargetTicks.GetDataAtTick(currentTick, out var curTargetTicks))
{
curTargetTicks.AoeAbility = NetworkTick.Invalid;
curTargetTicks.SkillShotAbility = NetworkTick.Invalid;
}

// Q技能
if (curTargetTicks.AoeAbility == NetworkTick.Invalid || // 未获取到即没有释放过技能
currentTick.IsNewerThan(curTargetTicks.AoeAbility)) // 当前帧大于目标帧
{
abilityCooldownUIController.UpdateAoeMask(0f); // 使用单例中的方法更新Image.fillAmount的值
}
else
{ // TickIndexForValidTick能使计算更准确
var aoeRemainTickCount = curTargetTicks.AoeAbility.TickIndexForValidTick -
currentTick.TickIndexForValidTick;
var fillAmount = (float)aoeRemainTickCount / abilityCooldownTicks.AoeAbility;
abilityCooldownUIController.UpdateAoeMask(fillAmount);
}
}
}
}

退出服务器

在客户端或者GameObject世界执行如下操作

  1. 获取到NetworkStreamConnection实体,并添加NetworkStreamRequestDisconnect组件
  2. 销毁世界,并加载其他场景
1
2
3
4
var networkConnection = SystemAPI.GetSingletonEntity<NetworkStreamConnection>();
SystemAPI.GetComponent<NetworkStreamRequestDisconnect>(networkConnection);
World.DisposeAllWorlds(); // 不要用在服务端了
SceneManager.LoadScene(0);

虚拟客户端

创建

  • [WorldSystemFilter(WorldSystemFilterFlags.ThinClientSimulation)]:只在虚拟客户端运行。如创建一个英雄、设置CommandTarget连接

  • [WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation | WorldSystemFilterFlags.ThinClientSimulation)]:虚拟客户端和普通客户端都要运行。如英雄初始化(设置英雄标签、移动目标位置为自身脚下)

  1. 虚拟客户端创建假人,并表名自己选择的队伍,只需要最低限度的添加需要的组件。这一步类似客户端:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
       
    [WorldSystemFilter(WorldSystemFilterFlags.ThinClientSimulation)]
    public partial struct ThinClientEntrySystem : ISystem
    {
    public void OnUpdate(ref SystemState state)
    {
    state.Enabled = false; // 只需要创建一次
    var thinClientDummy = state.EntityManager.CreateEntity(); // 创建一个假人entity
    // 添加移动组件 "ChampMoveSystem" 需要使用
    state.EntityManager.AddComponent<ChampMoveTargetPosition>(thinClientDummy);
    // 在移动组件中添加一个数据,告诉假人要移动到哪里
    state.EntityManager.AddBuffer<InputBufferData<ChampMoveTargetPosition>>(thinClientDummy);

    // 随机选择一个队伍,再由"ClientRequestGameEntrySystem"捕获,向服务器发送进入游戏请求
    var thinClientRequestEntity = state.EntityManager.CreateEntity();
    state.EntityManager.AddComponentData(thinClientRequestEntity, new ClientTeamSelect
    {
    Value = TeamType.AutoAssign
    });
    }
    }
  2. 客户端向服务器发送进入游戏请求,这一步普通客户端和虚拟客户端都需要使用,详情见客户端向服务器发送进入游戏请求

  3. 服务端在接收到请求并创建服务端实体,这一步是服务器完成的

如此一来,就能在场景中生成虚拟客户端了

控制

在测试的时候发现,我们修改了虚拟客户端的ChampMoveTargetPosition,但是并不起作用(因为修改的内容并没有传递给服务器)。如何能一直控制他们移动呢,unity为我们在NetworkConnection上准备了CommandTarget组件。

  1. 创建第一步创建假人的最后设置CommandTarget组件,并添加移动用的组件

    1
    2
    3
    4
    5
    6
    7
    8
    9
    public struct ThinClientInputProperties : IComponentData
    {
    public Random Random; // 移动随机数
    public float Timer; // 移动计时器
    public float MinTimer; // 停留的最小时间
    public float MaxTimer; // 停留的最大时间
    public float3 MinPosition; // 移动的最小范围
    public float3 MaxPosition; // 移动的最大范围
    }
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    var connectionEntity = SystemAPI.GetSingletonEntity<NetworkId>();       // 获取连接id
    // 设置命令目标
    SystemAPI.SetComponent(connectionEntity, new CommandTarget { targetEntity = thinClientDummy });

    var connectionId = SystemAPI.GetSingleton<NetworkId>().Value;
    // 添加GhostOwner
    state.EntityManager.AddComponentData(thinClientDummy, new GhostOwner { NetworkId = connectionId });

    state.EntityManager.AddComponentData(thinClientDummy, new ThinClientInputProperties
    {
    Random = Random.CreateFromIndex((uint)connectionId),
    Timer = 0f,
    MinTimer = 1f,
    MaxTimer = 10f,
    MinPosition = new float3(-50f, 0f, -50f),
    MaxPosition = new float3(50f, 0f, 50f)
    });
  2. 在第三步的时候将服务器这边虚拟客户端控制的英雄实体赋给他自己的NetworkConnection的CommandTarget上

    1
    2
    3
    // ... 创建服务端英雄实体
    ecb.SetComponent(requestSource.SourceConnection, new CommandTarget { targetEntity = newChamp });
    // ...验证是否满足开始游戏条件

由于这个比较重要,而且不宜理解,所以这里单独从第二篇文章中单独拎出来展示。

关于Tick和IsFirstTimeFullyPredictingTick

参考Full vs. Partial Ticks

  • Tick是固定一秒60次,并不是update执行的次数。由于服务器没反应过来,服务器update的次数一般都会比Tick次数少,客户端反应比较快一半会比60大。

    • 服务端会将数据以Tick的频率发送给客户端,而客户端会平滑的设置其数值,有如下两种情景

      • 数值:服务端告诉客户端第20TickValue = 10,那么客户端在19Tick(假设Value=0)与20Tick之间会多次执行update,让Value从0平滑的到达10。假如客户端一个Tick可以运行10次,那么value第一次update为0,第二次update为1,第三次update为3….直到完整的到达第20Tick,Value就等于10了。

      • 事件:服务端告诉客户端第20Tick的时候创建一个小兵,那么在19Tick与20Tick之间,每次update都会创建一个小兵

        这时就需要用到IsFirstTimeFullyPredictingTick了,在19到20Tick之间updateIsFirstTimeFullyPredictingTick返回false;当你真正达到20Tick,IsFirstTimeFullyPredictingTick返回True,表示这是第一次完整的预测Tick

      假设有代码如下:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      // [UpdateInGroup(typeof(PredictedSimulationSystemGroup))]	system在模拟组中

      var networkTime = SystemAPI.GetSingleton<NetworkTime>();
      if (networkTime.IsFirstTimeFullyPredictingTick)
      {
      ecb.生成小兵(); // 只有在20Tick的时候才会执行
      }
      Value = 10; // 在18到20Tick之间,客户端每次update都将让Value更接近10
      // 虽然这里写的是直接赋值,但客户端并不会生硬的将Value设置为10

  • 服务端与客户端的Tick并不同步。这应该就是延迟的由来?

测试代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public partial struct TestSystem : ISystem
{
public void OnUpdate(ref SystemState state)
{
var isServer = state.WorldUnmanaged.IsServer();
if (isServer)
{
var networkTime = SystemAPI.GetSingleton<NetworkTime>();
Debug.Log($"Server: Tick: {networkTime.ServerTick}, ElapsedTime: {SystemAPI.Time.ElapsedTime}");
Debug.Log("Server: Update time");
}
else
{
var networkTime = SystemAPI.GetSingleton<NetworkTime>();
Debug.Log($"Client: Tick: {networkTime.ServerTick}, ElapsedTime: {SystemAPI.Time.ElapsedTime}");
Debug.Log("Client: Update time");
}
}
}

优势

协程

  1. 协程是与MonoBehaviour绑定的,如果没有继承自MonoBehaviour就无法使用
  2. 协程会产生很多GC,每次调用 yield return 都会创建一个 IEnumerator 对象。如果使用yield return 0yield return new WaitForSeconds(2f)还会出现装箱的操作
  3. Unity 每帧会检查所有正在运行的协程并执行,频繁的上下文切换可能会影响性能
  4. 无法通过try/catch捕获异常
  5. 协程是跟随UnityObject生命周期的,GameObject被销毁后协程也会停止
  6. 停止协程比较麻烦
  7. 不支持返回值

原生 async/await

  1. 每个 Task 都是一个类对象,它在创建时会分配堆内存
  2. 无法支持WebGL
  3. 在部分情况下无法捕获异常
  4. 不支持返回值

UniTask

  1. UniTask是针对Unity的异步编程,支持WebGL
  2. 主要通过结构体来管理异步,避免内存分配和GC压力
  3. 轻松捕获异常
  4. 便利的停止异步任务
  5. 支持返回值

UniTaskTracker

1
2
3
4
5
6
7
8
9
10
11
void start()
{
ExampleUniTask().Forget();
}
async UniTask ExampleUniTask()
{
for (int i = 0; i < 100; i++)
{
await UniTask.Delay(50);
}
}

如果没有按照正确方式使用AsyncUniTask不会消失,例如将上面的代码改成:

1
2
3
4
void start()
{
ExampleUniTask();
}

正确使用的两种方式:

  • 使用await修饰:后面的代码会等待执行完毕后再执行
  • 使用.Forget():不会等待,直接执行后面的代码

Status

  • Succeeded:执行完毕
  • Canceled:被CancellationTokenSource取消

WhenAllWhenAny

他们两个最主要的区别就是什么时候结束等待,开始执行后面的代码

1
2
3
4
5
6
await UniTask.WhenAll(
ExampleUniTask(bar1, 50),
ExampleUniTask(bar2, 100),
ExampleUniTask(bar3, 100)
);
Debug.Log("All tasks completed");

也可以用来控制取消任务

1
2
3
4
5
6
7
await UniTask.WhenAny(
ExampleUniTask(bar1, cts.Token),
ExampleAsync(bar2, cts.Token).AsUniTask(),
ExampleCoroutine(bar3).WithCancellation(cts.Token)
);
cts.Cancel();
Debug.Log("All tasks completed");

使进程跟随GameObject生命周期

就像前面优势说的,UniTask即使在物体被摧毁后也能执行,那么如何控制UniTask跟随GameObject生命周期呢

方法一:

1
2
3
4
5
6
private async void Start()
{
var token = this.GetCancellationTokenOnDestroy();

await ExampleUniTask(bar1, token);
}

方法二:

1
2
3
4
5
6
7
8
9
10
private CancellationTokenSource _cts = new CancellationTokenSource(); 

private async void Start()
{
await ExampleUniTask(bar1, _cts.Token);
}
private void OnDestroy()
{
_cts?.Cancel();
}

返回值

无返回值

如果不在意返回的内容可以使用UniTaskVoid

当然,这样就不能使用await修饰了,更不能加入到WhenAllWhenAny

1
2
3
4
5
6
7
8
9
async UniTaskVoid ExampleUniTask(CancellationToken token)
{
for (int i = 0; i < 100; i++)
{
token.ThrowIfCancellationRequested();
await UniTask.Delay(50);
Debug.Log(i);
}
}

有返回值

如果需要返回值,必须使用await修饰

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
void Start()
{
var (a, b, c) = await UniTask.WhenAll(
DelayedValueAsync(1, 5),
DelayedValueAsync(2, 10),
DelayedValueAsync(3, 20)
);
Debug.Log($"{a}, {b}, {c}");
}

async UniTask<int> DelayedValueAsync(int value, int delayFrames)
{
await UniTask.DelayFrame(delayFrames);
await UniTask.Yield(PlayerLoopTiming.FixedUpdate);
return value * 2;
}

其他

线程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
private async void Start()
{
Debug.Log($"主线程ID: {Thread.CurrentThread.ManagedThreadId}");
using (var cts = new CancellationTokenSource())
{
ExampleUniTask(bar1, cts.Token).Forget();
ExampleAsync(bar2, cts.Token);
StartCoroutine(ExampleCoroutine(bar3));
}
}

async UniTask ExampleUniTask(HealthScrollbar healthScrollbar, CancellationToken token)
{
Debug.Log($"ExampleUniTask线程ID: {Thread.CurrentThread.ManagedThreadId}");
for (int i = 0; i < 100; i++)
{
token.ThrowIfCancellationRequested();
await UniTask.Delay(50, cancellationToken: token);
healthScrollbar.Number++;
}
}

async Task ExampleAsync(HealthScrollbar healthScrollbar, CancellationToken token)
{
Debug.Log($"ExampleAsync线程ID: {Thread.CurrentThread.ManagedThreadId}");
for (int i = 0; i < 100; i++)
{
token.ThrowIfCancellationRequested();
await Task.Delay(50, token);
healthScrollbar.Number++;
}
}

IEnumerator ExampleCoroutine(HealthScrollbar healthScrollbar, CancellationToken token)
{
Debug.Log($"ExampleCoroutine线程ID: {System.Threading.Thread.CurrentThread.ManagedThreadId}");
for (int i = 0; i < 100; i++)
{
yield return new WaitForSeconds(0.05f);
healthScrollbar.Number++;
}
}

Unity是单线程引擎,Unity 的大部分 API 都是必须在主线程(也称为 Unity 线程)上运行的,包括游戏对象的更新、UI 操作和物理引擎等。因此,无论是 CoroutineUniTask 还是 Task,如果它们没有显式地切换到其他线程,默认都会在主线程上运行

1
2
3
4
5
6
7
8
9
private async void Start()
{
Debug.Log($"主线程ID: {Thread.CurrentThread.ManagedThreadId}");
int result = await Task.Run(() =>
{
Debug.Log($"ExpensiveCalculation线程ID: {Thread.CurrentThread.ManagedThreadId}");
return ExpensiveCalculation();
});
}

Task.Run是专门设计在后台线程上执行任务,而不是主线程。主线程在执行 await 之后,会暂时释放控制权,等待任务完成,而任务则在后台线程上执行。

将其他方法转换成UniTask方法

1
2
3
4
5
6
7
8
using (var cts = new CancellationTokenSource())
{
await UniTask.WhenAll(
ExampleUniTask(bar1, cts.Token),
ExampleAsync(bar2, cts.Token).AsUniTask(),
ExampleCoroutine(bar3).WithCancellation(cts.Token)
);
}

将UniTask转换成协程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
IEnumerator ExampleCoroutine(HealthScrollbar healthScrollbar)
{
yield return ExampleUniTask(_cts.Token).ToCoroutine();
}

async UniTask ExampleUniTask(CancellationToken token)
{
for (int i = 0; i < 100; i++)
{
token.ThrowIfCancellationRequested();
await UniTask.Delay(50);
Debug.Log(i);
}
}

安装

创建一个Unity项目

包体

使用Add the package by its name添加以下模块

  • com.unity.entities:包本体
  • com.unity.entities.graphics:可使用脚本渲染管道(SRP)渲染实体
  • com.unity.physics:实体的状态和物理系统,还需要下载Samples中的Custom Physics Authoring

可选:

  • com.unity.netcode:网络,多人游戏使用

环境

需要注意的是,IDE只能使用以下两种

  • Visual Studio 2022+
  • Rider 2021.3.3+

版本

  • Entities:1.2.4
  • Entities Graphics:1.2.4
  • Unity Physics:1.2.4

注意事项

IComponentData

  • 由于ECS中的C是结构体,在引用其内部数据的时候要注意引用的是其本体还是复制副本(经过学习后发现如果使用的方法正确,基本上不会碰到这个问题,Unity已经帮我们封装好了)
  • System绑定的依赖是IComponentData而不是Entity
  • Aspect绑定的才是Entity

Build

在build之前,执行以下操作确保Build成功

  1. 关闭Entity子场景,这样在editor中将优先加载子场景。运行游戏时可能会遇到报错,例如在OnCreate()OnUpdate()中获取单例就会报错
    • 原因:游戏在第一帧时子场景还没有加载完,所以单例不存在
    • 解决方法:在OnCreate()中添加RequireForUpdate<T>
    • 如果不希望每帧都调用单例,可以在OnStartRunning()中调用单例,这方法只适用SystemBase,因为ISystem没有该方法(除了单例,如果是确定个数的实体也适用该方法)
  2. Resolve Loading Entity Scene Failed errors,解决加载Entity Scene失败错误
    • 貌似是Unity的Bug
    • 可以重启Unity Editor,清除实体缓存。Edit-Preferences-Entities-ClearEntityCache
  3. 保存主场景和子场景

放在Entities中的子场景可以不用放在Scenes In Builde

System的生命周期

一般情况下可以查看Systems窗口查看执行顺序,但如果使用了缓冲器就得注意顺序了

1
2
var ecb = SystemAPI.GetSingleton<EndInitializationEntityCommandBufferSystem.Singleton>().CreateCommandBuffer(state.WorldUnmanaged);		// 在初始化组结束的时候执行
ecb.RemoveComponent<NewEnemyTag>(entity);
1
2
3
var ecb = new EntityCommandBuffer(Allocator.Temp);
ecb.RemoveComponent<NewEnemyTag>(entity);
ecb.Playback(state.EntityManager); // 立即执行

示例:控制角色移动

场景搭建

  1. 创建子场景

  2. 创建Entities对象

脚本

  • Component脚本:存放数据

    1
    2
    3
    4
    public struct Speed : IComponentData		// 存储角色移动速度
    {
    public float value;
    }
    1
    2
    3
    4
    public struct TargetPosition : IComponentData		// 存储目标位置
    {
    public float3 value;
    }
  • Entities脚本:挂载到Entities上,将数据传递给Entities

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    public class SpeedAuthoring : MonoBehaviour
    {
    public float value;

    public class SpeedBaker : Baker<SpeedAuthoring>
    {
    // Debug.Log("Bake SpeedAuthoring");
    public override void Bake(SpeedAuthoring authoring)
    {
    var entity = GetEntity(TransformUsageFlags.Dynamic);
    var data = new Speed
    {
    value = authoring.value
    };
    AddComponent(entity, data);
    }
    }
    }
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    public class TargetPositionAuthoring : MonoBehaviour
    {
    public float3 value;

    public class Baker : Baker<TargetPositionAuthoring>
    {
    public override void Bake(TargetPositionAuthoring authoring)
    {
    var entity = GetEntity(TransformUsageFlags.Dynamic);
    var data = new TargetPosition
    {
    value = authoring.value
    };
    AddComponent(entity, data);
    }
    }
    }

    注意一下烘焙时机,虽然可能不会用到

  • System脚本:执行游戏逻辑

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    public partial class MovingSystemBase : SystemBase
    {
    protected override void OnUpdate()
    {
    foreach (var (localTransform, speed, targetPosition) in SystemAPI.Query<RefRW<LocalTransform>, RefRW<Speed>, RefRW<TargetPosition>>())
    {
    var direction = math.normalize(targetPosition.ValueRW.value - localTransform.ValueRW.Position);
    localTransform.ValueRW.Position += direction * SystemAPI.Time.DeltaTime * speed.ValueRO.value;
    }
    }
    }

窗口介绍

  • Systems

    • Entity Count:该system涉及到的实体,如:

      • 符合ISystem.IJobEntity.Execute()参数IComponentDataIAspect条件的实体

      • 在该system中使用过该实体的方法,如

        1
        2
        var entityCommandBuffer = SystemAPI.GetSingleton<BeginSimulationEntityCommandBufferSystem.Singleton>()
        .CreateCommandBuffer(World.Unmanaged);


System

IAspect

上面的System脚本是否看起来十分臃肿,可以使用IAspect将下达命令的System与执行逻辑分开。

除了能解耦执行逻辑外,还可以很清楚区分开该实体是否需要处理此执行逻辑。如下面的MoveToPositionAspect使用了LocalTransformSpeedTargetPosition三个标签Component,那么所有拥有这三个的实体就执行此逻辑,少了任何一个都无法执行

  • IAspect同样是结构体,在使用时需要注意值类型和引用类型的问题

  • IAspect的Component字段只能是RefRWRefROEnabledRefRWEnabledRefRODynamicBufferEntity
    Ref正如关键字ref一样,直接引用其地址,而不是复制,这样就能避免值类型和引用类型的问题

  • 可以将IAspect看做是特殊的IComponentData,所有拥有其定义的Ref字段的实体就拥有该IAspect

1
2
3
4
5
6
7
8
9
10
11
12
public readonly partial struct MoveToPositionAspect : IAspect
{
readonly RefRW<LocalTransform> localTransform; // 可读可写
readonly RefRO<Speed> speed; // 只读
readonly RefRW<TargetPosition> targetPosition;

public void Move(float deltaTime)
{
var direction = math.normalize(targetPosition.ValueRW.value - localTransform.ValueRW.Position);
localTransform.ValueRW.Position += direction * deltaTime * speed.ValueRO.value;
}
}

将上面的MovingSystemBase修改为:

1
2
3
4
5
6
7
8
9
10
public partial class MovingSystemBase : SystemBase
{
protected override void OnUpdate()
{
foreach (MoveToPositionAspect aspect in SystemAPI.Query<MoveToPositionAspect>())
{
aspect.Move(SystemAPI.Time.DeltaTime);
}
}
}

需要注意的是,SystemAPI只能在System中使用,所以只能将DeltaTime当做参数传递过去

ISystem

除了上面的SystemBase外还可以使用ISystem实现,ISystem更加轻量

因为是结构体,所以速度更快,但是和Component同样在使用时需要注意值类型和引用类型的问题

1
2
3
4
5
6
7
8
9
10
11
12
public partial struct MovingSystem : ISystem
{
public void OnCreate(ref SystemState state) { }
public void OnDestroy(ref SystemState state) { }
public void OnUpdate(ref SystemState state) // 与上面的SystemBase代码完全相同
{
foreach (MoveToPositionAspect aspect in SystemAPI.Query<MoveToPositionAspect>())
{
aspect.Move(SystemAPI.Time.DeltaTime);
}
}
}
  • 实体很多就使用ISystem
  • 不用在性能表现就使用SystemBase

IJobEntity异步Burst编译

将逻辑放在其他线程上,不阻塞主线程,引入IJobEntity的结构体

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
[BurstCompile]
public partial struct MovingISystem : ISystem
{
[BurstCompile]
public void OnCreate(ref SystemState state) { }
[BurstCompile]
public void OnDestroy(ref SystemState state) { }
[BurstCompile]
public void OnUpdate(ref SystemState state)
{
// foreach (MoveToPositionAspect aspect in SystemAPI.Query<MoveToPositionAspect>()) // 需要删除掉遍历
// {
var deltaTime = SystemAPI.Time.DeltaTime;
// aspect.Move(deltaTime);

Debug.Log($"MovingISystem ThreadId: {System.Threading.Thread.CurrentThread.ManagedThreadId}");

JobHandle jobHandle = new MoveJob()
{
deltaTime = SystemAPI.Time.DeltaTime
}.ScheduleParallel(state.Dependency); // 异步并行,调到其他线程上执行

jobHandle.Complete(); // 等待所有任务完成,再执行后面的代码,类似await,也就是说会在主线程上运行

// Do Something...
// }
}
}

[BurstCompile]
public partial struct MoveJob : IJobEntity
{
[NativeDisableUnsafePtrRestriction]
public RefRO<Speed> speed; // 在IJobEntity中调用`RefRo<>`或`RefRW<>`时会报错:"不允许使用非安全指针,可能会崩溃"。如果你知道自己在做什么并且没有乱用指针可以使用属性关闭报错
public float deltaTime;

// 通过该方法的虚参来获取符合条件的实体,所以上面的foreach遍历是多余的
[BurstCompile]
public void Execute(MoveToPositionAspect aspect)
{
Debug.Log($"MoveJob ThreadId: {System.Threading.Thread.CurrentThread.ManagedThreadId}");
aspect.Move(deltaTime);
}
}
线程ID

按照上面的代码运行了很多次,都是在4线程,说明ScheduleParallel并不是随机分配线程ID

但是也有一个奇怪的现象,如果注释掉主线程的Debug.Log(),MoveJob ThreadId不止为4
原因应该与UnityEngine方法只能在主线程上执行有关,Debug.Log()UnityEngine(Unity可能对该方法做了特殊处理,所以可以在其他线程上运行,但是输出的内容就不能考究了,尤其是涉及了线程)
并且线程ID并不重要,所以就不深究了

调度方法

IJobEntity.Run():立即在当前线程上执行,完成后再进入下一个System,并不一定是在主线程

IJobEntity.Schedule():单线程调度。将job调度到一个工作线程上,但不是并行的。所有符合查询条件的实体会按照顺序在同一个线程上依次处理。线程安全不用担心数据竞争或冲突,适合不需要复杂的并行计算,或者实体数量较少时使用。

IJobEntity.ScheduleParallel():并行调度。将job调度为并行执行的任务。会拆分为多个批次,并在不同的线程上处理这些批次。实体较多时可显著提高性能,但可能会引发数据竞争或冲突

当实体数量较多,并且每个实体的处理相对独立(没有数据竞争)时,ScheduleParallel 能显著提高性能

但如果任务较小或无法保证线程安全,则可以使用 Schedule

使用job.Complete()后,主线程将被阻塞,直到该作业完成

[BurstCompile]属性:

优点:

  • 将代码编译为高度优化的本地机器代码(如 x86 或 ARM 架构)。与标准的 C# IL 代码相比,生成的机器代码能更有效地利用 CPU,减少不必要的性能开销。
  • Burst 编译器会尝试将代码自动矢量化,可以利用CPU指令集来同时处理多个数据
  • Burst 编译器生成的代码与 C# 的托管环境隔离得更彻底,减少了垃圾回收的负担

缺点:

  • 调试会变得更困难,因为编译后的代码不容易映射回原始 C# 代码。可以使用Burst Inspector 来分析生成的代码

  • Burst 编译器不支持一些高级 C# 特性,如虚函数、多态、异常处理等

    以下为碰到的不支持的语法,有些语法编译会提示Burst不支持,但是用起来却没有问题,是因为其退回到了普通的.NET托管代码模式了

    1
    2
    3
    4
    5
    6
    System.DateTime;		// 虽然用起来没啥问题,但是在编译后unity会报错,提示不支持
    System.Environment.Tick; // 提示找不到该方法
    System.Diagnostics.Stopwatch.GetTimestamp(); // 同上
    System.Environment.NewLine; // 编译提示不支持
    System.Guid.NewGuid().ToByteArray(); // 编译提示不支持
    ... // 不列了,基本上System都会不支持
    • System.DateTime
      • 报错内容:(0,0): Burst error BC1045: Struct System.DateTime with auto layout is not supported
      • 本质:BurstCompile 不支持带有自动内存布局的结构体,例如 System.DateTime。这意味着 Burst 不能处理其内存布局,因为它可能在不同平台上表现不同。
    • System.Guid.ToByteArray()
      • 报错内容:Assets\Scripts\Malevolent\DotsHelpers.cs(25,46): Burst error BC1016: The managed function System.Guid.ToByteArray(System.Guid* this) is not supported
      • 本质:由于调用了不支持的托管方法,Burst 无法编译与托管代码相关的 API,比如 System.Guid 的方法。
  • 只用ISystem使用Burst编译
  • 使用[BurstCompile]后在Profiler中可以观察到IJobEntity
  • 可以使用Job-Burst-OpenInspector窗口查看编译错误

控制执行生命周期

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
public partial class PlayerSpawnerSystem : SystemBase
{
protected override void OnUpdate()
{
// 获取单例,如果场景中没有或者有2个以上,Unity将报错
EntityQuery playerEntityQuery = EntityManager.CreateEntityQuery(typeof(PlayerTag));

// 只是读取该实体,所以不用担心值类型、引用类型的问题
PlayerSpawner playerSpawner = SystemAPI.GetSingleton<PlayerSpawner>();

// 创建一个命令缓冲器,将任务分批次执行
var entityCommandBuffer = SystemAPI.GetSingleton<BeginSimulationEntityCommandBufferSystem.Singleton>().CreateCommandBuffer(World.Unmanaged);

int spawnAmount = 2;
if (playerEntityQuery.CalculateEntityCount() < spawnAmount)
{
// 注释掉在常规生命周期
// EntityManager.Instantiate(playerSpawner.playerPrefab);

// 分批次的创建物体
entityCommandBuffer.Instantiate(playerSpawner.playerPrefab);

// 并不是真正的设置,而是创建了一个新的Component替换旧的
entityCommandBuffer.SetComponent(entity, new Speed
{
value = DotsHelpers.GetRandomFloat(0, 2)
});
}
}
}


Profiler

可以根据System窗口的顺序查找


场景互通

GameObject获取Entities场景中的实体

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
using Unity.Entities;
using Unity.Transforms;
using UnityEngine;
using Random = UnityEngine.Random;

public class PlayerVisual : MonoBehaviour
{
private Entity _targetEntity;

private void LateUpdate()
{
if (Input.GetKeyDown(KeyCode.Space))
{
_targetEntity = GetRandomEntity();
}

if (_targetEntity != Entity.Null)
{
// 获取目标实体的坐标
var followPosition = World.DefaultGameObjectInjectionWorld.EntityManager.GetComponentData<LocalToWorld>(_targetEntity).Position;
transform.position = followPosition;
}
}

private Entity GetRandomEntity()
{
// 获取Entities场景中Tags为 "PlayerTag" 的实体
// 因为只有一个世界,所以可以直接使用Default
EntityQuery playerTagEntityQuery = World.DefaultGameObjectInjectionWorld.EntityManager.CreateEntityQuery(typeof(PlayerTag));

// 将查找到的物体临时存储到NativeArray中
NativeArray<Entity> entityNativeArray = playerTagEntityQuery.ToEntityArray(Allocator.Temp);

// 返回一个随机的实体
return entityNativeArray.Length > 0 ? entityNativeArray[Random.Range(0, entityNativeArray.Length)] : Entity.Null;
}
}

性能测试

既然使用了ECS,那当然少不了喜闻乐见的性能测试了,生成20000个Player

GameObject的Player生成与移动逻辑代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class PlayerSpawnerGameObject : MonoBehaviour
{
public GameObject playerPrefab;

private void Start()
{
int spawnAmount = 20000;
for (int i = 0; i < spawnAmount; i++)
{
GameObject playerGameObject = Instantiate(playerPrefab);
var moveToPositionGameObject = playerGameObject.AddComponent<MoveToPositionGameObject>();

moveToPositionGameObject.speed = Random.Range(2, 5);
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
public class MoveToPositionGameObject : MonoBehaviour
{
const float TwoPi = 2f * math.PI;
private Vector3 targetPosition;
public float speed;

private void Update()
{
Move(Time.deltaTime);
}

public void Move(float deltaTime)
{
var direction = (targetPosition - transform.position).normalized;
transform.position += direction * deltaTime * speed;

if (Vector3.Distance(targetPosition, transform.position) < .5f)
{
targetPosition = GetRandomPosition();
}
}

public Vector3 GetRandomPosition(float radius = 10f, float3 center = default)
{
var angle = Random.Range(0, TwoPi);
var distance = Random.Range(0, radius);
var x = math.cos(angle) * distance;
var z = math.sin(angle) * distance;
return new float3(x, 0, z) + center;
}
}

总结

总得来说,搞清楚工作流程之后还是挺简单的,但以上演示是最简单的操作方式,还需要继续学习。

  • 比如可以使用[UpdateInGroup(typeof(InitializationSystemGroup), OrderLast = true)]属性修饰System控制生命周期
  • System中还有诸如OnCreateOnStartRunning等抽象方法复写