[Unity] ECS中的重复多态性

英文原文:
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++,它本身就很冗长。

上一篇:js回顾


下一篇:js判断两个变量或常量是否相等