C++/Game

[게임 프레임워크 개발 일지] #13 Collider System

Kareus 2023. 5. 26. 22:35

2D Collider를 만들고, Broadphase까지 적용한 건 좋았는데

그래서 캐릭터와 벽이 충돌하는 건 언제 볼 수 있느냐가 문제입니다.

 

Entity 간의 상호작용이므로, 충돌 상호작용을 전담할 System을 설계해야 합니다.

여기서 만들 Collider System은 (처리할 Scene 내의) Entity 목록을 받아와서

1. 각 Entity의 collider boundary를 계산해서 AABB를 만들고

2. 그 AABB에 대해 broadphase를 거친 뒤에

3. 실제 collider 끼리 비교해서 충돌한 두 Entity pair에게 Collided Event를 호출해야 합니다.

 

여기서 Entity가 collider를 갖고 있어야 하니, Collidable이라는 Component를 가진 Entity로 한정하겠습니다.

 

Collider는 여러 개의 shape으로 구성될 수 있습니다.

struct Collider
{
private:
    std::vector<Shape*> colliders;
    
public:
    void addShape(AABB aabb);
    void addShape(Circle circle);
    void addShape(Polygon polygon);
    void removeShape(size_t index);
    void clear();
    
    template <typename T = Shape>
    T* getShape(size_t index) const
    {
        return reinterpret_cast<T*>(colliders.at(index));
    }
    
    AABB computeAABB() const
    {
        if (colliders.empty()) return AABB();
        
        AABB aabb = colliders[0]->computeAABB();
        
        for (size_t i = 1; i < colliders.size(); i++)
        {
            auto shape = colliders[i]->computeAABB();
            aabb.min.x = std::min(aabb.min.x, shape.min.x);
            aabb.min.y = std::min(aabb.min.y, shape.min.y);
            aabb.max.x = std::max(aabb.max.x, shape.max.x);
            aabb.max.y = std::max(aabb.max.y, shape.max.y);
        }
        
        return aabb;
    }
}

대충 필요한 것만 적었습니다.

각 도형에 대해 AABB를 계산하는 건 간단합니다.

AABB는 그대로 반환하면 되고, Circle은 중심을 기준으로 반지름을 빼거나 더하면 됩니다.

Polygon은 여기서와 마찬가지로 각 좌표에 대해 min/max를 써서 포함 boundary를 구하면 됩니다.

 

Collidable은 이 Collider를 멤버 변수로 갖고 있습니다.

Collider를 여러 개 가질 수 있게 할까 생각해봤는데, Collider부터도 shape을 여러 개 갖고 있어서

충돌 검사할 때도 번거롭고, 각 Collider마다 이벤트 처리가 달라지면 의미가 있겠는데

그러자니 Collidable 하나로 너무 무거워지는 느낌이 있어서 하나만 포함하기로 했습니다.

 

서로 다른 상호작용이 필요한 경우

 

이렇게 되니, 물체 바깥에서는 조사 상호작용을, 물체 자체와는 벽 상호작용을 해야되는 경우를 깔끔하게 만들 수가 없었습니다. Entity를 2개 만들면 되기야 한데, 얘가 움직이면 둘 다 움직여야 되니 두 Entity 위치가 어긋나서 문제가 나진 않을까 싶더군요.

 

그래서 Entity Hierarchy가 필요하게 됐습니다. 구현은 간단하게 했습니다.

class Entity
{
protected:
    ...
    Entity* parent = nullptr;
    std::vector<Entity*> children;
}

void renderHierarchy(sf::RenderTarget& target, sf::RenderStates state, Entity* current)
{
    if (current == nullptr) return;
    if (current->has<Renderable>())
    {
        auto graphic = current->getComponent<Renderable>().getGraphic(); //sf::Drawable*
        if (graphic)
        {
            target.draw(*graphic, state);
            
            if (auto transform = dynamic_cast<sf::Transformable*>(graphic))
                state.transform *= transform->getTransform(); //parent transform for children
        }
        
        for (size_t i = 0; i < current->getNumChildren(); i++)
            renderHierarchy(target, state, current->getChildAt(i));
    }
}

Child Entities를 렌더링하는 좌표는 parent의 좌표 기준이므로, transform을 적용시켜주면 됩니다.

 

 

다시 돌아와서, Collidable은 Collider를 하나 가지고 있고, 충돌 이벤트를 처리해야 합니다.

처음에는 충돌이 발생하는 경우만을 생각했는데, 좀 생각해보니 충돌 시작과 충돌 끝 이벤트도 있으면 좋겠다 싶었습니다.

Unity에서도 그런 이벤트를 제공하고 있기도 하고...

 

struct Collidable
{
public:
    enum CollisionEventType
    {
        BeginCollision, Collision, EndCollision
    }
    
