关于Unity-Dots框架

前言

        本文根据Unity官方提供的教程实践为基础来进行说明并添加一些我实践过程中的内容。如果你觉得看我文章浪费时间你可以去官方的教程下查看自行了解一下——官方教程连接(友情提醒:官方教程的Dots版本并没有到最新。至少在2023.9.17,我查看的时候教程Dots版本大概是在1.0.0)。

电脑环境信息:

    Windows10版本

    Unity2022.3.7f1c1

    Dots框架版本1.0.16

环境搭建

        Unity版本直接在UnityHub下下载,版本只要下载2022版本以上的长期支持版本就好了(越新的版本可支持的Dots框架版本越高)。接下来的操作你都可以在上面的教程中看到,我这里也只是复述。在进行Unity版本下载的时候,我们要将其URP的选项一起点上。如果你没有找到,后面你在创建空项目的时候再选择URP项目。接下来你只要等Unity自动下载好后再创建这个工程。

        接下来我们打开,并在顶部的工具栏中按照以下的操作打开包管理器:Windows => Package Manager。我们点击包管理器界面顶部左侧的+号按钮,并选择Add package by name,将name的字段设置为com.unity.entities.graphics,而version字段无需填写。如下图: 图片1 等待安装包安装完毕后,我们在顶部工具栏中选择Edit => Project Settings => Editor启用Enter Play Mode Options其子选项不启用。官方截图中子选项上有一个启动项,但是这个启动项我没有在设置中找到。后面我进行实践的时候到也没感觉有什么影响,可能后面官方就把这个子选项在我们启动Enter Play Mode Options时就默认开启了吧。总之这个问题不大。有关这些选项的意义官方也给出了对应说明的链接,这里我也给出来->Unity 手册 - 可配置的进入游戏模式,Unity 博客 - 在 Unity 2019.3 中更快地进入游戏模式。官方教程中还有建立文件夹的过程,但我觉得这个没必要。大家按照自己的习惯创建文件夹就好了。

        接下来我们设置一下烘焙管线。我们在顶部的工具栏中选择Edit => Project Settings => Graphics。你会在Graphics中看到Script Render Pipeline Settings的选项。双击这个你的文件窗口就会跳转到其所在的位置。点击在文件窗口中的它以后Inspector窗口上会出现它的信息,之后点击Renderer List下的选项。同样在文件窗口中会给出它的位置,之后你点击一下它。在Inspector窗口中就会出现它的信息,在其Rendering下的Rendering Path设置为Forward+。这个设置官方并没有说明,但是当你运行程序的时候如果没有按照这样设置则会出现警告。但是即使没有这样设置,我也没有遇到什么问题。如果你看不懂不改也没关系。这个修改的简单办法是你直接在文件窗口中找到URP-HighFidelity-Renderer文件,点击它然后在Inspector窗口中修改Rendering Path并设置为Forward+。这里我再次说明这样的方法只适合于我现在的版本,因为我也不知道后面Unity官方是否会进行修改。

        环境搭建的最后一步就是创建Scene。对于我们已打开的Scene场景,一般Unity会默认给你一个SampleScene场景。即在Hierarchy窗口中第一个(非预制体预览下),鼠标右键其并选择New Subscene。之后会弹出一个窗口,你填上你要创建的Scene名称(我这里把其命名为Test)后点击确定。成功后你Dots的Scene应该在SampleScene下。如下图所示图片2 Ps:我觉得大部分情况下我们创建出来的实体应该要放到Dots的Scene中,但是如果你去网上搜索就会发现有些的代码创建出来的实体不会在Dots的Scene下。而这些实体于其他的实体几乎没有太大的差别,一样可以被查询并进行渲染等操作。不过我们不能没有Scene,否则有些代码是不能执行的。

创建所需物体

  1. Dots的Scene下,创建3D立方体 (Cube),并将其命名为“Tank”。将其位置和旋转设为(0,0,0),缩放(1,1,1)。

  2. 在Tank下创建3D球体(Sphere),并将其命名为“Turret”。将其位置设为(0,0.5,0),将旋转 设为(45,0,0),将缩放设为(1,1,1)。

  3. 在Turret下创建3D圆柱体(Cylinder),并将其命名为“Cannon”。将其位置设为(0,0.5,0),将旋转 设为(0,0,0),将缩放设为(0.2,0.2,0.2)。

  4. 在Cannon下创建空对象(即Create Empty),并将其命名为“SpawnPoint”。将其位置设为(0,1,0),将旋转 设为(-90,0,0),将缩放设为(1,1,1)。 最终呈现的效果如下:

    图片3

  5. 最后我们删除上述创建对象的Collider组件。后面我们用不上所以没必要有这个组件。当然你不删只影响性能,不影响结果。

逻辑编写

炮台旋转逻辑

        我们先创建一个名为TurretRotationSystem的脚本。代码逻辑如下:

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
using Unity.Burst;
using Unity.Entities;
using Unity.Mathematics;
using UnityEngine;
using Unity.Transforms;

namespace ESCLearn
{
[BurstCompile]
public partial struct TurretRotationSystem : ISystem
{
[BurstCompile]
public void OnUpdate(ref SystemState state)
{
quaternion rotation = quaternion.RotateY(SystemAPI.Time.DeltaTime * math.PI);
foreach (var transform in SystemAPI.Query<RefRW<LocalToWorld>>())
{
Quaternion q = rotation;
Vector3 angel = q.eulerAngles;
q = transform.ValueRW.Rotation;
angel += q.eulerAngles;
rotation = Quaternion.Euler(angel);
LocalTransform localTransform = new LocalTransform();
localTransform.Position = transform.ValueRW.Position;
localTransform.Rotation = rotation;
localTransform.Scale = 1;
float4x4 mat = localTransform.ToMatrix();
float3 scale = transform.ValueRW.Value.Scale();
mat.c0 *= scale.x;
mat.c1 *= scale.y;
mat.c2 *= scale.z;
transform.ValueRW.Value = mat;
}
}
}
}

        如果你有看官方的教程你会发现我写的和官方写的不一样。官方给的是TransformAspect,而这个在1.0.0-pre.65版本的时候就已经被删除了。具体内容可以在官方更新日志中查到。教程中还有几个函数:

1
2
3
4
5
[BurstCompile]
public void OnCreate(ref SystemState state){}

[BurstCompile]
public void OnDestroy(ref SystemState state){}

