英文原文:
https://coffeebraingames.wordpress.com/2019/09/15/replicating-polymorphism-in-ecs/
多态性是那些难以摆脱的OOP基础之一。它很容易做到,而且非常直观。我们用它来重构代码,使其更有条理。我们用它来管理不同的行为,同时只保持一个单一的接口。我们还用它来制作全面的创作编辑器,通过与反射相辅相成,影响运行时的行为。
然而,在一个不允许引用类型的环境中,它是无法做到的(Unity的HPC#)。它可以在ECS中以另一种方式进行复制,这就是本帖的内容。
OOP版本
比方说,我们有一个投射物的框架。我们还可以说,我们的游戏世界是带有魔法的蒸汽朋克。我们希望能够同时支持子弹和魔法弹丸,如火球。我们的OOP代码可能看起来像这样:
public abstract class Projectile {
// Common projectile properties
protected Vector2 position;
private int damage;
// Each projectile type may implement its own movement
public abstract void Move();
// Each projectile might have different effects on impact
public abstract void OnImpact();
public int Damage {
get {
return this.damage;
}
}
}
public class Bullet : Projectile {
private readonly Vector2 direction;
private readonly float speed;
public Bullet(Vector2 direction, float speed) {
this.direction = direction.normalized;
this.speed = speed;
}
public override void Move() {
// Move by speed in straight line
this.position += this.speed * Time.deltaTime * this.direction;
}
public override void OnImpact() {
// Maybe just destroy the bullet here
}
}
public class Fireball : Projectile {
private readonly float initialVelocity;
private readonly float angle;
private readonly float gravity;
private readonly float vX;
private readonly float vYPart;
private float polledTime;
public Fireball(float initialVelocity, float angle, float gravity) {
this.initialVelocity = initialVelocity;
this.angle = angle;
this.gravity = gravity;
// Cache
this.vX = this.initialVelocity * Mathf.Cos(this.angle);
this.vYPart = this.initialVelocity * Mathf.Sin(this.angle);
}
public override void Move() {
// Move by projectile motion
// There are better ways to do this but just bare with me
this.polledTime += Time.deltaTime;
// Update X
this.position.x += this.vX * Time.deltaTime;
// Update Y
float vY = this.vYPart - this.gravity * this.polledTime;
this.position.y += vY * Time.deltaTime;
}
public override void OnImpact() {
// Destroy the projectile then send a request to show a fireball impact particle effect
// at the current position
}
}
然后我们可以这样实现处理抛射物的类:
public class ProjectileManager {
private readonly List<Projectile> projectiles = new List<Projectile>();
public void Add(Projectile projectile) {
this.projectiles.Add(projectile);
}
public void Update() {
for (int i = 0; i < this.projectiles.Count; ++i) {
this.projectiles[i].Move();
}
CheckForCollisions();
}
private void CheckForCollisions() {
// Let's just say a list of collisions exists
foreach(Collision c in this.collisions) {
// Apply damage if health component exists
if(c.Health != null) {
c.Health.Value -= c.Projectile.Damage;
}
// Execute custom on impact routines
c.Projectile.OnImpact();
}
}
}
这种臆造的弹射系统应该很容易理解。有在一定方向上直线运动的子弹和以弹射运动的火球。它们都可以由ProjectileManager来处理,因为它们继承了Projectile基类。
ECS Version
在Unity的纯ECS中,不能使用类,当然也不能使用继承。但我认为这是一件好事,因为在ECS中需要一种不同的思维方式。在使用ECS对游戏元素进行建模时,我们必须忘记OOP。
让我们从我们的投射物组件开始:
public struct Projectile : IComponentData {
public float2 position;
public readonly int damage;
public Projectile(float2 position, int damage) {
this.position = position;
this.damage = damage;
}
}
我们对这个组件的意图是,任何拥有这个组件的实体都被认为是一个射弹。这个组件可以被系统用来只过滤具有这种组件的实体,然后执行可以应用于所有射弹的一般或通用逻辑。可以把它看作是基类中的代码。
接下来是代表子类的组件:
public struct Bullet : IComponentData {
public readonly float2 direction;
public readonly float speed;
public Bullet(float2 direction, float speed) {
this.direction = math.normalize(direction);
this.speed = speed;
}
}
public struct Fireball : IComponentData {
public readonly float initialVelocity;
public readonly float angle;
public readonly float gravity;
public readonly float vX;
public readonly float vYPart;
public float polledTime;
public Fireball(float initialVelocity, float angle, float gravity) {
this.initialVelocity = initialVelocity;
this.angle = angle;
this.gravity = gravity;
// Cache
this.vX = this.initialVelocity * math.cos(this.angle);
this.vYPart = this.initialVelocity * math.sin(this.angle);
this.polledTime = 0;
}
}
我们只是把它们的数据移到它们自己的组件中。为了给一个子弹射出物建模,我们创建了一个具有射出物和子弹组件的实体。同样的情况也适用于火球弹。
// Create a bullet
Entity bullet = entityManager.CreateEntity(typeof(Projectile), typeof(Bullet));
// Create a fireball
Entity fireball = entityManager.CreateEntity(typeof(Projectile), typeof(Fireball));
从这里,我们就可以定义不同的运动系统:
// Using ComponentSystem here instead of JobComponentSystem so that it's
// easier to understand
public class BulletMoveSystem : ComponentSystem {
private EntityQuery query;
protected override void OnCreate() {
this.query = GetEntityQuery(typeof(Projectile), typeof(Bullet));
}
protected override void OnUpdate() {
this.Entities.With(this.query).ForEach(delegate(ref Projectile projectile, ref Bullet bullet) {
projectile.position = projectile.position + (bullet.speed * Time.deltaTime * bullet.direction);
});
}
}
public class FireballMoveSystem : ComponentSystem {
private EntityQuery query;
protected override void OnCreate() {
this.query = GetEntityQuery(typeof(Projectile), typeof(Fireball));
}
protected override void OnUpdate() {
this.Entities.With(this.query).ForEach(delegate(ref Projectile projectile, ref Fireball fireball) {
// Move by projectile motion
fireball.polledTime += Time.deltaTime;
float2 newPosition = projectile.position;
newPosition.x += fireball.vX * Time.deltaTime;
float vY = fireball.vYPart - fireball.gravity * fireball.polledTime;
newPosition.y += vY * Time.deltaTime;
projectile.position = newPosition;
});
}
}
为了处理不同的 "撞击 "逻辑,一个单独的系统可以处理检查碰撞检测,然后给发生碰撞的实体添加碰撞标签组件。然后,独立的系统将处理伤害处理和 "撞击 "程序。这里是处理碰撞检测的系统:
// Component that is added to entities that have collided
public struct Collided : IComponentData {
public readonly Entity other; // The other entity that we collided with
}
public class ProjectileCollisionDetectionSystem : ComponentSystem {
private EntityQuery query;
protected override void OnCreate() {
this.query = GetEntityQuery(typeof(Projectile), ComponentType.Exclude<Collided>());
}
protected override void OnUpdate() {
// Adds Collided component to entities that have collided
}
}
应用伤害的系统可以是这样的:
// Component representing health
public struct Health : IComponentData {
public int amount;
}
public class ProjectileDamageSystem : ComponentSystem {
private EntityQuery query;
protected override void OnCreate() {
this.query = GetEntityQuery(typeof(Projectile), typeof(Collided));
}
protected override void OnUpdate() {
ComponentDataFromEntity<Health> allHealth = GetComponentDataFromEntity<Health>();
this.Entities.With(this.query).ForEach(delegate(ref Projectile projectile, ref Collided collided) {
// Apply damage
Health health = allHealth[collided.other];
health.amount -= projectile.damage;
allHealth[collided.other] = health; // Modify
});
}
}
也可以在自己的系统中实现 "受影响 "的程序。
public class BulletOnImpactSystem : ComponentSystem {
private EntityQuery query;
protected override void OnCreate() {
this.query = GetEntityQuery(typeof(Projectile), typeof(Collided), typeof(Bullet));
}
protected override void OnUpdate() {
// Just destroy them
this.EntityManager.DestroyEntity(this.query);
}
}
public class FireballOnImpactSystem : ComponentSystem {
private EntityQuery query;
protected override void OnCreate() {
this.query = GetEntityQuery(typeof(Projectile), typeof(Collided), typeof(Fireball));
}
protected override void OnUpdate() {
this.Entities.With(this.query).ForEach(delegate(ref Projectile projectile) {
// Request fireball particle effect at projectile.position
});
// Then destroy them
this.EntityManager.DestroyEntity(this.query);
}
}
在这一点上,我们已经将OOP的逻辑复制到其ECS版本。
更理想的ECS解决方案
从我们最初的重构中,我们可以对一些组件进行修改,使它们更容易被重用。
我们可以改进的一个方面是运动。与其使用Bullet组件进行直线运动,为什么不把它定义为自己的组件,如StraightDirectionMovement。这样,我们就可以在游戏中的其他需要这种运动的元素上重复使用它。在这样做之前,我们还需要从Projectile中移除位置属性,并使用一个单独的组件代表它。这就是新的运动系统的模样:
// Holds the projectile's position
public struct Position : IComponentData {
public float2 value;
}
public struct StraightDirectionMovement : IComponentData {
public readonly float2 direction;
public readonly float speed;
public StraightDirectionMovement(float2 direction, float speed) {
this.direction = math.normalize(direction);
this.speed = speed;
}
}
public class StraightDirectionMovementSystem : ComponentSystem {
private EntityQuery query;
protected override void OnCreate() {
this.query = GetEntityQuery(typeof(Position), typeof(StraightDirectionMovement));
}
protected override void OnUpdate() {
this.Entities.With(this.query).ForEach(delegate(ref Position position, ref StraightDirectionMovement movement) {
position.value = position.value + (movement.speed * Time.deltaTime * movement.direction);
});
}
}
以同样的方式,火球的弹射运动也可以变成自己的组件。假设我们把它叫做ProjectileMotionMovement。
public struct ProjectileMotionMovement : IComponentData {
public readonly float initialVelocity;
public readonly float angle;
public readonly float gravity;
public readonly float vX;
public readonly float vYPart;
public float polledTime;
public ProjectileMotionMovement(float initialVelocity, float angle, float gravity) {
this.initialVelocity = initialVelocity;
this.angle = angle;
this.gravity = gravity;
// Cache
this.vX = this.initialVelocity * math.cos(this.angle);
this.vYPart = this.initialVelocity * math.sin(this.angle);
this.polledTime = 0;
}
}
public class ProjectileMotionMovementSystem : ComponentSystem {
private EntityQuery query;
protected override void OnCreate() {
this.query = GetEntityQuery(typeof(Position), typeof(ProjectileMotionMovement));
}
protected override void OnUpdate() {
this.Entities.With(this.query).ForEach(delegate(ref Position position, ref ProjectileMotionMovement movement) {
// Move by projectile motion
movement.polledTime += Time.deltaTime;
float2 newPosition = position.value;
newPosition.x += movement.vX * Time.deltaTime;
float vY = movement.vYPart - movement.gravity * movement.polledTime;
newPosition.y += vY * Time.deltaTime;
position.value = newPosition;
});
}
}
我们可以改进的另一个方面是撞击时的程序。与其让BulletOnImpactSystem只对带有Bullet组件的实体起作用,不如让它更容易重复使用。让我们用一个名为DestroyOnCollision的组件来代替:
// A tag component that identifies an entity to be removed on collision
public struct DestroyOnCollision : IComponentData {
}
public class DestroyOnCollisionSystem : ComponentSystem {
private EntityQuery query;
protected override void OnCreate() {
this.query = GetEntityQuery(typeof(Collided), typeof(DestroyOnCollision));
}
protected override void OnUpdate() {
// Just destroy them
this.EntityManager.DestroyEntity(this.query);
}
}
请求像火球撞击那样的粒子效果也可以是它自己的组件和系统。比方说,我们有一个名为RequestParticleEffectOnCollision的组件:
public struct RequestParticleEffectOnCollision : IComponentData {
// Used to identify what particle effect to deploy
public readonly int effectId;
public RequestParticleEffectOnCollision(int effectId) {
this.effectId = effectId;
}
}
[UpdateBefore(typeof(DestroyOnCollisionSystem))]
public class RequestParticleEffectOnCollisionSystem : ComponentSystem {
private EntityQuery query;
protected override void OnCreate() {
this.query = GetEntityQuery(typeof(Position), typeof(Collided), typeof(RequestParticleEffectOnCollision));
}
protected override void OnUpdate() {
this.Entities.With(this.query).ForEach(delegate(ref Position position, ref RequestParticleEffectOnCollision effectRequest) {
// Request the particle effect at position.value
});
// Destruction of the entity will now be handled by DestroyOnCollisionSystem
}
}
有了上面的系统,在游戏中为子弹物体建模,现在看起来是这样的:
Entity bullet = entityManager.CreateEntity(typeof(Position),
typeof(Projectile),
typeof(StraightDirectionMovement),
typeof(DestroyOnCollision));
请注意,我们已经完全删除了 "子弹 "的概念。现在,一颗子弹是由构成其行为的部件组成的。这对火球也是一样的:
Entity fireball = entityManager.CreateEntity(typeof(Position),
typeof(Projectile),
typeof(ProjectileMotionMovement),
typeof(RequestParticleEffectOnCollision),
typeof(DestroyOnCollision));
最后的想法
很明显,把OOP变成ECS需要更多的代码。对此,我只能说…它就是它。不幸的是,我们只是用C#结构来模拟ECS。目前还没有这样一种意识到ECS的编程语言(还没有)可以大大减少这些代码。我认为这是一种交易。我得到了高度模块化和高效代码的好处,但却牺牲了冗长的语言。
老实说,冗长的代码并不是一个沉重的代价。我可以拥有尽可能快的代码,而不需要切换到另一个更复杂的代码,如C++,它本身就很冗长。