C++ 게임 엔진 기초 | 렌더링·물리·입력·스크립팅 시스템 구현 [#50-3]
상용 엔진 없이 2D 게임을 직접 만들면 캐릭터가 바닥을 뚫고 떨어지거나, 바닥에서 떨리거나, 같은 높이의 스프라이트가 프레임마다 앞뒤가 바뀌는 문제를 금방 만나게 됩니다. 이 글은 컴포넌트 조합 방식의 엔티티 구조 위에 SDL2 렌더링, AABB 물리, 입력 처리, Lua 스크립팅을 하나의 게임 루프로 묶어 보고, 각 시스템에서 이런 문제가 왜 생기는지와 어떻게 고치는지를 함께 설명합니다.
예제는 C++17, SDL2와 SDL2_image, glm, Lua 5.4를 기준으로 하며, Box2D는 마지막에 직접 구현한 물리를 대체하는 선택지로 다룹니다.
SDL2 기반 ECS 구조
왜 ECS인가?
상속으로 게임 오브젝트를 설계하면 “날 수 있고 헤엄도 치는 적”처럼 기능이 겹치는 순간 계층이 깊어지거나 다중 상속과 다이아몬드 문제로 이어집니다. ECS(Entity Component System)는 상속 대신 조합을 씁니다. 엔티티에는 필요한 컴포넌트만 붙이고, 동작은 특정 컴포넌트 조합을 가진 엔티티를 순회하는 시스템이 맡습니다.
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로 처리하는 것이 맞습니다.
뒤에서 물리를 고정 타임스텝으로 바꾸면 입력에도 영향이 있습니다. 렌더 프레임이 업데이트 주기보다 빠르면(144Hz 화면, 60Hz 업데이트) 업데이트가 한 번도 돌지 않는 프레임이 생기는데, 콜백 대신 “이번 프레임에 눌림” 플래그를 두고 프레임 끝에서 지우면 그런 프레임에 들어온 점프 입력은 업데이트가 보기도 전에 사라집니다. 플래그는 업데이트 틱이 소비한 뒤에 지워야 합니다. 또 입력 처리를 렌더링 뒤에 두면 그 입력이 다음 프레임 업데이트에서야 반영되어 한 프레임 늦어지므로, 루프에서 입력 처리를 가장 먼저 합니다.
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는 upvalue 없이 등록하므로 아래 ud는 항상 NULL이 됩니다.
// 올바른 등록 방법은 "문제 해결" 절의 lua_pushcclosure 예제를 참고하세요.
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 호출: 문제 해결
엔티티가 바닥을 뚫고 떨어짐
물리 시스템은 TransformComponent, RigidBodyComponent, ColliderComponent를 모두 가진 엔티티만 충돌 검사 대상으로 삼습니다. 바닥에 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가 같은 원소끼리의 순서를 보장하지 않습니다. 게다가 정렬 전 목록은 unordered_map 순회 순서를 따르므로 엔티티를 추가하거나 삭제해 재해싱이 일어나면 순서가 달라질 수 있습니다. 같은 z_index일 때 엔티티 ID로 2차 비교를 하면 전순서가 정해지므로 std::sort로도 결과가 고정되고, std::stable_sort를 쓰더라도 입력 순서 자체가 흔들리므로 2차 키가 필요합니다.
// ✅ 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;을 하면 스택에 우연히 있던 인자 값이 반환되는 이상한 동작이 생깁니다.
빠른 오브젝트가 벽을 통과하는 터널링
한 물리 스텝 동안 이동한 거리가 벽 두께보다 크면, 이동 전후 어느 위치에서도 겹치지 않아 충돌이 감지되지 않습니다. 프레임 속도가 낮아 dt가 클수록, 오브젝트가 빠를수록 잘 생깁니다. 이동 경로 전체를 검사하는 연속 충돌 감지(CCD)를 쓰거나, 아래처럼 한 프레임의 물리를 여러 개의 작은 스텝으로 나눠 실행합니다.
// ✅ 서브스텝: dt를 나눠 여러 번 물리 업데이트
const int substeps = 4;
float sub_dt = dt / substeps;
for (int i = 0; i < substeps; ++i) {
physics_system_.update(entities_, sub_dt);
}
텍스처 로드 실패 시 아무것도 그려지지 않음
앞의 load_texture는 실패해도 아무 메시지 없이 반환하고, 렌더 시스템은 텍스처가 없는 엔티티를 건너뛰므로 화면에는 SDL_RenderClear가 칠한 검은 바탕만 남습니다. 상대 경로는 실행 파일 위치가 아니라 현재 작업 디렉터리를 기준으로 해석되기 때문에 IDE에서 실행할 때 로드에 실패하는 경우가 흔합니다. 실패 이유를 로그로 남겨야 원인을 바로 찾을 수 있습니다.
// ✅ 에러 체크
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;
}
프레임 속도에 따라 물리 결과가 달라질 때
가변 dt를 그대로 물리에 넘기면 dt가 클 때 한 스텝의 이동 거리가 커져 결과가 불안정해지고, 프레임 속도에 따라 점프 높이 같은 결과가 달라집니다. 경과 시간을 누적해 두고 고정된 target_dt_ 단위로 물리를 여러 번 실행하는 고정 타임스텝 방식으로 바꿉니다.
// ✅ dt 상한 + 고정 타임스텝 누적
const float max_dt = 1.0f / 30.0f; // 한 프레임에서 물리에 반영할 최대 시간
// accumulated_는 GameEngine 멤버 변수 (프레임 사이에 남은 시간을 유지)
accumulated_ += std::min(dt, max_dt);
while (accumulated_ >= target_dt_) {
physics_system_.update(entities_, target_dt_);
accumulated_ -= target_dt_;
}
누적값은 반드시 프레임을 넘어 유지되는 멤버여야 합니다. 루프 안의 지역 변수로 두면 매 프레임 0에서 시작해 남은 시간이 버려집니다. 그러면 프레임 간격이 target_dt_보다 짧은 환경(예: 144Hz 모니터에서 VSync를 켠 경우)에서는 while 조건이 한 번도 참이 되지 않아 물리가 전혀 진행되지 않습니다. 고정 타임스텝의 장점은 물리 결과가 프레임 속도와 무관하게 같아진다는 것이고(리플레이와 네트워크 동기화의 전제 조건이기도 합니다), 대가는 렌더링 시점이 물리 스텝 사이에 걸려 움직임이 약간 끊겨 보인다는 것입니다. 이를 없애려면 남은 accumulated_ / target_dt_ 비율로 이전 상태와 현재 상태를 보간해 그립니다. max_dt 상한은 물리 계산이 한 프레임보다 오래 걸려 밀린 스텝이 계속 늘어나는 악순환(“spiral of death”)을 끊는 역할을 합니다. 대신 프레임 속도가 30 FPS 아래로 떨어지면 버려지는 시간만큼 게임 속도 자체가 느려집니다.
보간은 물리 스텝 직전의 위치를 저장해 두고, 렌더링할 때 남은 비율 alpha만큼 섞어 그리는 방식입니다.
struct TransformComponent : Component {
glm::vec2 position{0, 0};
glm::vec2 prev_position{0, 0}; // 추가: 직전 물리 스텝의 위치
float rotation = 0.0f;
glm::vec2 scale{1, 1};
};
// 물리 스텝 시작: 이번 스텝이 바꾸기 전의 위치를 저장
for (auto* e : entities) {
auto* t = e->get_component<TransformComponent>();
t->prev_position = t->position;
}
// ... 물리 연산으로 position 갱신 ...
// 렌더링: alpha = accumulated_ / target_dt_ (0~1)
glm::vec2 draw_pos = t->prev_position + (t->position - t->prev_position) * alpha;
prev_position을 저장하는 위치가 중요합니다. 물리 연산 뒤에 저장하면 두 값이 항상 같아져 보간이 아무 효과도 내지 못하고, 화면은 고정 스텝 단위로 끊겨 움직입니다. 코드가 잘 돌아가는 것처럼 보여서 놓치기 쉬운 실수입니다. 이 방식은 화면이 실제 물리 상태보다 최대 한 스텝 늦게 보인다는 비용이 있지만, 60Hz 물리라면 약 16ms라서 대부분의 게임에서 체감되지 않습니다.
엔티티 삭제 시 크래시 (use-after-free)
시스템들은 get_entities_with로 받은 Entity* 목록을 순회하므로, 순회 도중 Lua 콜백이나 게임 로직이 destroy_entity를 호출하면 목록에 남은 포인터가 해제된 메모리를 가리키게 됩니다. 삭제 요청은 목록에 모아 두고, 모든 시스템이 순회를 마친 프레임 경계에서 한꺼번에 처리하는 지연 삭제로 해결합니다.
// ✅ 프레임 경계에서 한꺼번에 삭제
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 목록을 유지하면, 가장 짧은 목록 하나만 훑으면서 나머지 조건을 확인하면 되므로 관련 없는 엔티티를 건너뛸 수 있습니다. 대신 컴포넌트를 추가하거나 제거할 때마다 목록을 갱신해야 합니다.
// 컴포넌트 추가/제거 시 인덱스 갱신
std::unordered_map<std::type_index, std::vector<EntityID>> component_index_;
렌더 배치(Batching)
같은 텍스처를 쓰는 스프라이트를 연속으로 그리면 텍스처 전환이 줄어듭니다. SDL 2.0.10 이상은 렌더러 내부에서 연속된 그리기 명령을 묶어 처리하므로(render batching), 호출 순서를 텍스처별로 모아 주면 그 효과가 커집니다. 다만 텍스처별로 묶으면 z_index 순서가 깨지므로, 실제로는 같은 z_index 안에서만 텍스처별로 묶어야 합니다.
// 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) {
auto it = textures_.find(tex_id); // operator[]는 없는 키에 nullptr을 삽입하므로 find 사용
if (it == textures_.end()) continue;
SDL_Texture* tex = it->second;
for (auto* e : list) {
// SDL_RenderCopyEx 반복 (같은 텍스처)
}
}
공간 분할로 충돌 감지 최적화
지금의 충돌 검사는 모든 쌍을 비교하므로 O(n²)입니다. 공간을 일정 크기의 셀로 나눈 공간 해시나 쿼드트리를 쓰면 같은 셀이나 인접 셀에 있는 오브젝트끼리만 비교하면 됩니다.
// 간단한 그리드 기반 공간 분할
// 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 시 풀에서 꺼내기
dirty 플래그로 갱신하는 씬 그래프
캐릭터가 든 무기, 차량의 바퀴, 움직이는 플랫폼 위의 오브젝트처럼 부모를 따라 움직여야 하는 엔티티가 생기면, 엔티티를 트리로 묶고 “부모 월드 변환 × 자기 로컬 변환”으로 위치를 구하는 씬 그래프가 필요합니다. 모든 노드의 월드 행렬을 매 프레임 다시 계산하면 낭비이므로, 로컬 변환이 바뀐 노드에만 dirty 플래그를 세우고 조회할 때 다시 계산합니다.
#include <algorithm>
#include <glm/gtc/matrix_transform.hpp>
class SceneNode {
public:
explicit SceneNode(EntityID id) : entity_id_(id) {}
void set_parent(SceneNode* parent) {
if (parent_) {
auto& sib = parent_->children_;
sib.erase(std::remove(sib.begin(), sib.end(), this), sib.end());
}
parent_ = parent;
if (parent_) parent_->children_.push_back(this);
mark_dirty(); // 부모가 바뀌면 월드 변환도 바뀜
}
void set_local(const glm::vec3& pos, float rot_z, const glm::vec3& scale) {
local_pos_ = pos; local_rot_ = rot_z; local_scale_ = scale;
mark_dirty();
}
// 자신과 모든 자손을 dirty로 표시 (이미 dirty인 가지는 건너뜀)
void mark_dirty() {
if (dirty_) return;
dirty_ = true;
for (auto* c : children_) c->mark_dirty();
}
glm::mat4 world_matrix() {
if (dirty_) {
glm::mat4 local = glm::translate(glm::mat4(1.0f), local_pos_);
local = glm::rotate(local, local_rot_, glm::vec3(0, 0, 1));
local = glm::scale(local, local_scale_);
world_ = parent_ ? parent_->world_matrix() * local : local;
dirty_ = false;
}
return world_;
}
private:
EntityID entity_id_;
SceneNode* parent_ = nullptr;
std::vector<SceneNode*> children_;
glm::vec3 local_pos_{0}, local_scale_{1};
float local_rot_ = 0.0f;
glm::mat4 world_{1.0f};
bool dirty_ = true;
};
dirty 표시를 로컬 변환을 바꾸는 순간 자손 전체에 전파하는 것이 요점입니다. 월드 행렬을 다시 계산할 때 자식에 표시하는 방식은, 자식이 부모보다 먼저 조회되면 아직 dirty가 아니라서 이전 행렬을 돌려주므로 한 프레임 늦게 반영되는 버그가 생깁니다. 또 씬 그래프의 결과로 TransformComponent를 덮어쓰면서 같은 엔티티를 물리 시스템도 움직이면 두 시스템이 서로의 결과를 덮어씁니다. 한 엔티티의 위치는 물리와 부모 중 한쪽만 소유하도록 정해 두고, 예를 들어 물리로 움직이는 루트의 위치를 씬 노드의 로컬 변환에 반영한 뒤 그 자식들만 씬 그래프로 계산합니다. 부모 노드를 삭제할 때 자식을 함께 지우거나 다른 부모로 옮기지 않으면 자식의 parent_가 해제된 메모리를 가리키게 되는 것도 흔한 크래시 원인입니다.
캐싱과 비동기 로딩을 하는 리소스 매니저
같은 텍스처를 여러 엔티티가 쓰는데 엔티티마다 IMG_Load를 부르면 메모리와 로딩 시간이 그대로 곱해집니다. 리소스 매니저는 경로를 키로 텍스처를 캐시하고, 아무도 쓰지 않는 리소스만 해제하며, 가능하면 파일 읽기를 메인 루프 밖에서 처리합니다. 수동 참조 카운트(add_ref/release)는 짝이 한 번만 어긋나도 누수나 조기 해제가 생기므로, std::shared_ptr과 커스텀 삭제자로 수명을 맡기는 편이 안전합니다.
using TextureHandle = std::shared_ptr<SDL_Texture>;
class ResourceManager {
SDL_Renderer* renderer_;
std::unordered_map<std::string, std::weak_ptr<SDL_Texture>> cache_;
public:
explicit ResourceManager(SDL_Renderer* r) : renderer_(r) {}
// 메인 스레드 전용: 디코딩된 surface를 텍스처로 올림
TextureHandle from_surface(const std::string& path, SDL_Surface* surface) {
SDL_Texture* tex = SDL_CreateTextureFromSurface(renderer_, surface);
SDL_FreeSurface(surface);
if (!tex) return nullptr;
TextureHandle h(tex, SDL_DestroyTexture); // 마지막 사용자가 놓으면 해제
cache_[path] = h;
return h;
}
TextureHandle load(const std::string& path) {
if (auto it = cache_.find(path); it != cache_.end()) {
if (auto h = it->second.lock()) return h; // 아직 누가 쓰고 있으면 재사용
}
SDL_Surface* surface = IMG_Load(path.c_str());
if (!surface) { SDL_Log("load failed: %s (%s)", path.c_str(), IMG_GetError()); return nullptr; }
return from_surface(path, surface);
}
};
SDL_Renderer나 OpenGL 컨텍스트는 만든 스레드(보통 메인 스레드)에서만 써야 하므로, 비동기 로딩에서는 워커 스레드가 파일 읽기와 이미지 디코딩(IMG_Load)까지만 하고 텍스처 생성과 완료 콜백은 메인 루프에서 처리합니다.
class AsyncLoadQueue {
public:
using Callback = std::function<void(TextureHandle)>;
void enqueue(const std::string& path, Callback cb) { // 워커: 디코딩만
workers_.emplace_back([this, path, cb = std::move(cb)]() mutable {
SDL_Surface* surface = IMG_Load(path.c_str());
std::lock_guard lock(mutex_);
done_.push_back({path, surface, std::move(cb)});
});
}
void process_completed(ResourceManager& rm) { // 메인 루프에서 매 프레임
std::vector<Done> ready;
{ std::lock_guard lock(mutex_); ready.swap(done_); }
for (auto& d : ready) {
d.cb(d.surface ? rm.from_surface(d.path, d.surface) : nullptr);
}
}
~AsyncLoadQueue() { for (auto& t : workers_) t.join(); }
private:
struct Done { std::string path; SDL_Surface* surface; Callback cb; };
std::mutex mutex_;
std::vector<Done> done_;
std::vector<std::thread> workers_;
};
워커에서 SDL_CreateTextureFromSurface를 부르거나 완료 콜백에서 렌더러를 건드리면, 크래시하거나 드라이버에 따라 조용히 깨진 텍스처가 나옵니다. 요청마다 스레드를 새로 만드는 것은 예시를 위한 단순화이고, 실제로는 고정된 수의 워커와 작업 큐를 둡니다. std::async(std::launch::async, ...)를 호출하고 반환된 std::future를 버리는 코드도 흔한데, 이 future의 소멸자는 작업이 끝날 때까지 기다리므로 사실상 동기 호출이 됩니다. 비동기로 보이는데 로딩 중 화면이 멈춘다면 이것부터 의심할 만합니다.
Box2D로 넘어갈 때
직접 구현한 AABB 물리는 사각형끼리 부딪히는 간단한 게임에 적합합니다. 원형·다각형 충돌, 조인트, 쌓인 물체가 안정적으로 멈춰 있는 강체 시뮬레이션이 필요해지면 Box2D로 바꾸는 편이 낫습니다. 이때 이 글의 PhysicsSystem 자리를 Box2D 월드와 엔티티를 동기화하는 시스템이 대신하고, RigidBodyComponent의 속도는 Box2D가 계산한 값을 읽어 오는 용도로 바뀝니다.
Box2D는 미터 단위를 쓰므로, 픽셀 단위인 게임에서는 PPM(Pixels Per Meter) 상수로 변환합니다. 아래는 Box2D 2.4 API 기준의 동기화 시스템입니다.
#include <box2d/box2d.h>
constexpr float PPM = 100.0f; // 100 pixels = 1 meter
class Box2DPhysicsSystem {
public:
Box2DPhysicsSystem() : world_({0.0f, 9.8f}) {} // 화면 좌표계(y 아래 방향)에 맞춘 중력
void sync_to_physics(EntityManager& entities) {
for (auto* e : entities.get_entities_with<TransformComponent, RigidBodyComponent, ColliderComponent>()) {
if (entity_to_body_.count(e->get_id())) continue; // 이미 바디가 있으면 건너뜀
auto* t = e->get_component<TransformComponent>();
auto* rb = e->get_component<RigidBodyComponent>();
auto* col = e->get_component<ColliderComponent>();
b2BodyDef def;
// Box2D 위치는 도형 중심, Transform은 왼쪽 위 기준이므로 반 크기만큼 보정
def.position.Set((t->position.x + col->width / 2) / PPM,
(t->position.y + col->height / 2) / PPM);
def.type = rb->is_static ? b2_staticBody : b2_dynamicBody;
def.userData.pointer = static_cast<uintptr_t>(e->get_id());
b2Body* body = world_.CreateBody(&def);
b2PolygonShape box;
box.SetAsBox(col->width / (2 * PPM), col->height / (2 * PPM));
b2FixtureDef fix;
fix.shape = &box; // shape은 복사되므로 지역 변수여도 됨
fix.density = 1.0f;
body->CreateFixture(&fix);
entity_to_body_[e->get_id()] = body;
}
}
void step(float dt) { world_.Step(dt, 6, 2); } // velocityIterations, positionIterations
void sync_from_physics(EntityManager& entities) {
for (auto* e : entities.get_entities_with<TransformComponent, RigidBodyComponent, ColliderComponent>()) {
auto it = entity_to_body_.find(e->get_id());
if (it == entity_to_body_.end()) continue;
b2Body* body = it->second;
auto* t = e->get_component<TransformComponent>();
auto* col = e->get_component<ColliderComponent>();
b2Vec2 pos = body->GetPosition();
t->position.x = pos.x * PPM - col->width / 2;
t->position.y = pos.y * PPM - col->height / 2;
b2Vec2 vel = body->GetLinearVelocity();
e->get_component<RigidBodyComponent>()->velocity = {vel.x * PPM, vel.y * PPM};
}
}
void destroy_body(EntityID id) {
auto it = entity_to_body_.find(id);
if (it == entity_to_body_.end()) return;
world_.DestroyBody(it->second);
entity_to_body_.erase(it);
}
private:
b2World world_;
std::unordered_map<EntityID, b2Body*> entity_to_body_;
};
Box2D는 0.1~10미터 크기의 물체에 맞춰 조정돼 있어서, 픽셀 값을 그대로 넣으면 32×32 스프라이트가 32미터짜리 건물이 됩니다. 물체가 물속처럼 느리게 움직이고, 한 스텝에 이동할 수 있는 거리 상한(기본 2미터)에 걸려 빠른 물체가 이상하게 멈추는 증상이 여기서 나옵니다. PPM 상수는 한 곳에만 정의하고, 물리 월드에는 미터로 넣고 렌더링할 때만 픽셀로 바꾸는 규칙을 엔진 전체에서 지켜야 합니다.
sync_to_physics가 이미 만든 바디를 건너뛰지 않으면 프레임마다 같은 위치에 바디가 하나씩 늘어나고, 겹친 바디들이 서로 밀어내며 튕겨 나갑니다. 엔티티를 지울 때 destroy_body를 함께 호출하지 않으면 보이지 않는 바디가 월드에 남아 충돌을 일으킵니다. 충돌 콜백(b2ContactListener::BeginContact)은 world.Step() 도중에 불리고 이때 월드가 잠겨 있으므로, 콜백 안에서 CreateBody나 DestroyBody를 부르면 assert가 걸리거나 nullptr이 돌아옵니다. 콜백에서는 삭제할 엔티티를 표시만 하고 실제 제거는 Step()이 끝난 뒤 앞의 지연 삭제 단계에서 합니다. 2024년에 나온 Box2D 3.0은 b2World 클래스 대신 b2CreateWorld 같은 C 스타일 API와 ID 핸들로 바뀌었으므로, 버전을 확인하고 예제를 고르세요.
빌드 및 의존성
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
)
# CMake의 FindLua 모듈은 imported target 대신 변수를 제공합니다
target_include_directories(game_engine PRIVATE ${LUA_INCLUDE_DIR})
target_link_libraries(game_engine PRIVATE
SDL2::SDL2
SDL2_image::SDL2_image
glm::glm
${LUA_LIBRARIES}
)
vcpkg로 의존성 설치
# vcpkg 설치 후
vcpkg install sdl2 sdl2-image glm lua
cmake -B build -DCMAKE_TOOLCHAIN_FILE=[vcpkg root]/scripts/buildsystems/vcpkg.cmake
cmake --build build
플랫폼별 참고
| 플랫폼 | 참고 |
|---|---|
| Windows | SDL2를 DLL로 동적 링크하거나 정적 링크할 수 있습니다. 정적 링크하면 DLL을 함께 배포하지 않아도 됩니다 |
| macOS | brew install sdl2 sdl2_image lua 또는 vcpkg |
| Linux | apt 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 {
using SceneLoader = std::function<void(EntityManager&)>;
std::unordered_map<std::string, SceneLoader> scene_registry_;
std::string current_scene_;
public:
void register_scene(const std::string& name, SceneLoader loader) {
scene_registry_[name] = std::move(loader);
}
bool load(const std::string& name, EntityManager& entities) {
auto it = scene_registry_.find(name);
if (it == scene_registry_.end()) return false;
entities.clear(); // EntityManager에 전체 삭제 함수를 추가했다고 가정
it->second(entities);
current_scene_ = name;
return true;
}
};
씬을 바꿀 때 기존 엔티티를 모두 지우므로, 앞의 지연 삭제와 마찬가지로 시스템이 엔티티 목록을 순회하는 도중이 아니라 프레임 경계에서 호출해야 합니다.
일시정지 메뉴처럼 기존 씬을 지우지 않고 위에 겹쳐야 하는 경우에는 교체 대신 씬 스택을 씁니다. 맨 위 씬만 업데이트하고, 아래 씬은 멈춘 채 그리기만 합니다.
std::vector<std::unique_ptr<Scene>> scene_stack_;
void push_scene(std::unique_ptr<Scene> s) {
if (!scene_stack_.empty()) scene_stack_.back()->on_pause();
scene_stack_.push_back(std::move(s));
scene_stack_.back()->on_enter();
}
void pop_scene() {
scene_stack_.back()->on_exit();
scene_stack_.pop_back();
if (!scene_stack_.empty()) scene_stack_.back()->on_resume();
}
저장/로드 (직렬화)
// Transform, RigidBody 등 컴포넌트를 JSON으로 직렬화
// (EntityManager가 엔티티 순회를 제공하고 serialize_entity가 있다고 가정)
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();
}
디버그 오버레이
// Dear ImGui로 FPS와 엔티티 수 표시 (dt_와 엔티티 수 조회 함수는 엔진에 있다고 가정)
void render_debug_overlay() {
ImGui::Text("FPS: %.1f", 1.0f / dt_);
ImGui::Text("Entities: %zu", entities_.size());
}
참고 자료
- SDL2 공식 문서
- Box2D 매뉴얼
- Lua 5.4 Reference
- ECS 아키텍처 패턴 (Wikipedia)
다음 글: C++ 데이터베이스 엔진 구현: 페이지 저장, B+Tree 인덱스, WAL, SQL 파서와 실행 계획