顾名思义,OnCreate在这个ISystem被创建的时候触发,OnDestroy在这个ISystem被销毁的时候触发。它们旁边官方给出的注释是:由 ISystem 定义的每个函数即便是空的,也必须实现。但是其实你要是没有用到可以不用多写出来,如同我上面给出的代码一样。

        官方还有其他的注释:基于ISystem的非托管系统可使用Burst编译,但这不是默认设置。因此,我们必须通过[BurstCompile]属性明确选择 Burst 编译。它必须添加到结构和 OnCreate/OnDestroy/OnUpdate 函数才能生效(就如同我在OnUpdate函数上也用了[BurstCompile])。

        代码整体是思路是计算出当前要旋转的值,然后通过SystemAPI.Query查询出所有带有LocalToWorld组件的实体。因为LocalToWorld是结构体,如果我们对其的修改要作用到实体上则这里我们必须要加上RefRW让LocalToWorld的修改能作用到实体上。Unity的Dots框架很多都是用结构体来做的,这样里就会出现C#中值类型修改数据的问题,比如我们得到的值类型不能直接作用到其本身。所以Dots框架中提供了RefRW类帮助我们做到这一点,不过RefRW只接受IComponentData(即Dots中组件)类型的结构体。最后就是修改查找到的LocalToWorld。因为LocalToWorld修改只能动矩阵,所以我为了方便使用了localTransform。具体效果大家没必要那么在乎,虽然看起来的确和官方教程中的不一样,我想大概率是因为TransformAspect内部实现的原因。

        当我们物体在Dots的Scene下时,它自身会转变为实体而它身上的部分component会转变为对应的Dots下的组件。如果你要查看可以在普通的Hierarchy窗口下点DotsScene下的物体。在Inspector的窗口中就会显示出来如图所示: 图片4图中红框圈中的就是这个物体所拥有的组件名称。有时候这个窗口可能会被拉下去从而被隐藏,如下图所示图片5这时候你只需选中后拉起来便可以再次显示。还有一种特殊的情况下这个窗口直接就没有了。不过我们仍然有方法可以看到信息。选中这个物体,然后将Inspector窗口设置为runtime模式。这时候Inspector窗口就会显示它所拥有的所有实体。如下图所示:图片6其中红色箭头所指的就是其中的一个组件。

        点击运行按钮进行执行后,你就可以看到效果了。这里我们额外说一些信息。点击运行后,我们在上方工具栏中找到Window => Entities => System。点击后就会出现所有的System信息,在这之中你就可以发现我们设定的TurretRotationSystem。其是在Update的Simulation System Group下,如果我们没有特殊设置,那么我们创建的系统一般都会在这个下面。当然有时候我们希望对自己创建出来的系统进行一个绝对的控制。那么就可以自己创建System Group,并通过其他的UpdateBefore或是UpdateAfter等属性进行控制。实例代码如下

1
2
3
4
5
6
[UpdateInGroup(typeof(TransformSystemGroup))]
[UpdateBefore(typeof(LocalToWorldSystem))]
public partial struct Sample : ISystem
{

}

这里表明Sample是在TransformSystemGroup下,并且在LocalToWorldSystem前执行。有关其更多的信息可以查看:UpdateAfterAttributeUpdateBeforeAttributeUpdateInGroupAttribute

使用烘焙系统进行GameObject转Entity

        接下来我来介绍一下如何将一个GameObject转为Entity,这其中我们需要使用Dots中的烘焙系统。但是在此之前我们要先创建一个组件。之后在烘焙的时候,我们将这个组件添加到我们的实体中。我们先创建一个脚本名为Turret,其中代码如下:

1
2
3
4
5
6
using Unity.Entities;

namespace ESCLearn
{
public struct Turret : IComponentData {}
}

Dots框架中只要实现IComponentData接口的结构体就是组件,而只有组件才可以被添加到实体中。现在这个组件中什么都没有,官方教程中称这样的空组件为“标签组件”。有了这个组件后,我们就开始写烘焙的代码。我们创建一个脚本名为TurretAuthoring,其中代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
using Unity.Entities;
using UnityEngine;

namespace ESCLearn
{
public class TurretAuthoring : MonoBehaviour { }

public class TurretBaker : Baker<TurretAuthoring>
{
public override void Bake(TurretAuthoring authoring)
{
var entity = GetEntity(TransformUsageFlags.Dynamic);
AddComponent(entity, new Turret { });
}
}
}

        在当前的版本中类似于AddComponent();这样的代码官方已经禁用了。我们必须要指定实体才可以进行添加。Baker类中自带的GetEntity方法就可以做到将GameObject转为Entity。关于GetEntityTransformUsageFlags更多信息可以去看官方文档的说明。

        在官方教程中关于这段代码的注释:MonoBehaviours 是常规的 GameObject 组件。它们构成烘焙系统的输入,烘焙系统生成 ECS 数据。烘焙器将创作 MonoBehaviours 转换为实体和组件。

        接下来我们将此脚本给场景中的Turret对象。这时候你会发现Dots组件窗口中多出了一个Turret组件。你可以试着在Bake函数下打一个日志,你会发现烘焙触发的时间是你每次重新编译或是开启游戏的时候。

SystemAPI.Query 设置查询条件

        之前我们使用SystemAPI.Query<RefRW>()时,这个函数会返回场景中所有的LocalToWorld组件。有时候我们只想要特别的实体上的LocalToWorld组件,那么我们就可以使用WithAll函数来帮助我们做到这点。我们修改TurretRotationSystem脚本的foreach代码如下:

1
foreach (var transform in SystemAPI.Query<RefRW<LocalToWorld>>().WithAll<Turret>()) {...}

通过添加WithAll(),我们就可以筛选出有LocalToWorld并有Turret的实体了。而这正是我们想要的实体。除了WithAll还有WithAny等,这个大家可以去查一下官方文档进行了解。

SystemBase和和 Entities.ForEach 并行性

        在官方的教程中,这部分实现了坦克的移动逻辑用来引出SystemBase和和 Entities.ForEach 并行性。这里我们需要以上面同样的方法创建出脚本Tank和TankAuthoring。代码如下:

1
2
3
4
5
6
7
8
9
using Unity.Entities;

namespace ESCLearn
{
public struct Tank:IComponentData
{

}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
using Unity.Entities;
using UnityEngine;

namespace ESCLearn
{
public class TankAuthoring : MonoBehaviour { }

class TankBaker : Baker<TankAuthoring>
{
public override void Bake(TankAuthoring authoring)
{
var entity = GetEntity(TransformUsageFlags.Dynamic);
AddComponent(entity,new Tank());
}
}
}

之后我们将TankAuthoring脚本给在场景中的Tank。接下来我来介绍新的Dots中System的实现。我们先创建名为TankMovementSystem的脚本,其代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
using Unity.Entities;
using Unity.Mathematics;
using Unity.Transforms;

namespace ESCLearn
{
public partial class TankMovementSystem : SystemBase
{
protected override void OnUpdate()
{
var dt = SystemAPI.Time.DeltaTime;
Entities.WithAll<Tank>().ForEach((Entity entity,ref LocalTransform transform) =>
{
var pos = transform.Position;
pos.y = entity.Index;
var angle = (0.5f + noise.cnoise(pos / 10f)) * 4.0f * math.PI;
var dir = float3.zero;
math.sincos(angle, out dir.x, out dir.z);
transform.Position += dir * dt * 5.0f;
transform.Rotation = quaternion.RotateY(angle);
}).ScheduleParallel();
}
}
}

SystemBase和ISystem 一致的地方在于他们都存在OnUpdate/OnCreate/OnDestroy等函数。不一致的地方在于SystemBase作用于类,而ISystem作用于结构体。正因为SystemBase作用于类,所以有时候大家就用这个来进行MonoBehaviour和Dots间的通信。

