[2026] C++ 게임 엔진 기초 | 렌더링·물리·입력·스크립팅 시스템 구현 [#50-3]
이 글의 핵심
C++ 게임 엔진 기초: 렌더링·물리·입력·스크립팅 시스템 구현 [#50-3]. 실무에서 겪은 문제·ECS 아키텍처.
들어가며: “캐릭터가 바닥을 뚫고 떨어지고, 충돌이 튕겨요”
게임 엔진을 만들다 보면 겪는 문제들
Unity나 Unreal 같은 상용 엔진 없이 직접 2D 게임을 만들다 보면 이런 문제를 겪습니다:
- 엔티티가 바닥을 뚫고 떨어짐 — 충돌 감지 순서나 AABB 경계 계산 오류
- 프레임마다 렌더 순서가 뒤섞임 — Z-index 정렬이 없거나 레이어 시스템 부재
- 입력이 프레임에 묶여 반응이 느림 — 이벤트 기반이 아닌 폴링만 사용
- 게임 로직과 엔진 코드가 뒤섞여 수정이 어려움 — 스크립팅 분리 미흡 이 글에서는 ECS 아키텍처를 기반으로 렌더링, 물리, 입력, 스크립팅을 통합한 2D 게임 엔진 기초를 다룹니다. 목표:
- ECS (Entity Component System) 아키텍처
- 2D 렌더링 파이프라인 (SDL2/SFML)
- 물리 시뮬레이션 (충돌 감지, 강체 동역학)
- 입력 처리 및 이벤트 시스템
- Lua 스크립팅 통합 요구 환경: C++17 이상, SDL2 또는 SFML, Box2D(선택), Lua 5.4
개념을 잡는 비유
이 글의 주제는 여러 부품이 맞물리는 시스템으로 보시면 이해가 빠릅니다. 한 레이어(저장·네트워크·관측)의 선택이 옆 레이어에도 영향을 주므로, 본문에서는 트레이드오프를 숫자와 패턴으로 정리합니다.
실무 적용 경험: 이 글은 대규모 C++ 프로젝트에서 실제로 겪은 문제와 해결 과정을 바탕으로 작성되었습니다. 책이나 문서에서 다루지 않는 실전 함정과 디버깅 팁을 포함합니다.
목차
1. ECS 아키텍처
왜 ECS인가?
전통적인 상속 기반 게임 오브젝트는 “다중 상속 지옥”과 “다이아몬드 상속” 문제를 일으킵니다. ECS는 조합(Composition) 방식으로, 엔티티에 필요한 컴포넌트만 붙여 유연하게 확장합니다. 다음은 mermaid를 활용한 상세한 구현 코드입니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
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
핵심 컴포넌트 정의
다음은 cpp를 활용한 상세한 구현 코드입니다. 필요한 모듈을 import하고, 클래스를 정의하여 데이터와 기능을 캡슐화하며, 반복문으로 데이터를 처리합니다, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
#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는 매 프레임 호출 시 벡터를 새로 할당합니다. 고성능이 필요하면 컴포넌트별 인덱스를 유지하는 방식으로 최적화하세요.
2. 렌더링 시스템
렌더링 파이프라인 흐름
아래 코드는 mermaid를 사용한 구현 예제입니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
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
구현
다음은 cpp를 활용한 상세한 구현 코드입니다. 필요한 모듈을 import하고, 클래스를 정의하여 데이터와 기능을 캡슐화하며, 반복문으로 데이터를 처리합니다, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
#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(),
{
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);
}
};
3. 물리 시뮬레이션
간단한 물리 엔진 (AABB 충돌)
다음은 cpp를 활용한 상세한 구현 코드입니다. 클래스를 정의하여 데이터와 기능을 캡슐화하며, 반복문으로 데이터를 처리합니다, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
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;
}
}
};
4. 입력 및 이벤트
입력 시스템 (폴링 + 이벤트)
다음은 cpp를 활용한 상세한 구현 코드입니다. 필요한 모듈을 import하고, 클래스를 정의하여 데이터와 기능을 캡슐화하며, 반복문으로 데이터를 처리합니다, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
#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)); }
};
5. 스크립팅 통합
Lua API 등록 예시
다음은 cpp를 활용한 상세한 구현 코드입니다. 필요한 모듈을 import하고, 클래스를 정의하여 데이터와 기능을 캡슐화하며, 에러 처리를 통해 안정성을 확보합니다, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
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", -> 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", -> 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 게임 로직 예시
아래 코드는 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를 사용한 구현 예제입니다. 함수를 통해 로직을 구현합니다, 비동기 처리를 통해 효율적으로 작업을 수행합니다. 코드를 직접 실행해보면서 동작을 확인해보세요.
-- 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
아래 코드는 cpp를 사용한 구현 예제입니다. 에러 처리를 통해 안정성을 확보합니다, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
// 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));
}
}
}
6. 완성 게임 엔진 예제
게임 루프 흐름
아래 코드는 mermaid를 사용한 구현 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.
flowchart LR
subgraph Frame[한 프레임]
A[입력 처리] --> B[물리 업데이트]
B --> C[스크립트 업데이트]
C --> D[렌더링]
D --> E[프레임 제한]
end
E --> A
게임 루프와 통합
다음은 cpp를 활용한 상세한 구현 코드입니다. 클래스를 정의하여 데이터와 기능을 캡슐화하며, 반복문으로 데이터를 처리합니다, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
class GameEngine {
SDL_Window* window_ = nullptr;
SDL_Renderer* renderer_ = nullptr;
EntityManager entities_;
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_ = 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() {
if (renderer_) SDL_DestroyRenderer(renderer_);
if (window_) SDL_DestroyWindow(window_);
SDL_Quit();
}
};
7. 자주 발생하는 문제와 해결법
문제 1: 엔티티가 바닥을 뚫고 떨어짐
원인: ColliderComponent가 없거나, check_collision에서 ColliderComponent를 요구하는데 바닥에 추가하지 않음.
해결법:
아래 코드는 cpp를 사용한 구현 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.
// ❌ 잘못된 예: 바닥에 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;
문제 2: 렌더 순서가 매 프레임 바뀜
원인: std::sort에 사용하는 비교자가 불안정하거나, z_index가 동일한 경우 순서가 랜덤.
해결법:
아래 코드는 cpp를 사용한 구현 예제입니다. 조건문으로 분기 처리를 수행합니다. 코드를 직접 실행해보면서 동작을 확인해보세요.
// ✅ 안정 정렬 + 엔티티 ID로 2차 정렬
std::stable_sort(entities_to_render.begin(), entities_to_render.end(),
{
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로 고정
});
문제 3: Lua 스크립트에서 “attempt to call a nil value”
원인: lua_register로 C 함수를 등록할 때 lua_upvalueindex를 사용해 EntityManager*를 넘기지 않음.
해결법:
아래 코드는 cpp를 사용한 구현 예제입니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
// ✅ upvalue로 컨텍스트 전달
void register_api() {
lua_pushlightuserdata(L_, entities_);
lua_pushcclosure(L_, -> int {
auto* em = static_cast<EntityManager*>(lua_touserdata(L, lua_upvalueindex(1)));
// em 사용
return 1;
}, 1);
lua_setglobal(L_, "create_entity");
}
문제 4: 고프레임에서 물리 “터널링” (빠른 오브젝트가 벽 통과)
원인: 한 프레임에 이동 거리가 충돌체보다 커서 충돌 감지를 건너뜀. 해결법: 연속 충돌 감지(CCD) 또는 서브스텝 사용. 아래 코드는 cpp를 사용한 구현 예제입니다. 반복문으로 데이터를 처리합니다. 코드를 직접 실행해보면서 동작을 확인해보세요.
// ✅ 서브스텝: dt를 나눠 여러 번 물리 업데이트
const int substeps = 4;
float sub_dt = dt / substeps;
for (int i = 0; i < substeps; ++i) {
physics_system_.update(entities_, sub_dt);
}
문제 5: 텍스처 로드 실패 시 검은 화면
원인: IMG_Load 실패 시 nullptr 반환을 체크하지 않고 SDL_CreateTextureFromSurface 호출.
해결법:
다음은 cpp를 활용한 상세한 구현 코드입니다. 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
// ✅ 에러 체크
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;
}
문제 6: 게임이 60 FPS보다 느릴 때 물리가 “느려 보임”
원인: dt가 커지면 한 프레임에 이동 거리가 커져 물리가 불안정해지고, 저사양 PC에서는 dt가 1/30초를 넘을 수 있음.
해결법: dt 상한선을 두고, 초과 시 여러 번 나눠 업데이트.
아래 코드는 cpp를 사용한 구현 예제입니다. 반복문으로 데이터를 처리합니다. 코드를 직접 실행해보면서 동작을 확인해보세요.
// ✅ dt 클램핑 + 고정 timestep 보간
const float max_dt = 1.0f / 30.0f; // 최대 30 FPS 기준으로 물리
float accumulated = 0;
accumulated += std::min(dt, max_dt);
while (accumulated >= target_dt_) {
physics_system_.update(entities_, target_dt_);
accumulated -= target_dt_;
}
문제 7: 엔티티 삭제 시 크래시 (use-after-free)
원인: destroy_entity 호출 후 다른 시스템이 해당 엔티티 포인터를 계속 참조.
해결법: 지연 삭제(Deferred Destruction) 패턴 사용.
아래 코드는 cpp를 사용한 구현 예제입니다. 반복문으로 데이터를 처리합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
// ✅ 다음 프레임 시작 시 삭제
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() 호출
8. 성능 최적화 팁
1. 컴포넌트 인덱스로 쿼리 최적화
매 프레임 get_entities_with가 전체 엔티티를 순회합니다. 컴포넌트 타입별로 엔티티 ID 목록을 유지하면 O(1)에 가깝게 조회할 수 있습니다.
// 컴포넌트 추가/제거 시 인덱스 갱신
std::unordered_map<std::type_index, std::vector<EntityID>> component_index_;
2. 렌더 배치(Batching)
동일 텍스처를 사용하는 스프라이트를 묶어 한 번에 그리면 드로우콜을 줄일 수 있습니다. 아래 코드는 cpp를 사용한 구현 예제입니다. 반복문으로 데이터를 처리합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
// 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 반복 (같은 텍스처)
}
}
3. 공간 분할로 충돌 감지 최적화
O(n²) 충돌 검사 대신 공간 해시 또는 Quad Tree로 같은 영역의 오브젝트만 비교.
// 간단한 그리드 기반 공간 분할
std::unordered_map<std::pair<int,int>, std::vector<Entity*>> spatial_grid_;
// 셀 크기 64x64 등으로 분할 후, 같은 셀/인접 셀만 충돌 검사
4. 객체 풀링
엔티티/컴포넌트를 매번 new/delete하지 않고 풀에서 재사용.
std::vector<std::unique_ptr<Entity>> entity_pool_;
// destroy 시 실제 삭제 대신 풀에 반환, create 시 풀에서 꺼내기
Box2D 통합 (프로덕션급 물리)
직접 구현한 AABB 물리는 간단한 게임에 적합합니다. 복잡한 충돌(원형, 다각형, 조인트)이나 안정적인 강체 시뮬레이션이 필요하면 Box2D를 사용하세요.
Box2D와 ECS 연동
다음은 cpp를 활용한 상세한 구현 코드입니다. 필요한 모듈을 import하고, 클래스를 정의하여 데이터와 기능을 캡슐화하며, 반복문으로 데이터를 처리합니다, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
#include <box2d/box2d.h>
class Box2DPhysicsSystem {
b2World world_{{0, 9.8f}};
std::unordered_map<EntityID, b2Body*> entity_to_body_;
public:
void sync_to_physics(EntityManager& entities) {
for (auto* entity : entities.get_entities_with<TransformComponent, RigidBodyComponent, ColliderComponent>()) {
auto* t = entity->get_component<TransformComponent>();
auto* rb = entity->get_component<RigidBodyComponent>();
auto* col = entity->get_component<ColliderComponent>();
b2BodyDef def;
def.position.Set(t->position.x / 100.0f, t->position.y / 100.0f); // 픽셀→미터
def.type = rb->is_static ? b2_staticBody : b2_dynamicBody;
b2Body* body = world_.CreateBody(&def);
b2PolygonShape box;
box.SetAsBox(col->width / 200.0f, col->height / 200.0f);
b2FixtureDef fix;
fix.shape = &box;
fix.density = 1.0f;
body->CreateFixture(&fix);
entity_to_body_[entity->get_id()] = body;
}
}
void step(float dt) {
world_.Step(dt, 6, 2); // velocityIterations, positionIterations
}
void sync_from_physics(EntityManager& entities) {
auto physics_entities = entities.get_entities_with<
TransformComponent, RigidBodyComponent, ColliderComponent>();
for (auto* entity : physics_entities) {
auto it = entity_to_body_.find(entity->get_id());
if (it == entity_to_body_.end()) continue;
b2Body* body = it->second;
auto* t = entity->get_component<TransformComponent>();
auto* rb = entity->get_component<RigidBodyComponent>();
auto pos = body->GetPosition();
t->position = {pos.x * 100.0f, pos.y * 100.0f};
auto vel = body->GetLinearVelocity();
rb->velocity = {vel.x * 100.0f, vel.y * 100.0f};
}
}
};
주의: Box2D는 미터 단위를 사용합니다. 픽셀과 스케일 비율(예: 100픽셀 = 1미터)을 정해 변환하세요.
빌드 및 의존성
CMakeLists.txt 예시
다음은 cmake를 활용한 상세한 구현 코드입니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
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로 의존성 설치
다음은 간단한 bash 코드 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.
# 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 동적 링크 또는 정적 링크 선택 가능. 정적 링크 시 배포 단순화 |
| macOS | brew install sdl2 sdl2_image lua 또는 vcpkg |
| Linux | apt install libsdl2-dev libsdl2-image-dev liblua5.4-dev |
9. 프로덕션 패턴
1. 설정 파일 로드 (JSON/YAML)
// config.json: {"gravity": [0, 9.8], "target_fps": 60}
// nlohmann/json 등으로 로드 후 PhysicsSystem, GameEngine에 주입
2. 씬 전환 시스템
아래 코드는 cpp를 사용한 구현 예제입니다. 클래스를 정의하여 데이터와 기능을 캡슐화하며, 에러 처리를 통해 안정성을 확보합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
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;
}
};
3. 저장/로드 (직렬화)
아래 코드는 cpp를 사용한 구현 예제입니다. 반복문으로 데이터를 처리합니다. 코드를 직접 실행해보면서 동작을 확인해보세요.
// 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();
}
4. 디버그 오버레이
아래 코드는 cpp를 사용한 구현 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.
// ImGui 또는 SDL로 FPS, 엔티티 수, 물리 연산 시간 표시
void render_debug_overlay() {
ImGui::Text("FPS: %.1f", 1.0f / dt_);
ImGui::Text("Entities: %zu", entities_.size());
}
5. 구현 체크리스트
- 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 아키텍처로 게임 엔진의 핵심 시스템을 구현하고, 충돌·렌더·입력 문제를 체계적으로 해결할 수 있습니다. |
참고 자료
- SDL2 공식 문서
- Box2D 매뉴얼
- Lua 5.4 Reference
- ECS 아키텍처 패턴 (Wikipedia)
다음 글: [C++ 실전 가이드 #50-4] 데이터베이스 엔진 구현
실전 체크리스트
실무에서 이 개념을 적용할 때 확인해야 할 사항입니다.
코드 작성 전
- 이 기법이 현재 문제를 해결하는 최선의 방법인가?
- 팀원들이 이 코드를 이해하고 유지보수할 수 있는가?
- 성능 요구사항을 만족하는가?
코드 작성 중
- 컴파일러 경고를 모두 해결했는가?
- 엣지 케이스를 고려했는가?
- 에러 처리가 적절한가?
코드 리뷰 시
- 코드의 의도가 명확한가?
- 테스트 케이스가 충분한가?
- 문서화가 되어 있는가? 이 체크리스트를 활용하여 실수를 줄이고 코드 품질을 높이세요.
자주 묻는 질문 (FAQ)
Q. 이 내용을 실무에서 언제 쓰나요?
A. 2D 게임 엔진 기초: 렌더링 파이프라인, 물리 시뮬레이션, 입력 처리, Lua 스크립팅 통합, ECS 아키텍처. 실무에서는 위 본문의 예제와 선택 가이드를 참고해 적용하면 됩니다.
Q. 선행으로 읽으면 좋은 글은?
A. 각 글 하단의 이전 글 링크를 따라가면 순서대로 배울 수 있습니다. C++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다.