    void call(Collidable& A, Collidable& B); //BeginCollision
    void call(Collidable& A); //EndCollision
    void call(Collidable& A, Collidable& B, sf::Vector2f velocity); //Collision
    
private:
    Collider collider;
    sf::Transformable* transform;
    
    std::unordered_map<unsigned int, std::funciton<void(Collidable&, Collidable&)>> callback_begin;
    std::unordered_map<unsigned int, std::function<void(Collidable&, Collidable&, sf::Vector2f>> callback_col;
    ... callback_end;
    
public:
    Collider& getCollider();
    sf::Transformable* getObject();
}

callback_end는 귀찮아서 타입을 안 썼습니다. 충돌이 끝났기 때문에 충돌한 상대방만 받아오면 됩니다.

후처리가 필요없는 경우에는 void()여도 됩니다.

transform은 Collidable이 적용되는 Object입니다.

Object의 AABB 등을 계산할 때 Object의 transform (position, scale 등)을 적용해야 하기 때문에 타입을 sf::Transformable로 지정해줬습니다.

 

그리하여 ColliderSystem에서는 충돌이 처음 시작한 시점과, 끝나는 시점을 판별할 수 있어야 합니다.

시점 판별 알고리즘은 더 좋은 방법이 있을지도 모르겠으나, 저는 검색할 방법을 모르기 때문에 (검색하면 충돌 여부 알고리즘만 나오네요) 대충 각 entity마다 충돌했는지 여부를 저장하게 했습니다.

 

entity id와 상관없이 단순 충돌 여부만 판단한다면 Entity마다 Collidable에 bool 변수를 하나 만들고 이걸 toggling 해주면 되겠지만, 보통은 그렇지 않다보니 이중으로 map (혹은 set)을 만들어줘야 했습니다.

 

struct Collidable
{
    ...
 private:
    std::unordered_set<entt::entity> colliding;
    
 public:    
    bool isColliding() const;
    
    bool isColliding(entt::entity id) const;
}

bool Collidable::isColliding() const
{
    return !colliding.empty();
}

bool Collidable::isColliding(entt::entity id) const
{
    return colliding.contains(id);
}

ColliderSystem에서는 충돌을 검사하는 pair에 대해서, 충돌하는 경우 이벤트를 발생시켜야 합니다.

이미 충돌하고 있는 경우에는 BeginCollision 이벤트가 발생하면 안 됩니다.

 

ColliderSystem에서는 Collidable의 colliding을 수정해야 하고, 그 외에는 외부에서 수정하면 안 되는 탓에

friend class로 선언해줬습니다.

 

struct Collidable
{
private:
    ...
    friend class CollisionSystem;
    