        在TankMovementSystem中,我们使用了特别的ForEach来对查询到的结果进行修改。官方对此是有注释: Entities.ForEach 是使用 Burst 编译的(隐式)。时间是 SystemBase 的成员,属于托管类型(类)。这意味着,不可能直接从那里访问时间。因此,我们需要将所需的值 (DeltaTime) 复制到本地变量中。Entities.ForEach 是一种老式的查询处理方法。不鼓励使用该方法,但在通过 IFE 获得奇偶性功能之前,该方法仍然十分方便。Entities.ForEach 序列中的最后一个函数调用控制着应如何执行代码:Run(主线程)、Schedule(单线程,异步)或 ScheduleParallel(多线程,异步)。从根本上说,Entities.ForEach 是作业生成器,它极大地简化了并行作业的创建。遗憾的是,这也会产生复杂的成本和奇怪的任意约束,因此,我们倾向于更为显式的方法。

        我在网上虽然没有找到什么叫做“通过 IFE 获得奇偶性功能”,但是明显官方的意思是这样的方法不好。所以我这里也只是引出这样的方法,大家了解一下就好了。我个人觉得如果是为了做某种功能的demo还是可以用一下的。

        官方其实还提供了一个网站链接来说明如何使用Perlin 噪声生成流场。这篇文章是全英文的,有兴趣的读者可以点击链接看一下。TankMovementSystem中坦克的移动正是使用了这样的方法。这里我将pos.y = entity.Index而官方教程中在这里没有,实际上官方教程中是有的。但是官方在后面才加上去,我就直接在这里加上去了。这样做的原因就是为了根据entity.Index的不同形成不同的噪声数据。         点击运行按钮进行执行后,你就可以看到坦克在移动了。

IAspect

        IAspect在Dots框架中是十分常见的接口,一般是在数据查询中使用它。通过我们对IAspect的操作,Dots框架会帮助我们自动找到符合IAspect条件的实体。在开始介绍其用法之前,我们先按照官方教程中的步骤做一些操作。         首先我们先创建名为CannonBall和CannonBallAuthoring,这两个脚本。其代码如下:

1
2
3
4
5
6
7
8
9
10
using Unity.Entities;
using Unity.Mathematics;

namespace ESCLearn
{
public struct CannonBall : IComponentData
{
public float3 Speed;
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
using System.Collections;
using System.Collections.Generic;
using Unity.Entities;
using Unity.Rendering;
using UnityEngine;

namespace ESCLearn
{
public class CannonBallAuthoring : MonoBehaviour { }

public class CannonBallBaker : Baker<CannonBallAuthoring>
{
public override void Bake(CannonBallAuthoring authoring)
{
var entity = GetEntity(TransformUsageFlags.Dynamic);
AddComponent(entity, new CannonBall());
}
}
}

        接下来,我们要在场景中创建一个3D球体命名为CannonBall,将其位置设为(0,0,0),将旋转设为(0,0,0),将缩放设为(0.2,0.2,0.2),把脚本CannonBallAuthoring添加到它身上并删除其身上的碰撞体组件。然后我们将这个对象拖进Resources文件夹(没有这个文件夹的话,自己创建一下)变为预制体。之后我们再将场景中的CannonBall删除。

        预备工作现在还没有结束,我们打开之前的Turret脚本并将其内容修改为下面的样子:

1
2
3
4
5
6
7
8
9
10
using Unity.Entities;

namespace ESCLearn
{
public struct Turret : IComponentData
{
public Entity CannonBallSpawn;
public Entity CannonBallPrefab;
}
}

CannonBallSpawn是之前创建的空物体的实体。CannonBallPrefab表示炮弹预制体实体。然后我们再打开TurretAuthoring脚本并修改为下面的样子:

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
using Unity.Entities;
using UnityEngine;

namespace ESCLearn
{
public class TurretAuthoring : MonoBehaviour
{
public GameObject CannonBallPrefab;
public Transform CannonBallSpawn;
}

public class TurretBaker : Baker<TurretAuthoring>
{
public override void Bake(TurretAuthoring authoring)
{
var entity = GetEntity(TransformUsageFlags.Dynamic);

AddComponent(entity, new Turret
{
CannonBallPrefab = GetEntity(authoring.CannonBallPrefab, TransformUsageFlags.Dynamic),
CannonBallSpawn = GetEntity(authoring.CannonBallSpawn, TransformUsageFlags.Dynamic)
});
}
}
}

我们将生成位置的空对象和预制体转换为实体。修改好代码后,我们选中场景中的Turret对象,并Resources文件夹下的CannonBall和场景中的SpawnPoint分别拖进其身上TurretAuthoring组件的CannonBallPrefab和CannonBallSpawn参数。

        这时候预备工作才算是完成了。我们创建一个脚本名为TurretAspect,其内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
using System.Collections;
using System.Collections.Generic;
using Unity.Entities;
using Unity.Mathematics;
using Unity.Rendering;
using UnityEngine;

namespace ESCLearn
{
public readonly partial struct TurretAspect : IAspect
{
readonly RefRO<Turret> m_Turret;
public Entity CannonBallSpawn => m_Turret.ValueRO.CannonBallSpawn;
public Entity CannonBallPrefab => m_Turret.ValueRO.CannonBallPrefab;
}
}

官方在IAspect这部分给出的注释是:我们不直接访问Turret组件,而是创建IAspect。通过IAspect,您可以提供自定义的 API 来访问组件。该引用提供对Turret组件的只读访问。

        虽然官方说法是说只提供只读访问,其实你仍然可以进行修改。这你只需将RefRO改为RefRW,前面即使是readonly修饰也仍然可以修改其中的数值。readonly是IAspect要求的,这个改不了。而我们设置的readonly也正是IAspect筛选的条件。所以这段代码中,只有拥有Turret组件的实体才符合TurretAspect的条件。接下来的代码就是对TurretAspect的操作。我们创建名为TurretShootingSystem的脚本,其中代码如下所示:

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
using Unity.Burst;
using Unity.Collections;
using Unity.Entities;
using Unity.Mathematics;
using Unity.Rendering;
using Unity.Transforms;

namespace ESCLearn
{
public partial struct TurretShootingSystem : ISystem
{
ComponentLookup<LocalToWorld> m_LocalToWorldFromEntity;

[BurstCompile]
public void OnCreate(ref SystemState state)
{
m_LocalToWorldFromEntity = state.GetComponentLookup<LocalToWorld>(true);
}

[BurstCompile]
public void OnUpdate(ref SystemState state)
{
m_LocalToWorldFromEntity.Update(ref state);
var ecbSingleton = SystemAPI.GetSingleton<BeginSimulationEntityCommandBufferSystem.Singleton>();
var ecb = ecbSingleton.CreateCommandBuffer(state.WorldUnmanaged);
var turretShootJob = new TurretShoot
{
LocalToWorldEntity = m_LocalToWorldFromEntity,
ECB = ecb
};
turretShootJob.Schedule();
}
}

[BurstCompile]
public partial struct TurretShoot : IJobEntity
{
[ReadOnly] public ComponentLookup<LocalToWorld> LocalToWorldEntity;
public EntityCommandBuffer ECB;

public void Execute(TurretAspect turret)
{
var instance = ECB.Instantiate(turret.CannonBallPrefab);
var spawnLocalToWorld = LocalToWorldEntity[turret.CannonBallSpawn];
var cannonBallTransform = LocalTransform.FromPosition(spawnLocalToWorld.Position);
var prefabInfo = LocalToWorldEntity[turret.CannonBallPrefab];
float4x4 val = prefabInfo.Value;
ECB.SetComponent(instance, cannonBallTransform);
ECB.AddComponent(instance, new PostTransformMatrix
{
Value = val
});
ECB.SetComponent(instance, new CannonBall
{
Speed = spawnLocalToWorld.Forward * 20.0f
});
}
}
}

官方教程中是LocalToWorldTransform,但是这个在现在的Dots版本中已经没有了。我在Dots的修改日志中也没有找到修改的信息。我只能从其中的内容推断出这个应该就是现在的LocalToWorld。

