C++ 게임 엔진 기초 | 렌더링·물리·입력·스크립팅 시스템 구현 [#50-3]

들어가며: “캐릭터가 바닥을 뚫고 떨어지며, 충돌이 이상하게 튕겨요”

게임 엔진을 만들다 보면 겪는 문제들

Unity나 Unreal 같은 상용 엔진 없이 직접 2D 게임을 만들다 보면 이런 문제를 겪습니다:

  • 엔티티가 바닥을 뚫고 떨어짐 — 충돌 감지 순서나 AABB 경계 계산 오류
  • 프레임마다 렌더 순서가 뒤섞임 — Z-index 정렬이 없거나 레이어 시스템 부재
  • 입력이 프레임에 묶여 반응이 느림 — 이벤트 기반이 아닌 폴링만 사용
  • 게임 로직과 엔진 코드가 뒤섞여 수정이 어려움 — 스크립팅 분리 미흡 이 글에서는 ECS 아키텍처를 기반으로 렌더링, 물리, 입력, 스크립팅을 통합한 2D 게임 엔진 기초를 다룹니다.

목표:

  • ECS (Entity Component System) 아키텍처
  • 2D 렌더링 파이프라인 (SDL2/SFML)
  • 물리 시뮬레이션 (충돌 감지, 강체 동역학)
  • 입력 처리 및 이벤트 시스템
  • Lua 스크립팅 통합 요구 환경: C++17 이상, SDL2 또는 SFML, Box2D(선택), Lua 5.4

SDL2 기반 ECS 구조

왜 ECS인가?

전통적인 상속 기반 게임 오브젝트는 “다중 상속 지옥”과 “다이아몬드 상속” 문제를 일으킵니다. ECS는 조합(Composition) 방식으로, 엔티티에 필요한 컴포넌트만 붙여 유연하게 확장합니다.

flowchart TB
    subgraph ECS[ECS 아키텍처]
        E[Entity]
        C1[TransformComponent]
        C2[SpriteComponent]
        C3[RigidBodyComponent]
        C4[ColliderComponent]
        E --> C1
        E --> C2
        E --> C3
        E --> C4
    end
    subgraph Systems[시스템]
        S1[RenderSystem]
        S2[PhysicsSystem]
        S3[InputSystem]
    end
    C1 --> S1
    C2 --> S1
    C3 --> S2
    C4 --> S2

핵심 컴포넌트 정의

#include <glm/glm.hpp>
#include <SDL2/SDL.h>
#include <memory>
#include <string>
#include <typeindex>
#include <unordered_map>
using EntityID = uint32_t;
class Component {
public:
    virtual ~Component() = default;
};
// 위치·회전·스케일 — 모든 렌더/물리 엔티티에 필요
struct TransformComponent : Component {
    glm::vec2 position{0, 0};
    float rotation = 0.0f;
    glm::vec2 scale{1, 1};
};
// 스프라이트 렌더링용
struct SpriteComponent : Component {
    std::string texture_id;
    SDL_Rect src_rect;
    int z_index = 0;  // 렌더 순서 (낮을수록 뒤에 그려짐)
};
// 물리 속도·질량
struct RigidBodyComponent : Component {
    glm::vec2 velocity{0, 0};
    float mass = 1.0f;
    bool is_static = false;  // 바닥·벽 등 고정 오브젝트
};
// 충돌 영역 (AABB) — 물리 시스템에서 필수
struct ColliderComponent : Component {
    float width = 32.0f;
    float height = 32.0f;
    bool is_trigger = false;  // 통과 가능한 영역(트리거)
};
class Entity {
    EntityID id_;
    std::unordered_map<std::type_index, std::unique_ptr<Component>> components_;
public:
    explicit Entity(EntityID id) : id_(id) {}
    template <typename T, typename... Args>
    T& add_component(Args&&... args) {
        auto component = std::make_unique<T>(std::forward<Args>(args)...);
        auto* ptr = component.get();
        components_[typeid(T)] = std::move(component);
        return *ptr;
    }
    template <typename T>
    T* get_component() {
        auto it = components_.find(typeid(T));
        return it != components_.end() ? static_cast<T*>(it->second.get()) : nullptr;
    }
    template <typename T>
    bool has_component() const {
        return components_.find(typeid(T)) != components_.end();
    }
    EntityID get_id() const { return id_; }
};
class EntityManager {
    std::unordered_map<EntityID, std::unique_ptr<Entity>> entities_;
    EntityID next_id_ = 1;
public:
    Entity& create_entity() {
        auto id = next_id_++;
        auto entity = std::make_unique<Entity>(id);
        auto* ptr = entity.get();
        entities_[id] = std::move(entity);
        return *ptr;
    }
    void destroy_entity(EntityID id) {
        entities_.erase(id);
    }
    template <typename... Components>
    std::vector<Entity*> get_entities_with() {
        std::vector<Entity*> result;
        for (auto& [id, entity] : entities_) {
            if ((entity->has_component<Components>() && ...)) {
                result.push_back(entity.get());
            }
        }
        return result;
    }
};

주의점: get_entities_with는 매 프레임 호출 시 벡터를 새로 할당합니다. 고성능이 필요하면 컴포넌트별 인덱스를 유지하는 방식으로 최적화하세요.

이 구현은 ECS의 개념을 보여 주기 위한 가장 단순한 형태이고, 실제 ECS 라이브러리와는 저장 방식이 다릅니다. 여기서는 각 Entity가 unordered_map<type_index, unique_ptr<Component>>로 컴포넌트를 소유하므로, 컴포넌트 하나를 꺼낼 때마다 해시 조회와 가상 소멸자를 가진 객체의 포인터 추적이 일어나고 메모리도 힙 곳곳에 흩어집니다. 물리 시스템이 “모든 Transform과 RigidBody”를 훑을 때 CPU 캐시가 거의 도움이 되지 않는 구조입니다. EnTT 같은 라이브러리는 컴포넌트 타입별로 연속된 배열에 값을 담고, 엔티티는 그 배열의 인덱스를 가리키는 정수일 뿐입니다. 그래서 같은 순회가 배열을 앞에서부터 읽는 형태가 되어 엔티티 수가 많을수록 차이가 커집니다. 수백 개 수준의 2D 게임이라면 지금 구조로도 충분하고, 프로파일러에서 컴포넌트 조회가 상위에 올라올 때 저장소를 바꿔도 늦지 않습니다.

get_component가 raw 포인터를 돌려준다는 점도 기억해야 합니다. 같은 타입의 컴포넌트를 add_component로 다시 추가하면 맵의 기존 unique_ptr가 교체되면서 이전 객체가 파괴되므로, 그 전에 받아 둔 포인터는 댕글링이 됩니다. 엔티티 삭제도 마찬가지이며, 이 문제는 아래 문제 해결 절의 “엔티티 삭제 시 크래시” 항목에서 다시 다룹니다.


Z-index 정렬 렌더링 시스템

렌더링 파이프라인 흐름

sequenceDiagram
    participant GameLoop
    participant RenderSystem
    participant SDL
    GameLoop->>RenderSystem: update(entities)
    RenderSystem->>RenderSystem: Z-index 정렬
    RenderSystem->>SDL: RenderClear
    loop 각 엔티티
        RenderSystem->>SDL: RenderCopyEx
    end
    RenderSystem->>SDL: RenderPresent

RenderSystem 구현

#include <algorithm>
#include <SDL2/SDL_image.h>
class RenderSystem {
    SDL_Renderer* renderer_;
    std::unordered_map<std::string, SDL_Texture*> textures_;
public:
    explicit RenderSystem(SDL_Renderer* renderer) : renderer_(renderer) {}
    void update(EntityManager& entities) {
        auto entities_to_render =
            entities.get_entities_with<TransformComponent, SpriteComponent>();
        // Z-index 오름차순 정렬 (낮은 값이 먼저 = 뒤에 그려짐)
        std::sort(entities_to_render.begin(), entities_to_render.end(),
                   [](Entity* a, Entity* b) {
                      return a->get_component<SpriteComponent>()->z_index <
                             b->get_component<SpriteComponent>()->z_index;
                  });
        SDL_RenderClear(renderer_);
        for (auto* entity : entities_to_render) {
            auto* transform = entity->get_component<TransformComponent>();
            auto* sprite = entity->get_component<SpriteComponent>();
            auto it = textures_.find(sprite->texture_id);
            if (it == textures_.end()) continue;  // 텍스처 없으면 스킵
            SDL_Rect dest_rect = {
                static_cast<int>(transform->position.x),
                static_cast<int>(transform->position.y),
                static_cast<int>(sprite->src_rect.w * transform->scale.x),
                static_cast<int>(sprite->src_rect.h * transform->scale.y)};
            SDL_RenderCopyEx(renderer_, it->second, &sprite->src_rect,
                             &dest_rect, transform->rotation, nullptr,
                             SDL_FLIP_NONE);
        }
        SDL_RenderPresent(renderer_);
    }
    void load_texture(const std::string& id, const std::string& path) {
        SDL_Surface* surface = IMG_Load(path.c_str());
        if (!surface) return;
        SDL_Texture* texture = SDL_CreateTextureFromSurface(renderer_, surface);
        SDL_FreeSurface(surface);
        if (texture) textures_[id] = texture;
    }
    ~RenderSystem() {
        for (auto& [id, tex] : textures_) SDL_DestroyTexture(tex);
    }
};

렌더 시스템이 텍스처를 texture_id 문자열로 찾는 이유는 컴포넌트가 GPU 자원을 직접 소유하지 않게 하기 위해서입니다. 같은 스프라이트 시트를 쓰는 엔티티가 백 개여도 텍스처는 하나만 올라가고, 씬을 저장할 때도 문자열만 직렬화하면 됩니다. 대신 이 클래스는 raw SDL_Texture*를 소유하면서 복사 생성자를 막지 않았기 때문에, 복사되면 두 객체가 같은 텍스처를 각각 파괴하는 이중 해제가 생깁니다. 완성 예제의 GameEngine에서 unique_ptr로 들고 있는 것도 이 때문이며, 가능하면 RenderSystem(const RenderSystem&) = delete;를 선언해 두는 것이 안전합니다.

텍스처와 렌더러의 소멸 순서도 중요합니다. SDL_DestroyRenderer가 먼저 호출되면 그 렌더러로 만든 텍스처가 함께 해제되므로, 그 뒤에 RenderSystem 소멸자가 SDL_DestroyTexture를 부르면 이미 해제된 핸들을 다루게 됩니다. 종료 시에는 렌더 시스템을 먼저 파괴하고 렌더러를 나중에 파괴해야 합니다. 좌표를 static_cast<int>로 자르는 것도 눈여겨볼 부분입니다. 느리게 움직이는 오브젝트가 소수점 아래 이동을 버리면서 한 픽셀씩 끊어져 보이는 떨림이 생기는데, SDL 2.0.10 이상이라면 SDL_RenderCopyExF와 SDL_FRect로 실수 좌표를 그대로 넘길 수 있습니다.


AABB 충돌 물리 시뮬레이션

간단한 물리 엔진 (AABB 충돌)

class PhysicsSystem {
    glm::vec2 gravity_{0, 9.8f};
public:
    void update(EntityManager& entities, float dt) {
        auto physics_entities = entities.get_entities_with<
            TransformComponent, RigidBodyComponent, ColliderComponent>();
        // 1. 중력 적용
        for (auto* entity : physics_entities) {
            auto* rb = entity->get_component<RigidBodyComponent>();
            if (!rb->is_static) {
                rb->velocity += gravity_ * dt;
            }
        }
        // 2. 위치 업데이트
        for (auto* entity : physics_entities) {
            auto* transform = entity->get_component<TransformComponent>();
            auto* rb = entity->get_component<RigidBodyComponent>();
            if (!rb->is_static) {
                transform->position += rb->velocity * dt;
            }
        }
        // 3. 충돌 감지 및 해결
        check_collisions(physics_entities);
    }
    void check_collisions(const std::vector<Entity*>& entities) {
        for (size_t i = 0; i < entities.size(); ++i) {
            for (size_t j = i + 1; j < entities.size(); ++j) {
                if (check_collision(entities[i], entities[j])) {
                    resolve_collision(entities[i], entities[j]);
                }
            }
        }
    }
    bool check_collision(Entity* a, Entity* b) {
        auto* ta = a->get_component<TransformComponent>();
        auto* tb = b->get_component<TransformComponent>();
        auto* ca = a->get_component<ColliderComponent>();
        auto* cb = b->get_component<ColliderComponent>();
        if (!ca || !cb) return false;
        // AABB 충돌: 두 사각형이 겹치는지
        return ta->position.x < tb->position.x + cb->width &&
               ta->position.x + ca->width > tb->position.x &&
               ta->position.y < tb->position.y + cb->height &&
               ta->position.y + ca->height > tb->position.y;
    }
    void resolve_collision(Entity* a, Entity* b) {
        auto* rba = a->get_component<RigidBodyComponent>();
        auto* rbb = b->get_component<RigidBodyComponent>();
        if (!rba || !rbb) return;
        // 트리거는 물리 반응 없음
        auto* ca = a->get_component<ColliderComponent>();
        auto* cb = b->get_component<ColliderComponent>();
        if (ca->is_trigger || cb->is_trigger) return;
        // 정적 오브젝트와 충돌 시 속도 반전
        if (rbb->is_static) {
            rba->velocity.y = -rba->velocity.y * 0.8f;  // 탄성
        } else if (rba->is_static) {
            rbb->velocity.y = -rbb->velocity.y * 0.8f;
        } else {
            // 둘 다 동적: 속도 스왑 (간단한 탄성 충돌)
            auto temp = rba->velocity;
            rba->velocity = rbb->velocity;
            rbb->velocity = temp;
        }
    }
};

이 물리 시스템은 “중력 → 위치 적분 → 충돌 검사”라는 가장 기본적인 순서를 따릅니다. 속도를 먼저 갱신하고 그 속도로 위치를 옮기는 방식을 semi-implicit(symplectic) 오일러라고 하며, 위치를 먼저 옮기는 명시적 오일러보다 에너지가 덜 불어나 튀는 공이 점점 높이 뛰는 현상이 줄어듭니다.

직접 짜 보면 처음 부딪히는 문제는 대부분 resolve_collision에서 나옵니다. 이 함수는 속도만 뒤집고 위치는 보정하지 않습니다. 떨어지던 캐릭터가 바닥과 3픽셀 겹친 채로 속도가 반전되면, 다음 프레임에 중력이 다시 속도를 아래로 돌려놓아 여전히 겹친 상태에서 또 반전됩니다. 화면에서는 캐릭터가 바닥에서 부들부들 떨거나, 겹침이 누적돼 조금씩 파고드는 것으로 보입니다. 해결책은 두 사각형이 x축과 y축으로 각각 얼마나 겹쳤는지 계산해, 겹침이 작은 축으로 겹친 만큼 밀어낸 뒤 그 축의 속도만 바꾸는 것입니다. 지금 코드는 벽에 옆으로 부딪혀도 velocity.y를 뒤집는데, 이 축 판정을 하면 그 문제도 함께 풀립니다.

gravity_{0, 9.8f}의 단위도 확인해야 합니다. 위치가 픽셀이므로 이 값은 “초당 9.8픽셀”씩 빨라진다는 뜻이고, 화면에서는 캐릭터가 거의 떠 있는 것처럼 느리게 떨어집니다. 픽셀 좌표계에서는 수백~천 단위의 값을 쓰거나, Box2D처럼 미터 단위로 계산한 뒤 렌더링할 때만 픽셀로 바꾸는 방식을 씁니다. 또 SDL의 y축은 아래쪽이 양수이므로 중력의 y 성분이 양수인 것은 맞습니다. 수학 좌표계에 익숙하면 여기서 부호를 뒤집어 캐릭터가 하늘로 떨어지는 실수를 하기 쉽습니다.


폴링과 이벤트를 함께 쓰는 입력 처리

입력 시스템 (폴링 + 이벤트)

#include <functional>
#include <vector>
class InputSystem {
    std::unordered_map<SDL_Keycode, bool> key_states_;
    glm::vec2 mouse_position_{0, 0};
    bool quit_requested_ = false;
    using KeyCallback = std::function<void(SDL_Keycode)>;
    std::vector<KeyCallback> key_down_callbacks_;
    std::vector<KeyCallback> key_up_callbacks_;
public:
    void update() {
        SDL_Event event;
        while (SDL_PollEvent(&event)) {
            switch (event.type) {
                case SDL_QUIT:
                    quit_requested_ = true;
                    break;
                case SDL_KEYDOWN:
                    key_states_[event.key.keysym.sym] = true;
                    for (auto& cb : key_down_callbacks_) cb(event.key.keysym.sym);
                    break;
                case SDL_KEYUP:
                    key_states_[event.key.keysym.sym] = false;
                    for (auto& cb : key_up_callbacks_) cb(event.key.keysym.sym);
                    break;
                case SDL_MOUSEMOTION:
                    mouse_position_ = {static_cast<float>(event.motion.x),
                                      static_cast<float>(event.motion.y)};
                    break;
            }
        }
    }
    bool is_key_pressed(SDL_Keycode key) const {
        auto it = key_states_.find(key);
        return it != key_states_.end() && it->second;
    }
    glm::vec2 get_mouse_position() const { return mouse_position_; }
    bool is_quit_requested() const { return quit_requested_; }
    void on_key_down(KeyCallback cb) { key_down_callbacks_.push_back(std::move(cb)); }
    void on_key_up(KeyCallback cb) { key_up_callbacks_.push_back(std::move(cb)); }
};

입력 시스템은 두 가지 질문에 답합니다. “지금 이 키가 눌려 있는가”(이동처럼 누르고 있는 동안 계속되는 동작)는 is_key_pressed로 폴링하고, “이 키가 방금 눌렸는가”(점프, 메뉴 선택처럼 한 번만 일어나야 하는 동작)는 이벤트 콜백으로 처리합니다. 점프를 is_key_pressed로 처리하면 키를 누르고 있는 동안 매 프레임 점프가 발동되는 버그가 생깁니다.

반대로 콜백 쪽에도 함정이 있습니다. 운영체제의 키 반복 기능 때문에 키를 누르고 있으면 SDL_KEYDOWN이 계속 반복해서 들어옵니다. 점프 콜백이 여러 번 호출된다면 event.key.repeat가 0이 아닌 이벤트를 걸러 내야 합니다. 또 SDL_Keycode(keysym.sym)는 키보드 배열에 따라 바뀌는 “문자” 기준이라, WASD 이동을 sym으로 묶으면 AZERTY 키보드 사용자는 엉뚱한 위치의 키를 눌러야 합니다. 이동처럼 물리적 위치가 중요한 키는 keysym.scancode나 SDL_GetKeyboardState로 처리하는 것이 맞습니다.


Lua 스크립팅 통합

Lua API 등록 예시

extern "C" {
#include <lua.h>
#include <lualib.h>
#include <lauxlib.h>
}
class ScriptingSystem {
    lua_State* L_;
    EntityManager* entities_ = nullptr;
public:
    ScriptingSystem() {
        L_ = luaL_newstate();
        luaL_openlibs(L_);
    }
    void set_entity_manager(EntityManager* em) { entities_ = em; }
    void register_api() {
        if (!entities_) return;
        // create_entity() -> returns entity_id
        lua_register(L_, "create_entity", [](lua_State* L) -> int {
            auto* ud = static_cast<EntityManager*>(lua_touserdata(L, lua_upvalueindex(1)));
            if (!ud) return 0;
            auto& e = ud->create_entity();
            lua_pushinteger(L, static_cast<lua_Integer>(e.get_id()));
            return 1;
        });
        // set_position(entity_id, x, y)
        lua_register(L_, "set_position", [](lua_State* L) -> int {
            // 구현 생략: lua_tointeger, get_entity, get_component<Transform> 등
            return 0;
        });
    }
    bool run_script(const std::string& script) {
        if (luaL_dostring(L_, script.c_str()) != LUA_OK) {
            fprintf(stderr, "Lua error: %s\n", lua_tostring(L_, -1));
            lua_pop(L_, 1);
            return false;
        }
        return true;
    }
    ~ScriptingSystem() { lua_close(L_); }
};

Lua 게임 로직 예시

-- game_init.lua: 게임 시작 시 실행
local player_id = create_entity()
add_transform(player_id, 100, 200)
add_sprite(player_id, "player", 0)
add_rigidbody(player_id, 0, 0, 1, false)
add_collider(player_id, 32, 32)

Lua에서 C++ 콜백 호출

게임 로직을 Lua에서 처리하며, 특정 이벤트 시 C++ 함수를 호출하려면 lua_pcall과 테이블 기반 콜백 등록을 사용합니다.

-- Lua 측: on_collision 등록
function on_collision(a_id, b_id)
    if get_entity_tag(a_id) == "player" and get_entity_tag(b_id) == "coin" then
        add_score(10)
        destroy_entity(b_id)
    end
end
// C++ 측: Lua 콜백 호출
void PhysicsSystem::on_collision_detected(Entity* a, Entity* b) {
    lua_getglobal(L_, "on_collision");
    if (lua_isfunction(L_, -1)) {
        lua_pushinteger(L_, a->get_id());
        lua_pushinteger(L_, b->get_id());
        if (lua_pcall(L_, 2, 0, 0) != LUA_OK) {
            fprintf(stderr, "Lua callback error: %s\n", lua_tostring(L_, -1));
            lua_pop(L_, 1);  // 에러 메시지 제거
        }
    } else {
        lua_pop(L_, 1);  // 함수가 아니면 getglobal 결과 제거
    }
}

이 예제는 PhysicsSystem이 lua_State*(L_)를 알고 있다고 가정합니다. 실제로는 물리 시스템이 Lua에 직접 의존하지 않도록, 충돌 이벤트를 목록에 쌓아 두고 스크립팅 시스템이 프레임 끝에서 그 목록을 Lua로 전달하는 구조가 더 깔끔합니다. 그렇게 해야 물리 계산 도중 Lua 스크립트가 destroy_entity를 호출해 순회 중인 엔티티 목록이 무너지는 문제도 피할 수 있습니다.

Lua C API는 모든 값을 가상 스택으로 주고받기 때문에, 넣은 값을 꺼내지 않으면 스택이 계속 자랍니다. lua_getglobal은 함수가 없어도 nil을 스택에 올리고, lua_pcall이 실패하면 에러 메시지를 올려 둡니다. 원래 코드처럼 이 값을 그대로 두면 충돌이 일어날 때마다 스택에 하나씩 쌓이다가 결국 stack overflow 에러가 나거나, 이후 인덱스 기반 API가 엉뚱한 값을 읽습니다. 개발 중에는 콜백 호출 전후로 lua_gettop(L_)이 같은지 assert로 확인해 두면 이런 누수를 바로 잡을 수 있습니다.


게임 루프로 묶은 완성 예제

게임 루프 흐름

flowchart LR
    subgraph Frame[한 프레임]
        A[입력 처리] --> B[물리 업데이트]
        B --> C[스크립트 업데이트]
        C --> D[렌더링]
        D --> E[프레임 제한]
    end
    E --> A

게임 루프와 통합

class GameEngine {
    SDL_Window* window_ = nullptr;
    SDL_Renderer* renderer_ = nullptr;
    EntityManager entities_;
    std::unique_ptr<RenderSystem> render_system_;  // 렌더러 생성 후에 만들어야 함
    PhysicsSystem physics_system_;
    InputSystem input_system_;
    ScriptingSystem script_system_;
    bool running_ = true;
    const float target_dt_ = 1.0f / 60.0f;
public:
    bool init() {
        if (SDL_Init(SDL_INIT_VIDEO) != 0) return false;
        window_ = SDL_CreateWindow("2D Engine", SDL_WINDOWPOS_CENTERED,
                                   SDL_WINDOWPOS_CENTERED, 800, 600, 0);
        if (!window_) return false;
        renderer_ = SDL_CreateRenderer(window_, -1, SDL_RENDERER_ACCELERATED);
        if (!renderer_) return false;
        render_system_ = std::make_unique<RenderSystem>(renderer_);
        script_system_.set_entity_manager(&entities_);
        script_system_.register_api();
        create_sample_scene();
        return true;
    }
    void create_sample_scene() {
        // 플레이어
        auto& player = entities_.create_entity();
        player.add_component<TransformComponent>().position = {100, 200};
        player.add_component<SpriteComponent>();
        auto& spr = *player.get_component<SpriteComponent>();
        spr.texture_id = "player";
        spr.src_rect = {0, 0, 32, 32};
        spr.z_index = 1;
        player.add_component<RigidBodyComponent>();
        player.add_component<ColliderComponent>();
        // 바닥
        auto& floor = entities_.create_entity();
        floor.add_component<TransformComponent>().position = {0, 500};
        auto& floor_spr = floor.add_component<SpriteComponent>();
        floor_spr.texture_id = "floor";
        floor_spr.src_rect = {0, 0, 800, 100};
        floor_spr.z_index = 0;
        auto& floor_rb = floor.add_component<RigidBodyComponent>();
        floor_rb.is_static = true;
        floor.add_component<ColliderComponent>().width = 800;
        floor.get_component<ColliderComponent>()->height = 100;
    }
    void run() {
        Uint64 last = SDL_GetPerformanceCounter();
        while (running_) {
            Uint64 now = SDL_GetPerformanceCounter();
            float dt = static_cast<float>(now - last) / SDL_GetPerformanceFrequency();
            last = now;
            input_system_.update();
            if (input_system_.is_quit_requested()) break;
            physics_system_.update(entities_, std::min(dt, target_dt_ * 2));
            render_system_->update(entities_);
            // 프레임 제한
            float elapsed = static_cast<float>(SDL_GetPerformanceCounter() - now) /
                           SDL_GetPerformanceFrequency();
            if (elapsed < target_dt_) {
                SDL_Delay(static_cast<Uint32>((target_dt_ - elapsed) * 1000));
            }
        }
    }
    void shutdown() {
        render_system_.reset();  // 텍스처를 렌더러보다 먼저 해제
        if (renderer_) SDL_DestroyRenderer(renderer_);
        if (window_) SDL_DestroyWindow(window_);
        SDL_Quit();
    }
};

RenderSystem은 기본 생성자가 없고 SDL_Renderer*가 있어야 만들 수 있는데, 렌더러는 init() 안에서야 생깁니다. 그래서 값 멤버로 두면 GameEngine을 생성하는 시점에 컴파일 에러가 나고, 임시 객체를 대입하는 방식은 앞에서 말한 이중 해제 위험이 있습니다. unique_ptr로 두고 렌더러를 만든 뒤에 생성하면 두 문제가 모두 사라지고, shutdown()에서 소멸 순서를 명시적으로 제어할 수 있습니다.

이 루프는 매 프레임 실제 경과 시간 dt를 그대로 물리에 넘기는 가변 타임스텝 방식입니다. 구현이 가장 간단하지만, 프레임 속도에 따라 점프 높이가 미묘하게 달라지고 창을 드래그하거나 디버거에서 멈췄다 재개하면 dt가 수 초로 튀어 오브젝트가 순간 이동합니다. std::min(dt, target_dt_ * 2)로 상한을 둔 것이 이를 막는 최소한의 장치이며, 물리 결과를 재현 가능하게 만들려면 아래 “게임이 60 FPS보다 느릴 때” 항목의 고정 타임스텝 누적 방식으로 바꿔야 합니다. SDL_Delay는 운영체제 스케줄러 해상도 때문에 요청보다 1~2ms 이상 늦게 깨어나는 경우가 많아 프레임 간격이 들쭉날쭉해질 수 있습니다. 화면 찢김까지 함께 해결하려면 SDL_RENDERER_PRESENTVSYNC 플래그로 모니터 주사율에 맞추는 방법이 더 안정적입니다.


바닥 뚫림, 렌더 순서 흔들림, Lua nil 호출: 문제 해결

엔티티가 바닥을 뚫고 떨어짐

원인: ColliderComponent가 없거나, check_collision에서 ColliderComponent를 요구하는데 바닥에 추가하지 않음. 해결법:

// ❌ 잘못된 예: 바닥에 ColliderComponent 없음
auto& floor = entities_.create_entity();
floor.add_component<TransformComponent>();
floor.add_component<RigidBodyComponent>().is_static = true;
// ColliderComponent 누락!
// ✅ 올바른 예
floor.add_component<ColliderComponent>().width = 800;
floor.get_component<ColliderComponent>()->height = 100;

렌더 순서가 매 프레임 바뀜

원인: std::sort에 사용하는 비교자가 불안정하거나, z_index가 동일한 경우 순서가 랜덤. 해결법:

// ✅ 안정 정렬 + 엔티티 ID로 2차 정렬
std::stable_sort(entities_to_render.begin(), entities_to_render.end(),
     [](Entity* a, Entity* b) {
        int za = a->get_component<SpriteComponent>()->z_index;
        int zb = b->get_component<SpriteComponent>()->z_index;
        if (za != zb) return za < zb;
        return a->get_id() < b->get_id();  // 동일 z_index 시 ID로 고정
    });

Lua에서 create_entity()가 nil을 돌려주거나 “attempt to call a nil value”

원인: 두 증상은 원인이 다릅니다. attempt to call a nil value (global 'add_transform')은 스크립트가 호출한 함수가 C++에서 아예 등록되지 않았을 때 납니다. Lua 스크립팅 절의 game_init.lua가 쓰는 add_transform, add_sprite 등은 register_api()에 없으므로 이 에러가 그대로 재현됩니다. 반면 create_entity()가 조용히 nil을 돌려주는 것은 lua_register가 upvalue 없이 함수를 등록하기 때문입니다. 람다 안의 lua_upvalueindex(1)이 가리키는 값이 없어 lua_touserdata가 NULL을 돌려주고, 함수는 아무것도 push하지 않고 0을 반환합니다. 해결법: 스크립트가 쓰는 함수를 모두 등록하고, 컨텍스트가 필요한 함수는 lua_pushcclosure로 upvalue를 붙여 등록합니다.

// ✅ upvalue로 컨텍스트 전달
void register_api() {
    lua_pushlightuserdata(L_, entities_);
    lua_pushcclosure(L_, [](lua_State* L) -> int {
        auto* em = static_cast<EntityManager*>(lua_touserdata(L, lua_upvalueindex(1)));
        auto& e = em->create_entity();
        lua_pushinteger(L, static_cast<lua_Integer>(e.get_id()));
        return 1;  // push한 값의 개수
    }, 1);
    lua_setglobal(L_, "create_entity");
}

캡처 없는 람다는 C 함수 포인터(lua_CFunction)로 변환되므로 이렇게 넘길 수 있지만, [this]처럼 무언가를 캡처하면 변환되지 않아 컴파일 에러가 납니다. C++ 객체를 넘기는 통로가 upvalue인 이유입니다. 반환값은 “스택에 push한 값의 개수”이므로, push 없이 return 1;을 하면 스택에 우연히 있던 인자 값이 반환되는 이상한 동작이 생깁니다.

고프레임에서 물리 “터널링” (빠른 오브젝트가 벽 통과)

원인: 한 프레임에 이동 거리가 충돌체보다 커서 충돌 감지를 건너뜀. 해결법: 연속 충돌 감지(CCD) 또는 서브스텝 사용.

// ✅ 서브스텝: dt를 나눠 여러 번 물리 업데이트
const int substeps = 4;
float sub_dt = dt / substeps;
for (int i = 0; i < substeps; ++i) {
    physics_system_.update(entities_, sub_dt);
}

텍스처 로드 실패 시 검은 화면

원인: IMG_Load 실패 시 nullptr 반환을 체크하지 않고 SDL_CreateTextureFromSurface 호출. 해결법:

// ✅ 에러 체크
void load_texture(const std::string& id, const std::string& path) {
    SDL_Surface* surface = IMG_Load(path.c_str());
    if (!surface) {
        SDL_Log("Failed to load %s: %s", path.c_str(), IMG_GetError());
        return;
    }
    SDL_Texture* texture = SDL_CreateTextureFromSurface(renderer_, surface);
    SDL_FreeSurface(surface);
    if (!texture) {
        SDL_Log("Failed to create texture: %s", SDL_GetError());
        return;
    }
    textures_[id] = texture;
}

게임이 60 FPS보다 느릴 때 물리가 “느려 보임”

원인: dt가 커지면 한 프레임에 이동 거리가 커져 물리가 불안정해지며, 저사양 PC에서는 dt가 1/30초를 넘을 수 있음. 해결법: dt 상한선을 두며, 초과 시 여러 번 나눠 업데이트.

// ✅ dt 클램핑 + 고정 timestep 보간
const float max_dt = 1.0f / 30.0f;  // 최대 30 FPS 기준으로 물리
// accumulated_는 GameEngine 멤버 변수 (프레임 사이에 남은 시간을 유지)
accumulated_ += std::min(dt, max_dt);
while (accumulated_ >= target_dt_) {
    physics_system_.update(entities_, target_dt_);
    accumulated_ -= target_dt_;
}

누적값은 반드시 프레임을 넘어 유지되는 멤버여야 합니다. 루프 안의 지역 변수로 두면 매 프레임 0에서 시작해 남은 시간이 버려지므로, 60FPS보다 조금 빠른 모니터에서는 물리가 아예 돌지 않는 프레임이 생기고 게임이 느려집니다. 고정 타임스텝의 장점은 물리 결과가 프레임 속도와 무관하게 같아진다는 것이고(리플레이와 네트워크 동기화의 전제 조건이기도 합니다), 대가는 렌더링 시점이 물리 스텝 사이에 걸려 움직임이 약간 끊겨 보인다는 것입니다. 이를 없애려면 남은 accumulated_ / target_dt_ 비율로 이전 상태와 현재 상태를 보간해 그립니다. max_dt 상한은 물리 계산이 한 프레임보다 오래 걸려 밀린 스텝이 계속 늘어나는 악순환(“spiral of death”)을 끊는 역할을 합니다.

엔티티 삭제 시 크래시 (use-after-free)

원인: destroy_entity 호출 후 다른 시스템이 해당 엔티티 포인터를 계속 참조. 해결법: 지연 삭제(Deferred Destruction) 패턴 사용.

// ✅ 다음 프레임 시작 시 삭제
std::vector<EntityID> to_destroy_;
void mark_for_destruction(EntityID id) {
    to_destroy_.push_back(id);
}
void process_destruction() {
    for (auto id : to_destroy_) {
        entities_.destroy_entity(id);
    }
    to_destroy_.clear();
}
// run() 루프 시작 시 process_destruction() 호출

컴포넌트 인덱스·배칭·공간 분할로 성능 올리기

컴포넌트 인덱스로 쿼리 최적화

매 프레임 get_entities_with가 전체 엔티티를 순회합니다. 컴포넌트 타입별로 엔티티 ID 목록을 유지하면 O(1)에 가깝게 조회할 수 있습니다.

// 컴포넌트 추가/제거 시 인덱스 갱신
std::unordered_map<std::type_index, std::vector<EntityID>> component_index_;

렌더 배치(Batching)

동일 텍스처를 사용하는 스프라이트를 묶어 한 번에 그리면 드로우콜을 줄일 수 있습니다.

// texture_id로 그룹화 후 배치 렌더링
std::map<std::string, std::vector<Entity*>> by_texture;
for (auto* e : entities_to_render) {
    by_texture[e->get_component<SpriteComponent>()->texture_id].push_back(e);
}
for (auto& [tex_id, list] : by_texture) {
    SDL_Texture* tex = textures_[tex_id];
    for (auto* e : list) {
        // SDL_RenderCopyEx 반복 (같은 텍스처)
    }
}

공간 분할로 충돌 감지 최적화

O(n²) 충돌 검사 대신 공간 해시 또는 Quad Tree로 같은 영역의 오브젝트만 비교.

// 간단한 그리드 기반 공간 분할
// std::pair에는 기본 std::hash가 없으므로 셀 좌표를 64비트 정수 하나로 합쳐 키로 사용
inline int64_t cell_key(int cx, int cy) {
    return (static_cast<int64_t>(cx) << 32) | static_cast<uint32_t>(cy);
}
std::unordered_map<int64_t, std::vector<Entity*>> spatial_grid_;
// 셀 크기 64x64 등으로 분할 후, 같은 셀/인접 셀만 충돌 검사

객체 풀링

엔티티/컴포넌트를 매번 new/delete하지 않고 풀에서 재사용.

std::vector<std::unique_ptr<Entity>> entity_pool_;
// destroy 시 실제 삭제 대신 풀에 반환, create 시 풀에서 꺼내기

Box2D로 넘어갈 때

직접 구현한 AABB 물리는 사각형끼리 부딪히는 간단한 게임에 적합합니다. 원형·다각형 충돌, 조인트, 쌓인 물체가 안정적으로 멈춰 있는 강체 시뮬레이션이 필요해지면 Box2D로 바꾸는 편이 낫습니다. 이때 이 글의 PhysicsSystem 자리를 Box2D 월드와 엔티티를 동기화하는 시스템이 대신하고, RigidBodyComponent의 속도는 Box2D가 계산한 값을 읽어 오는 용도로 바뀝니다.

바꿀 때 가장 자주 부딪히는 문제는 세 가지입니다. Box2D는 미터 단위라 픽셀을 그대로 넣으면 물체가 거대해져 느리게 움직이고, 바디 위치가 도형의 중심이라 왼쪽 위 기준인 이 글의 TransformComponent와 반 크기만큼 어긋나며, 동기화 코드가 이미 만든 바디를 건너뛰지 않으면 프레임마다 바디가 늘어납니다. PPM 상수를 쓰는 연동 코드와 이 함정들, Box2D 2.4와 3.0의 API 차이는 C++ 게임 엔진 구조 잡기의 물리 엔진 통합 절에서 다룹니다.


빌드 및 의존성

CMakeLists.txt 예시

cmake_minimum_required(VERSION 3.16)
project(game_engine LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 17)
find_package(SDL2 REQUIRED)
find_package(SDL2_image REQUIRED)
find_package(glm CONFIG REQUIRED)
find_package(Lua REQUIRED)
add_executable(game_engine
    main.cpp
    engine/entity.cpp
    engine/render_system.cpp
    engine/physics_system.cpp
)
target_include_directories(game_engine PRIVATE
    ${SDL2_INCLUDE_DIRS}
    ${LUA_INCLUDE_DIR}
)
target_link_libraries(game_engine
    SDL2::SDL2
    SDL2_image::SDL2_image
    glm::glm
    Lua::Lua
)

vcpkg로 의존성 설치

# vcpkg 설치 후
vcpkg install sdl2 sdl2-image glm lua
cmake -B build -DCMAKE_TOOLCHAIN_FILE=[vcpkg root]/scripts/buildsystems/vcpkg.cmake
cmake --build build

플랫폼별 참고

플랫폼참고
WindowsSDL2는 DLL 동적 링크 또는 정적 링크 선택 가능. 정적 링크 시 배포 단순화
macOSbrew install sdl2 sdl2_image lua 또는 vcpkg
Linuxapt install libsdl2-dev libsdl2-image-dev liblua5.4-dev

설정 로드·씬 전환·저장/로드

설정 파일 로드 (JSON/YAML)

// config.json: {"gravity": [0, 9.8], "target_fps": 60}
// nlohmann/json 등으로 로드 후 PhysicsSystem, GameEngine에 주입

씬 전환 시스템

class SceneManager {
    std::string current_scene_;
    std::function<void(EntityManager&)> load_scene_;
public:
    void load(const std::string& name) {
        entities_.clear();  // 또는 destroy_all
        load_scene_ = scene_registry_[name];
        load_scene_(entities_);
        current_scene_ = name;
    }
};

저장/로드 (직렬화)

// Transform, RigidBody 등 컴포넌트를 JSON/바이너리로 직렬화
void save_game(const std::string& path) {
    nlohmann::json j;
    for (auto& [id, entity] : entities_) {
        j["entities"].push_back(serialize_entity(*entity));
    }
    std::ofstream f(path);
    f << j.dump();
}

디버그 오버레이

// ImGui 또는 SDL로 FPS, 엔티티 수, 물리 연산 시간 표시
void render_debug_overlay() {
    ImGui::Text("FPS: %.1f", 1.0f / dt_);
    ImGui::Text("Entities: %zu", entities_.size());
}

빌드·실행 환경 점검 항목

  • SDL2/SDL_image 설치 및 링크 (vcpkg: vcpkg install sdl2 sdl2-image)
  • Lua 5.4 설치 (vcpkg install lua)
  • 텍스처 경로 에러 처리 (상대 경로 vs 실행 경로)
  • 윈도우 리사이즈 시 뷰포트/스케일 조정
  • 메모리 누수 검사 (Valgrind/ASan)
  • 릴리즈 빌드에서 NDEBUG, 최적화 플래그 확인

시스템별 역할 요약

시스템역할
ECS엔티티-컴포넌트 관리
렌더링Z-index 정렬, 텍스처 배치
물리AABB 충돌, 중력, 서브스텝
입력폴링 + 이벤트 콜백
스크립팅Lua API 등록, upvalue 활용

ECS 아키텍처로 게임 엔진의 핵심 시스템을 구현하며, 충돌·렌더·입력 문제를 체계적으로 해결할 수 있습니다.


참고 자료


다음 글: [C++ 실전 가이드 #50-4] 데이터베이스 엔진 구현


같이 보면 좋은 글