    void updateColliding(bool new_colliding, entt::entity id, Collidable& collider);
    
...
}

void Collidable::updateColliding(bool new_colliding, entt::entity id, Collidable& collider)
{
    if (colliding.contains(id) == new_colliding)
        return;

    new_colliding ? call(*this, collider) : call(collider);

    if (new_colliding)
        colliding.insert(id);
    else
        colliding.erase(id);
}

 

ColliderSystem의 update 함수에는 다음과 같이 작성했습니다.

auto pairs = broadPhase->broadphase(entities); //entities are entity list
Vec2 vec;
std::unordered_map<entt::entity, std::unordered_set<entt::entity>> hit_entities;

for (auto& p : pairs)
{
    auto entityA = p.first, entityB = p.second; //Entity
    auto idA = entityA.getID(), idB = entityB.getID(); //entt::entity
    auto& colliderA = entityA->getComponent<Collidable>();
    auto& colliderB = entityB->getComponent<Collidable>();
    
    if (Collide(colliderA, colliderB, &vec))
    {
        sf::Vector2f velocity = sf::Vector2f(vec.x, vec.y);
        
        //call BeginCollision Event
        if (!colliderA.isColliding(idB))
        {
            hit_entities[idA].insert(idB);
            colliderA.updateColliding(true, idB, colliderB);
        }
        
        if (!colliderB.isColliding(idA))
        {
            hit_entities[idB].insert(idA);
            colliderB.updateColliding(true, idA, colliderA);
        }
        
        //call Collision Event
        colliderA.call(colliderA, colliderB, -velocity);
        colliderB.call(colliderB, colliderA, velocity);
    }
    
    for (auto& p : hit_entities)
	{
        auto& collider = entityManager->getEntity(p.first)->getComponent<Collidable>();

        for (auto& entity : collider.colliding)
            if (!p.second.contains(entity))
                collider.updateColliding(false, entity, entityManager->getEntity(entity)->getComponent<Collidable>());
    }
}

 

Collide 함수는 Collidable의 transform을 불러와 적용한 각 shape끼리 충돌 검사를 해주면 됩니다.

bool ColliderSystem::Collide(Collidable& A, Collidable& B, SP2C::Vec2* result)
{
    Manifold manifold;
    Vec2 v;

    auto& colliderA = A.getCollider();
    auto& colliderB = B.getCollider();

    auto transformA = A.getObject()->getTransform(), transformB = B.getObject()->getTransform();

    size_t sizeA = colliderA.size(), sizeB = colliderB.size();

    size_t touchCount = 0;
    std::vector<std::unique_ptr<Shape>> shapesA, shapesB;

    for (size_t i = 0; i < sizeA; i++)
    {
        std::unique_ptr<Shape> shape;
        shape.reset(Utils::transformCollider(transformA, colliderA.getShape(i)));
        shapesA.push_back(std::move(shape));
    }

    for (size_t i = 0; i < sizeB; i++)
    {
        std::unique_ptr<Shape> shape;
        shape.reset(Utils::transformCollider(transformB, colliderB.getShape(i)));
        shapesB.push_back(std::move(shape));
    }

    for (auto& shapeA : shapesA)
    {
        manifold.A = shapeA.get();

        for (auto& shapeB : shapesB)
        {
            manifold.B = shapeB.get();

            if (result)
            {
                if (Collision::Collide(&manifold))
                {
                    touchCount++;
                    v += manifold.normal * manifold.penetration;
                }
            }
            else
            {
                if (Collision::Collide(manifold.A, manifold.B))
                    return true;
            }
        }
    }

    if (touchCount > 0)
        v /= touchCount;

    if (result)
        *result = v;

    return touchCount > 0;
}

 

Collision::Collide 함수는 이 포스팅을 참고해주세요.

SFML의 transform은 3D format이기 때문에, Shape에 transform을 적용할 수 있게 변환시켜줘야 합니다.

Shape* transformCollider(const sf::Transform& transform, Shape* shape)
{
    switch (shape->type)
    {
    case ShapeType::AABB:
        return transformCollider(transform, *reinterpret_cast<AABB*>(shape)).Clone();

    case ShapeType::Circle:
        return transformCollider(transform, *reinterpret_cast<Circle*>(shape)).Clone();

    case ShapeType::Polygon:
        return transformCollider(transform, *reinterpret_cast<Polygon*>(shape)).Clone();
    }

    return nullptr;
}

AABB transformCollider(const sf::Transform& transform, AABB shape)
{
    shape.Transform(convert_mat3d_to_2d(transform.getMatrix()));
    return shape;
}

...

Mat2D convert_mat3d_to_2d(const float* mat_3d)
{
    Mat2D mat_2d;
    mat_2d.m[0][0] = mat_3d[0];
    mat_2d.m[0][1] = mat_3d[4];
    mat_2d.m[0][2] = mat_3d[12];

    mat_2d.m[1][0] = mat_3d[1];
    mat_2d.m[1][1] = mat_3d[5];
    mat_2d.m[1][2] = mat_3d[13];

    mat_2d.m[2][0] = mat_3d[3];
    mat_2d.m[2][1] = mat_3d[7];
    mat_2d.m[2][2] = mat_3d[15];

    return mat_2d;
}

 

이제 벽 상호작용을 하게끔 만들어서 테스트해봅시다.

 

//EntityManager manager;

auto entity = manager.createEntity<EntityObject>();
auto entity2 = manager.createEntity<EntityObject>();

entity->init(Storage::getTexture("player_walk_L"));
entity2->init(Storage::getTexture("player_walk_L"));

AABB shape1;
shape1.SetBox(32, 94); //(-16, -47), (16, 47)

auto& collidable1 = entity->getComponent<Collidable>();

Collider& collder1 = collidable1.getCollider();
collider1.addShape(shape1);

AABB shape2;
shape2.SetBox(32, 94);

auto& collidable2 = entity2->getComponent<Collidable>();

Collider& collider2 = collidable2.getCollider();
collider2.addShape(shape2);

//
collider1.addCollisionCallback([](auto& A, auto& B, auto v) {
    A.getObject()->move(v);
});
//collision event

texture 설정에서 보시다시피, 둘 다 플레이어가 왼쪽으로 걸어가는 이미지로 고정해놨습니다.

다른 이미지로 해야 이쁘게 보이기는 한데, 뭐... 귀찮잖아요.

 

Collision Event로 움직이게 한 쪽이 플레이어고, 이벤트가 없는 다른 한 쪽이 벽입니다.

플레이어를 키보드 등으로 움직이게 하고, 컴파일하면 다음과 같이 작동합니다.

 

철 괴

 

이렇게 구현을 끝냈습니다.

만들면서 시행착오가 되게 많았습니다.

처음에는 Collidable에 물체가 움직이게 할지, 다른 물체를 통과시킬지나 막을지 뭐 이런 여부를 정하도록 설계했었는데

뭔가 영 제약이 생기고 복잡해지더라구요.

 

다음에 뭐 할지를 생각해야겠습니다.

게임에 필수적인 다른 상호작용은 딱히 생각나는 게 없으니 사운드나 세이브를 구현해야 될 것 같네요.