        关于这段代码中ComponentLookup官方教程注释:ComponentLookup 提供对组件的随机访问查找实体。我们将使用它来提取世界空间位置和生成点的方向即大炮发射口。ComponentLookup 必须初始化一次。ComponentLookup 结构必须逐帧更新。

        所以ComponentLookup一般是在系统的OnCreate中进行初始化,然后在OnUpdate中调用自身的Update函数进行更新。其实我认为更新只要在你需要的时候进行就好了,当然对于这段代码来说就是每帧更新。而在初始化中传入的bool类型参数则表示该参数指定查找是否将为只读,或者查找是否应该允许写入。更多有关其的信息可查阅官方文档:ComponentLookup。总而言之,ComponentLookup内部存储了场景中所有你想要找的组件信息。

        关于这段代码中EntityCommandBuffer官方教程注释:创建 EntityCommandBuffer 以延迟实例化所需的结构更改。

        EntityCommandBuffer其实就是一堆改变实体和组件操作的集合。因此当我们使用EntityCommandBuffer对实体和组件进行操作的时候,其实这个操作并没有立刻执行。直到Dots执行到某个System后统一执行这些操作。当你打开查看Dots System的窗口,你就可以看到一些带有EntityCommandBufferSystem的System。我个人认为应该就是这些System去做统一的处理。有关其更多的信息,可以查阅官方文档:EntityCommandBuffer

        在官方的教程中,IJobEntity的Execute函数是这样描写的:void Execute(in TurretAspect turret)。并且官方还有这样一段描述:注意,TurretAspects 参数为“in”,这将其声明为只读。在本例中,将该参数更改为“ref”(读写)不会产生任何不同,但您会遇到潜在竞争条件触发安全系统的情况。会遇到潜在竞争条件触发安全系统的情况。

        虽然官方是说要加上in修饰,但是当你真的加上后又会报这样的错误:error SGJE0021: TurretAspect is an Aspect passed with a ref or in keyword. Aspects are already act as reference types and should just be passed in by value。而这个错误的解决方法就是不要加in。而在这个地方,IAspect的作用也真正发挥出来了。当我们使用IJobEntity时,Dots就只给符合IAspect条件的信息。

        官方的代码中使用了UniformScaleTransform去赋值缩放。可是这个类在当前的Dots版本中,我也找不到。并且官方更新日志中也没有这个说明。所以我使用了PostTransformMatrix来做缩放处理。其实因为这个缩放是统一缩放,所以你可以直接使用localTransform中的scale参数进行缩放处理。而PostTransformMatrix一般是用来处理非统一缩放的。         这里需要注意一下,虽然我们使用了Job,但这里的Job并不是多线程而是单线程。而EntityCommandBuffer如果要多线程并行执行则要做一些特殊的操作。         点击运行按钮进行执行后,你就可以看到坦克在边移动边吐炮弹了。当然现在炮弹还是不会运动的。这里你或许会发现一些问题,你在你熟悉的Hierarchy窗口上看不到生成出来的炮弹。这是因为你生成出来的炮弹并不是GameObject所以在你熟悉的Hierarchy窗口看不到这些炮弹。好在官方提供了一个Dots的Hierarchy窗口。我们在上方工具栏中找到Window => Entities => Hierarchy。点击后就会出现专属于Dots的Hierarchy窗口,这个下面就可以看到所有的Dots实例。

EntityCommandBuffer并行作业

        虽然上文中有说明使用Entities.WithAll().ForEach的并行作业,但这毕竟不被官方所推荐而且EntityCommandBuffer要执行并行操作前要进行一个特别的操作。好在官方教程中还是有介绍并行作业的和EntityCommandBuffer的特殊操作。首先,我们先创建名为CannonBallAspect的脚本,其内容如下:

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
using Unity.Entities;
using Unity.Mathematics;
using Unity.Transforms;

namespace ESCLearn
{
public readonly partial struct CannonBallAspect : IAspect
{
public readonly Entity Self;

readonly RefRW<CannonBall> cannonBall;
readonly RefRW<LocalTransform> Transform;

public float3 Position
{
get => Transform.ValueRO.Position;
set => Transform.ValueRW.Position = value;
}

public float3 Speed
{
get => cannonBall.ValueRO.Speed;
set => cannonBall.ValueRW.Speed = value;
}
}
}

官方的注释:IAspect中的Entity字段提供对Entity本身的访问。例如,对于在 EntityCommandBuffer 中注册命令,这是必需的。这是因为EntityCommandBuffer必须要指定Entity。所以其实就是说你想在Job中想要操作这个Entity,那么就只要在IAspect中添加一个public readonly Entity Self;这个Self必然会有你约定的组件。上述例子即为CannonBall组件和LocalTransform组件。

        然后,我们创建名为CannonBallSystem的脚本。其中内容如下:

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
using Unity.Burst;
using Unity.Entities;
using Unity.Mathematics;

namespace ESCLearn
{
[BurstCompile]
public partial struct CannonBallJob : IJobEntity
{
public EntityCommandBuffer.ParallelWriter ECB;
public float DeltaTime;
public void Execute([ChunkIndexInQuery] int chunkIndex, CannonBallAspect cannonBall)
{
var gravity = new float3(0.0f, -9.82f, 0.0f);
var invertY = new float3(1.0f, -1.0f, 1.0f);
cannonBall.Position += cannonBall.Speed * DeltaTime;
if (cannonBall.Position.y < 0.0f)
{
cannonBall.Position *= invertY;
cannonBall.Speed *= invertY * 0.8f;
}
cannonBall.Speed += gravity * DeltaTime;
var speed = math.lengthsq(cannonBall.Speed);
if (speed < 0.1f) ECB.DestroyEntity(chunkIndex, cannonBall.Self);
}
}

[BurstCompile]
public partial struct CannonBallSystem : ISystem
{
[BurstCompile]
public void OnUpdate(ref SystemState state)
{
var ecbSingleton = SystemAPI.GetSingleton<EndSimulationEntityCommandBufferSystem.Singleton>();
var ecb = ecbSingleton.CreateCommandBuffer(state.WorldUnmanaged);
var cannonBallJob = new CannonBallJob
{
ECB = ecb.AsParallelWriter(),
DeltaTime = SystemAPI.Time.DeltaTime
};
cannonBallJob.ScheduleParallel();
}
}
}

        在CannonBallSystem中,你会发现我们传了EntityCommandBuffer多做了一个AsParallelWriter函数。而在CannonBallJob中的ecb声明也变成了EntityCommandBuffer.ParallelWriter。这些都是为了让EntityCommandBuffer并执行操作所做的行为。官方也有注释:ecb.AsParallelWriter()注意为 EntityCommandBuffer 获取并行写入器所需的函数调用。不能直接从job访问时间,因此 DeltaTime 必须作为参数传入。

处理只应运行一次的初始化系统

        很多时候有些系统我们只需要运行一次进行一个初始化操作后就再也不运行了。然后其他的系统在这个初始化系统完成初始化后再运行。本节正是介绍这样的系统如何实现。这里我们按照官方教程做一下预备的工作。         首先我们将场景中的Tank拖入到Resources文件夹中变为预制体后删除场景中的Tank。接下来我们创建一个名为Config的脚本,其内容如下:

1
2
3
4
5
6
7
8
9
10
11
using Unity.Entities;

namespace ESCLearn
{
public struct Config : IComponentData
{
public Entity TankPrefab;
public int TankCount;
public float SafeZoneRadius;
}
}

然后我们再创建一个名为ConfigAuthoring的脚本,其内容如下:

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
using Unity.Entities;
using UnityEngine;

namespace ESCLearn
{
public class ConfigAuthoring : MonoBehaviour
{
public GameObject TankPrefab;
public int TankCount;
public float SafeZoneRadius;
}

public class ConfigBaker : Baker<ConfigAuthoring>
{
public override void Bake(ConfigAuthoring authoring)
{
var entity = GetEntity(TransformUsageFlags.Dynamic);
AddComponent(entity, new Config
{
TankPrefab = GetEntity(authoring.TankPrefab, TransformUsageFlags.Dynamic),
TankCount = authoring.TankCount,
SafeZoneRadius = authoring.SafeZoneRadius
});
}
}
}

之后,我们在Dot的Scene下创建一个空对象取名为Config并为其添加ConfigAuthoring。最后我们Tank预制体赋给Config上ConfigAuthoring的TankPrefab值,并将其TankCount设置为20,SafeZoneRadius设置为15。这里SafeZoneRadius是和后面逻辑相关,所以接下来的逻辑中并不会用到SafeZoneRadius。自此准备工作完成,我们开始主要逻辑的编写。我们创建一个名为TankSpawningSystem的脚本,其内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
using Unity.Burst;
using Unity.Collections;
using Unity.Entities;
using Unity.Rendering;

namespace ESCLearn
{
[BurstCompile]
public partial struct TankSpawningSystem : ISystem
{
[BurstCompile]
public void OnUpdate(ref SystemState state)
{
var config = SystemAPI.GetSingleton<Config>();
var ecbSingleton = SystemAPI.GetSingleton<BeginSimulationEntityCommandBufferSystem.Singleton>();
var ecb = ecbSingleton.CreateCommandBuffer(state.WorldUnmanaged);
var vehicles = CollectionHelper.CreateNativeArray<Entity>(config.TankCount, Allocator.Temp);
ecb.Instantiate(config.TankPrefab, vehicles);
state.Enabled = false;
}
}
}

SystemAPI.GetSingleton是一个很好的方法用来获取当前游戏运行过程中,有且仅有一个的组件。如果组件不存在或是组件的数量超过一个,这里都会报错。这个让系统只运行一次的方法是非常简单粗暴的,就是直接禁用系统。至于为什么不放在OnCreate中,如果你放到OnCreate中则会报

1
System.InvalidOperationException: GetSingleton<ESCLearn.Config>() requires that exactly one entity exists that match this query, but there are none. Are you missing a call to RequireForUpdate<T>()? You could also use TryGetSingleton<T>()

的错误。这样也就是说在Dots调用OnCreate的时候,Config还没有存在。这也意味着ISystem的OnCreate函数调用的时机是早于烘焙函数调用的时机。这里再次提醒一下,烘焙会在我们每次编译和启动游戏的时候进行。大家可以打日志验证一下。所以初始化的工作还是放在OnUpdate函数中吧。这里其实有一个问题,如果烘焙函数比这个系统的OnUpdate还要晚进行怎么办呢?其实官方在后面的内容中有给出实现,即在此System的OnCreate函数加入一段代码。我这里直接放出整体的样子:

1
2
3
4
5
[BurstCompile]
public void OnCreate(ref SystemState state)
{
state.RequireForUpdate<Config>();
}

这样就保证了在Config加载前,系统不会运行。

ECS组件控制着色器输入

        我们时常有要求去控制Shader一些属性。这一节官方就介绍了如何去控制Shader的属性。但是官方只是简单说了一下并使用了一个组件。因为官方没说为什么这样做。而且官方给出来的做法只能控制它自己的Shader。我这里仍然按照官方的做法实现一遍,后面我会给出如何写出自己想要的控制Shader组件。前提提示,这里会涉及到一些Shader方面的知识。         我们打开Tank的预制件,并选中Tank、Turret和Cannon,这三个对象为其添加URPMaterialPropertyBaseColorAuthoring组件。接下来我们修改TankSpawningSystem脚本,其代码如下:

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
using Unity.Burst;
using Unity.Collections;
using Unity.Entities;
using Unity.Rendering;
using UnityEngine;
using Random = Unity.Mathematics.Random;

namespace ESCLearn
{
[BurstCompile]
public partial struct TankSpawningSystem : ISystem
{
EntityQuery m_BaseColorQuery;

[BurstCompile]
public void OnCreate(ref SystemState state)
{
state.RequireForUpdate<Config>();
m_BaseColorQuery = state.GetEntityQuery(ComponentType.ReadOnly<URPMaterialPropertyBaseColor>());
}

[BurstCompile]
public void OnUpdate(ref SystemState state)
{
var config = SystemAPI.GetSingleton<Config>();
var random = Random.CreateFromIndex(1234);
var hue = random.NextFloat();
URPMaterialPropertyBaseColor RandomColor()
{
hue = (hue + 0.618034005f) % 1;
var color = Color.HSVToRGB(hue, 1.0f, 1.0f);
return new URPMaterialPropertyBaseColor { Value = (Vector4)color };
}
var ecbSingleton = SystemAPI.GetSingleton<BeginSimulationEntityCommandBufferSystem.Singleton>();
var ecb = ecbSingleton.CreateCommandBuffer(state.WorldUnmanaged);
var vehicles = CollectionHelper.CreateNativeArray<Entity>(config.TankCount, Allocator.Temp);
ecb.Instantiate(config.TankPrefab, vehicles);
var queryMask = m_BaseColorQuery.GetEntityQueryMask();
foreach (var vehicle in vehicles)
{
ecb.SetComponentForLinkedEntityGroup(vehicle, queryMask, RandomColor());
}
state.Enabled = false;
}
}
}

为了查找到所有使用URPMaterialPropertyBaseColor的实体,代码中使用了EntityQuery进行查询。有关EntityQuery更多的信息可以查看官方的文档:Query data with EntityQueryStruct EntityQuery。代码还使用一个随机生成颜色的算法,并且官方还给出了这个算法的说明文章:How to Generate Random Colors Programmatically。对于EntityQueryMask即m_BaseColorQuery.GetEntityQueryMask();获取的值,官方的注释是:EntityQueryMask 能够有效测试 EntityQuery 是否将选择某个特定实体。最后我们通过SetComponentForLinkedEntityGroup进行设置,官方的注释是:每个预设体根都包含一个 LinkedEntityGroup,这是其所有实体的列表。LinkedEntityGroup缓冲区使实体成为一组连接实体的根。有关其更多的信息可以看一下官方文档中对其的描述:Struct LinkedEntityGroup

        官方教程中也把子弹改为和坦克一样的颜色,这个我就不多赘述了,直接贴代码了。修改CannonBallAuthoring脚本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
using Unity.Entities;
using Unity.Rendering;
using UnityEngine;

namespace ESCLearn
{
public class CannonBallAuthoring : MonoBehaviour { }

public class CannonBallBaker : Baker<CannonBallAuthoring>
{
public override void Bake(CannonBallAuthoring authoring)
{
var entity = GetEntity(TransformUsageFlags.Dynamic);
AddComponent(entity, new CannonBall());
AddComponent(entity, new URPMaterialPropertyBaseColor());
}
}
}

修改TurretAspect脚本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
using Unity.Entities;
using Unity.Mathematics;
using Unity.Rendering;

namespace ESCLearn
{
public readonly partial struct TurretAspect : IAspect
{
readonly RefRO<Turret> m_Turret;

readonly RefRO<URPMaterialPropertyBaseColor> m_BaseColor;

public Entity CannonBallSpawn => m_Turret.ValueRO.CannonBallSpawn;
public Entity CannonBallPrefab => m_Turret.ValueRO.CannonBallPrefab;
public float4 Color => m_BaseColor.ValueRO.Value;
}
}

修改TurretShootingSystem脚本:

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
using Unity.Burst;
using Unity.Collections;
using Unity.Entities;
using Unity.Mathematics;
using Unity.Rendering;
using Unity.Transforms;

namespace ESCLearn
{
public partial struct TurretShootingSystem : ISystem
{
ComponentLookup<LocalToWorld> m_LocalToWorldFromEntity;

[BurstCompile]
public void OnCreate(ref SystemState state)
{
m_LocalToWorldFromEntity = state.GetComponentLookup<LocalToWorld>(true);
}

[BurstCompile]
public void OnUpdate(ref SystemState state)
{
m_LocalToWorldFromEntity.Update(ref state);
var ecbSingleton = SystemAPI.GetSingleton<BeginSimulationEntityCommandBufferSystem.Singleton>();
var ecb = ecbSingleton.CreateCommandBuffer(state.WorldUnmanaged);
var turretShootJob = new TurretShoot
{
LocalToWorldEntity = m_LocalToWorldFromEntity,
ECB = ecb
};
turretShootJob.Schedule();
}

}

[BurstCompile]
public partial struct TurretShoot : IJobEntity
{
[ReadOnly] public ComponentLookup<LocalToWorld> LocalToWorldEntity;
public EntityCommandBuffer ECB;

public void Execute(TurretAspect turret)
{
var instance = ECB.Instantiate(turret.CannonBallPrefab);
var spawnLocalToWorld = LocalToWorldEntity[turret.CannonBallSpawn];
var cannonBallTransform = LocalTransform.FromPosition(spawnLocalToWorld.Position);
var prefabInfo = LocalToWorldEntity[turret.CannonBallPrefab];
float4x4 val = prefabInfo.Value;
ECB.SetComponent(instance, cannonBallTransform);
ECB.AddComponent(instance, new PostTransformMatrix
{
Value = val
});
ECB.SetComponent(instance, new CannonBall
{
Speed = spawnLocalToWorld.Forward * 20.0f
});
ECB.SetComponent(instance, new URPMaterialPropertyBaseColor
{
Value = turret.Color
});
}
}
}

那接下来我们就来开始讲述如何修改自己的Shader了。         首先,我们先来看一下URPMaterialPropertyBaseColorAuthoring的源码:

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
#if URP_10_0_0_OR_NEWER
using Unity.Entities;
using Unity.Mathematics;

namespace Unity.Rendering
{
[MaterialProperty("_BaseColor")]
public struct URPMaterialPropertyBaseColor : IComponentData
{
public float4 Value;
}

[UnityEngine.DisallowMultipleComponent]
public class URPMaterialPropertyBaseColorAuthoring : UnityEngine.MonoBehaviour
{
[Unity.Entities.RegisterBinding(typeof(URPMaterialPropertyBaseColor), nameof(URPMaterialPropertyBaseColor.Value))]
public UnityEngine.Color color;

class URPMaterialPropertyBaseColorBaker : Unity.Entities.Baker<URPMaterialPropertyBaseColorAuthoring>
{
public override void Bake(URPMaterialPropertyBaseColorAuthoring authoring)
{
Unity.Rendering.URPMaterialPropertyBaseColor component = default(Unity.Rendering.URPMaterialPropertyBaseColor);
float4 colorValues;
colorValues.x = authoring.color.linear.r;
colorValues.y = authoring.color.linear.g;
colorValues.z = authoring.color.linear.b;
colorValues.w = authoring.color.linear.a;
component.Value = colorValues;
var entity = GetEntity(TransformUsageFlags.Renderable);
AddComponent(entity, component);
}
}
}
}
#endif

我们先不管其中的宏判断,我们仅仅专注于其代码的实现。其中重要的只有URPMaterialPropertyBaseColor的声明。在它上面有一个这样的修饰:[MaterialProperty("_BaseColor")]。这个就表示了这个值是作用于Shader中的_BaseColor属性。这里要注意的是,其内部存储的值必须和Shader中属性值类型一致。比如Shader中Color一般是float4类型的,所以URPMaterialPropertyBaseColor中声明的属性也是float4类型。对此官方文档中也有介绍,但是教程中竟然没有说明。官方文档地址:(Material overrides using C#)[https://docs.unity3d.com/Packages/com.unity.entities.graphics@1.0/manual/material-overrides-code.html?q=MaterialProperty]。所以只要我们使用MaterialProperty并自定义声明属性就可以修改了。但是这里其实还有一个关于Shader的坑。如果你要修改一个Shader的属性,除了要像上面那般声明。Shader这里也要做特殊的处理。这里我直接贴出官方Shader中的实现:

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
// NOTE: Do not ifdef the properties here as SRP batcher can not handle different layouts.
CBUFFER_START(UnityPerMaterial)
float4 _BaseMap_ST;
float4 _DetailAlbedoMap_ST;
half4 _BaseColor;
half4 _SpecColor;
half4 _EmissionColor;
half _Cutoff;
half _Smoothness;
half _Metallic;
half _BumpScale;
half _Parallax;
half _OcclusionStrength;
half _ClearCoatMask;
half _ClearCoatSmoothness;
half _DetailAlbedoMapScale;
half _DetailNormalMapScale;
half _Surface;
CBUFFER_END

// NOTE: Do not ifdef the properties for dots instancing, but ifdef the actual usage.
// Otherwise you might break CPU-side as property constant-buffer offsets change per variant.
// NOTE: Dots instancing is orthogonal to the constant buffer above.
#ifdef UNITY_DOTS_INSTANCING_ENABLED

UNITY_DOTS_INSTANCING_START(MaterialPropertyMetadata)
UNITY_DOTS_INSTANCED_PROP(float4, _BaseColor)
UNITY_DOTS_INSTANCED_PROP(float4, _SpecColor)
UNITY_DOTS_INSTANCED_PROP(float4, _EmissionColor)
UNITY_DOTS_INSTANCED_PROP(float , _Cutoff)
UNITY_DOTS_INSTANCED_PROP(float , _Smoothness)
UNITY_DOTS_INSTANCED_PROP(float , _Metallic)
UNITY_DOTS_INSTANCED_PROP(float , _BumpScale)
UNITY_DOTS_INSTANCED_PROP(float , _Parallax)
UNITY_DOTS_INSTANCED_PROP(float , _OcclusionStrength)
UNITY_DOTS_INSTANCED_PROP(float , _ClearCoatMask)
UNITY_DOTS_INSTANCED_PROP(float , _ClearCoatSmoothness)
UNITY_DOTS_INSTANCED_PROP(float , _DetailAlbedoMapScale)
UNITY_DOTS_INSTANCED_PROP(float , _DetailNormalMapScale)
UNITY_DOTS_INSTANCED_PROP(float , _Surface)
UNITY_DOTS_INSTANCING_END(MaterialPropertyMetadata)

#define _BaseColor UNITY_ACCESS_DOTS_INSTANCED_PROP_WITH_DEFAULT(float4 , _BaseColor)
#define _SpecColor UNITY_ACCESS_DOTS_INSTANCED_PROP_WITH_DEFAULT(float4 , _SpecColor)
#define _EmissionColor UNITY_ACCESS_DOTS_INSTANCED_PROP_WITH_DEFAULT(float4 , _EmissionColor)
#define _Cutoff UNITY_ACCESS_DOTS_INSTANCED_PROP_WITH_DEFAULT(float , _Cutoff)
#define _Smoothness UNITY_ACCESS_DOTS_INSTANCED_PROP_WITH_DEFAULT(float , _Smoothness)
#define _Metallic UNITY_ACCESS_DOTS_INSTANCED_PROP_WITH_DEFAULT(float , _Metallic)
#define _BumpScale UNITY_ACCESS_DOTS_INSTANCED_PROP_WITH_DEFAULT(float , _BumpScale)
#define _Parallax UNITY_ACCESS_DOTS_INSTANCED_PROP_WITH_DEFAULT(float , _Parallax)
#define _OcclusionStrength UNITY_ACCESS_DOTS_INSTANCED_PROP_WITH_DEFAULT(float , _OcclusionStrength)
#define _ClearCoatMask UNITY_ACCESS_DOTS_INSTANCED_PROP_WITH_DEFAULT(float , _ClearCoatMask)
#define _ClearCoatSmoothness UNITY_ACCESS_DOTS_INSTANCED_PROP_WITH_DEFAULT(float , _ClearCoatSmoothness)
#define _DetailAlbedoMapScale UNITY_ACCESS_DOTS_INSTANCED_PROP_WITH_DEFAULT(float , _DetailAlbedoMapScale)
#define _DetailNormalMapScale UNITY_ACCESS_DOTS_INSTANCED_PROP_WITH_DEFAULT(float , _DetailNormalMapScale)
#define _Surface UNITY_ACCESS_DOTS_INSTANCED_PROP_WITH_DEFAULT(float , _Surface)
#endif

这段源码是在#include "Packages/com.unity.render-pipelines.universal/Shaders/LitInput.hlsl"中。你可以发现除了URP正常的声明属性外,他还做一个关于Dots的判断,并将一些属性做了些特殊的操作。如果你不这么做,那么你在脚本中做的事情就不会应用到物体上。其实这个就和普通的Shader做实例化的操作一样,对于使用同一个材质的物体其中部分属性会有所不同。那么为了可以进行合批操作,那么这个不同属性就要使用一些宏声明。再具体的我就不展开了。总之这样设定Shader和脚本后,你就可以随心所欲的修改你的Shader的属性了。在使用URP的时候,你可能会得到这样的错误:

1
A BatchDrawCommand is using a pass from the shader "URP/xxx" that is not SRP Batcher compatible. Reason: "Material property is found in another cbuffer than "UnityPerMaterial"" (Color) This is not supported when rendering with a BatchRendererGroup (or Entities Graphics). 

这个错误也可以通过上面的方法解决。至少我在实践的时候遇到这样的错误就是用这个方法解决的。

IEnableableComponent

        Dots框架中提供了IEnableableComponent来打开和关闭组件,这个会比直接添加和移除组件来得高效。这里提一下,我实际使用中很少用到这个组件。官方教程中的案例也是每次对生成的坦克进行条件判断后修改IEnableableComponent的值。我觉得这个例子不好,因为条件判断完全可以在执行行为的System中进行判断,不符合条件的不执行就好了。我想大概率是我太菜,不能明白官方的深意吧。         我们创建一个名为Shooting的脚本,其内容如下:

1
2
3
4
5
6
using Unity.Entities;

namespace ESCLearn
{
public struct Shooting : IComponentData, IEnableableComponent { }
}

然后我们修改TurretAuthoring脚本,修改后其内容如下:

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
using Unity.Entities;
using UnityEngine;

namespace ESCLearn
{
public class TurretAuthoring : MonoBehaviour
{
public GameObject CannonBallPrefab;
public Transform CannonBallSpawn;
}

public class TurretBaker : Baker<TurretAuthoring>
{
public override void Bake(TurretAuthoring authoring)
{
var entity = GetEntity(TransformUsageFlags.Dynamic);

AddComponent(entity, new Turret
{
CannonBallPrefab = GetEntity(authoring.CannonBallPrefab, TransformUsageFlags.Dynamic),
CannonBallSpawn = GetEntity(authoring.CannonBallSpawn, TransformUsageFlags.Dynamic)
});
AddComponent(entity, new Shooting());
SetComponentEnabled<Shooting>(entity, false);
}
}
}

官方教程中其实是没有SetComponentEnabled(entity, false);,但我觉得加上好看一点。即使它默认就是false。接下来我们创建一个名为SafeZoneSystem的脚本文件,其内容如下:

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
using Unity.Burst;
using Unity.Collections;
using Unity.Entities;
using Unity.Mathematics;
using Unity.Transforms;
using UnityEngine;

namespace ESCLearn
{
[WithAll(typeof(Turret))]
[BurstCompile]
public partial struct SafeZoneJob : IJobEntity
{
[NativeDisableParallelForRestriction] public ComponentLookup<Shooting> TurretActiveFromEntity;
public float SquaredRadius;

void Execute(Entity entity, LocalToWorld transform)
{
TurretActiveFromEntity.SetComponentEnabled(entity, math.lengthsq(transform.Position) > SquaredRadius);
}
}

[BurstCompile]
public partial struct SafeZoneSystem : ISystem
{
ComponentLookup<Shooting> m_TurretActiveFromEntity;
[BurstCompile]
public void OnCreate(ref SystemState state)
{
state.RequireForUpdate<Config>();
m_TurretActiveFromEntity = state.GetComponentLookup<Shooting>();
}

[BurstCompile]
public void OnUpdate(ref SystemState state)
{
float radius = SystemAPI.GetSingleton<Config>().SafeZoneRadius;
const float debugRenderStepInDegrees = 20;
for (float angle = 0; angle < 360; angle += debugRenderStepInDegrees)
{
var a = float3.zero;
var b = float3.zero;
math.sincos(math.radians(angle), out a.x, out a.z);
math.sincos(math.radians(angle + debugRenderStepInDegrees), out b.x, out b.z);
Debug.DrawLine(a * radius, b * radius);
}
m_TurretActiveFromEntity.Update(ref state);
var safeZoneJob = new SafeZoneJob
{
TurretActiveFromEntity = m_TurretActiveFromEntity,
SquaredRadius = radius * radius
};
safeZoneJob.ScheduleParallel();
}
}
}

这里使用了一个[WithAll(typeof(Turret))]是为了指定这个IJobEntity应该包含作Turret组件。官网文档地址:WithAllAttribute。其实这个在这里是没有必要,毕竟有添加Shooting的只有拥有Turret组件的实体。这里我想官方只是为了介绍一下WithAllAttribute。         这里需要注意的是在SafeZoneJob中,我们对TurretActiveFromEntity进行了[NativeDisableParallelForRestriction]的声明。官方注释为:并行运行该作业时,安全系统将抱怨与 TurretActiveFromEntity 的潜在竞争条件,因为从不同线程访问相同实体将导致问题。但该作业的代码是这样编写的:在 TurretActiveFromEntity 中仅查找目前正在处理的实体,这确保了该流程的安全性。因此,我们可以禁用并行安全检查。         其实这段代码逻辑很简单就只是判断一下条件然后对Shooting进行启用和禁用。如果你要看到Debug.DrawLine画出来的安全区,则要将编辑器中的Gizmo启用。并且也只有在Scene窗口中才可以看得到。接下来我们继续修改TurretShootingSystem脚本:

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
using Unity.Burst;
using Unity.Collections;
using Unity.Entities;
using Unity.Mathematics;
using Unity.Rendering;
using Unity.Transforms;

namespace ESCLearn
{
public partial struct TurretShootingSystem : ISystem
{
ComponentLookup<LocalToWorld> m_LocalToWorldFromEntity;

[BurstCompile]
public void OnCreate(ref SystemState state)
{
m_LocalToWorldFromEntity = state.GetComponentLookup<LocalToWorld>(true);
}

[BurstCompile]
public void OnUpdate(ref SystemState state)
{
m_LocalToWorldFromEntity.Update(ref state);
var ecbSingleton = SystemAPI.GetSingleton<BeginSimulationEntityCommandBufferSystem.Singleton>();
var ecb = ecbSingleton.CreateCommandBuffer(state.WorldUnmanaged);
var turretShootJob = new TurretShoot
{
LocalToWorldEntity = m_LocalToWorldFromEntity,
ECB = ecb
};
turretShootJob.Schedule();
}

}

[WithAll(typeof(Shooting))]
[BurstCompile]
public partial struct TurretShoot : IJobEntity
{
[ReadOnly] public ComponentLookup<LocalToWorld> LocalToWorldEntity;
public EntityCommandBuffer ECB;

public void Execute(TurretAspect turret)
{
var instance = ECB.Instantiate(turret.CannonBallPrefab);
var spawnLocalToWorld = LocalToWorldEntity[turret.CannonBallSpawn];
var cannonBallTransform = LocalTransform.FromPosition(spawnLocalToWorld.Position);
var prefabInfo = LocalToWorldEntity[turret.CannonBallPrefab];
float4x4 val = prefabInfo.Value;
ECB.SetComponent(instance, cannonBallTransform);
ECB.AddComponent(instance, new PostTransformMatrix
{
Value = val
});
ECB.SetComponent(instance, new CannonBall
{
Speed = spawnLocalToWorld.Forward * 20.0f
});
ECB.SetComponent(instance, new URPMaterialPropertyBaseColor
{
Value = turret.Color
});
}
}
}

其就是在之前的IJobEntity上再添加了一个WithAllAttribute。所以当IEnableableComponent其值为False的时候,WithAllAttribute会认为这个实体并不存在此IEnableableComponent。

MonoBehavior和Dots间的通信

        我们很难使用纯的Dots代码来进行游戏的建设。特别的有关UI的部分,我们还需要使用MonoBehavior。至少现在的我想不出如何使用Dots去实现。官方教程中介绍了摄像机跟随的实现,来引出MonoBehavior和Dots之间的简单互动。         首先我们先创建名为CameraSingleton的脚本,其内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

namespace ESCLearn
{
public class CameraSingleton : MonoBehaviour
{
public static Camera Instance;

private void Awake()
{
Instance = GetComponent<Camera>();
}
}
}

然后将这个脚本挂载到你想要的相机上。官方教程就是说主相机上。然后我们创建名为CameraSystem的脚本,其内容如下:

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
using Unity.Collections;
using Unity.Entities;
using Unity.Mathematics;
using Unity.Transforms;
using UnityEngine;
using Random = Unity.Mathematics.Random;

namespace ESCLearn
{
[UpdateInGroup(typeof(LateSimulationSystemGroup))]
public partial class CameraSystem : SystemBase
{
Entity Target;
Random Random;
EntityQuery TanksQuery;
protected override void OnCreate()
{
Random = Random.CreateFromIndex(1234);
TanksQuery = GetEntityQuery(typeof(Tank));
RequireForUpdate(TanksQuery);
}

protected override void OnUpdate()
{
if (Target == Entity.Null || Input.GetKeyDown(KeyCode.Space))
{
var tanks = TanksQuery.ToEntityArray(Allocator.Temp);
Target = tanks[Random.NextInt(tanks.Length)];
}
var cameraTransform = CameraSingleton.Instance.transform;
var tankTransform = SystemAPI.GetComponent<LocalToWorld>(Target);
cameraTransform.position = tankTransform.Position - 10.0f * tankTransform.Forward + new float3(0.0f, 5.0f, 0.0f);
cameraTransform.LookAt(tankTransform.Position, new float3(0.0f, 1.0f, 0.0f));
}
}
}

这里官方使用了单例和SystemBase来完成通信。这样的方法看似简单但是非常好用。代码中使用UpdateInGroup,官方给出的注释是系统应在变换系统更新后运行,否则摄像机将比坦克滞后一帧,并且会抖动。

最后:碎碎念

        我写这篇文章的主要目的还是为了防止我本人之后要是太久没有使用Dots而忘记如何使用了。主要还是因为我放完国庆假期回来后,很多东西就忘了。再加上我觉得官方教程挺难看的,尤其是其给代码的方式,还有不能跳转到我想要的地方。不过看了一下我创建文章的日期是2023.9.17,而我完成的时间是2023.10.11。我想要不是国庆回来后我忘记怎么用Dots,我想我也不会把它赶出来吧。明明很多都是照抄官方教程的,不过我也花费了快一周的时间去写。有些离谱了。

        2023.10.17补充:修正了一些错误,如果大家还有发现错误帮忙指出来,我在此谢过了。