C++ 게임 엔진 구조 잡기: 게임 루프, ECS, 씬 그래프, 리소스 매니저, 물리 통합
들어가며: “프레임이 떨어지고, 엔티티 관리가 혼란스럽다”
왜 게임 엔진 아키텍처인가
직접 게임을 만들다 보면 프레임 드랍, 엔티티를 추가하거나 지울 때의 크래시, 리소스 로딩 병목, 씬 전환 때 메모리가 불어나는 문제를 겪게 됩니다. 대부분은 게임 루프, ECS, 씬 그래프, 리소스 매니저 같은 뼈대를 체계 없이 짠 데서 생깁니다. 이 글에서는 이런 문제를 하나씩 짚고, 각각을 해결하는 엔진 구조를 C++ 예제로 만들어 봅니다.
다루는 순서는 고정 timestep과 보간을 쓰는 게임 루프, 지연 삭제를 갖춘 ECS, 부모-자식 변환을 처리하는 씬 그래프, 캐싱과 비동기 로딩을 하는 리소스 매니저, Box2D 연동이고, 마지막으로 이를 하나로 묶은 뒤 자주 하는 실수를 정리합니다.
관련 글: 게임 엔진 기초 #50-3, 메모리 풀 #48-3.
프레임 드랍·삭제 후 크래시·씬 로딩 멈춤: 엔진 구조 문제
첫 번째는 물리 불안정입니다. 게임 루프에서 측정한 dt(델타 타임)를 그대로 물리 업데이트에 넣으면, 저사양 PC에서 한 프레임이 50ms 걸릴 때 dt=0.05로 60Hz 기준 세 프레임 분량의 이동이 한 번에 적용됩니다. 한 스텝의 이동 거리가 커지면서 빠른 물체가 벽을 통과하는 터널링이 생기고, 프레임 시간이 흔들릴 때마다 시뮬레이션 결과도 달라집니다. 물리는 고정 timestep으로 돌리고, 렌더링은 두 물리 상태 사이를 보간해서 부드럽게 보여 주는 것이 해결책입니다.
두 번째는 삭제된 엔티티 참조입니다. PhysicsSystem이 충돌을 감지하고 그 자리에서 destroy_entity(id)를 호출하면, 같은 프레임에 뒤이어 도는 RenderSystem이 이미 목록으로 받아 둔 포인터로 해당 엔티티에 접근하다 use-after-free로 크래시합니다. 시스템들이 도는 중간에 엔티티를 지운 것이 원인이므로, 삭제할 ID를 큐에 넣어 두고 프레임 끝에서 한꺼번에 지우는 지연 삭제로 해결합니다.
세 번째는 씬 로딩 중 화면 멈춤입니다. load_scene("level1")이 텍스처, 모델, 사운드를 메인 스레드에서 동기적으로 전부 읽으면, 에셋 수에 비례해 그동안 게임 루프가 멈춥니다. 파일 읽기와 디코딩은 백그라운드 스레드에서 하고, 메인 루프는 완료된 것만 받아서 통합하며 그동안 로딩 화면을 그리는 구조가 필요합니다.
네 번째는 부모-자식 변환 누락입니다. 움직이는 플랫폼 위에 선 캐릭터가 플랫폼이 이동해도 제자리에 남는 경우입니다. 각 엔티티가 독립된 월드 좌표만 가지고 있어서 생기는 문제로, 자식의 월드 변환을 “부모 월드 변환 × 자기 로컬 변환”으로 계산하는 씬 그래프가 있으면 부모를 따라 자식도 움직입니다.
다섯 번째는 리소스 중복 로드입니다. 적 10마리가 같은 enemy.png를 쓰는데 스프라이트마다 load_texture("enemy.png")를 호출하면 같은 텍스처가 메모리에 10개 올라갑니다. 경로를 키로 하는 캐시와 참조 카운팅을 가진 리소스 매니저로 해결합니다.
여섯 번째는 물리 좌표와 렌더 좌표의 불일치입니다. Box2D는 미터 단위, 게임 화면은 픽셀 단위인데 body->GetPosition()을 그대로 그리면 캐릭터가 엉뚱한 곳에 나타납니다. PPM(Pixels Per Meter) 상수로 render_x = physics_x * PPM, physics_x = render_x / PPM처럼 변환해야 합니다.
문제와 담당 서브시스템 다이어그램
flowchart TB
subgraph Problems[게임 엔진 문제]
P1[프레임 드랍/물리 불안정]
P2[엔티티 삭제 크래시]
P3[씬 로딩 블로킹]
P4[부모-자식 변환 누락]
P5[리소스 중복 로드]
P6[물리-렌더 좌표 불일치]
end
subgraph Solutions[해결책]
S1[고정 timestep + 보간]
S2[지연 삭제]
S3[비동기 리소스 로더]
S4[씬 그래프]
S5[리소스 매니저 캐싱]
S6[PPM 스케일 변환]
end
P1 --> S1
P2 --> S2
P3 --> S3
P4 --> S4
P5 --> S5
P6 --> S6
고정 timestep과 보간을 쓰는 게임 루프
입력·업데이트·렌더 순환
게임 루프는 입력, 업데이트, 렌더링을 반복합니다. 설계에서 까다로운 부분은 업데이트를 얼마나 자주, 어떤 dt로 돌릴지입니다.
flowchart LR
subgraph Frame[한 프레임]
A[입력 처리] --> B[업데이트]
B --> C[물리 스텝]
C --> D[렌더링]
D --> E[프레임 제한]
end
E --> A
고정 timestep과 누적기
물리와 게임 로직은 고정 timestep(예: 1/60초)으로 돌리고, 렌더링은 프레임마다 하면서 보간하는 구성이 표준입니다. 가변 timestep은 구현이 단순하지만 프레임 시간이 흔들릴 때마다 물리 결과가 달라지고 터널링이 생깁니다. 고정 timestep은 결과가 재현 가능한 대신, 저사양에서 업데이트가 밀리는 상황을 따로 막아야 합니다. 누적기(accumulator)로 고정 스텝을 여러 번 돌리는 루프 전체 코드, 두 방식의 비교, 프레임별 시퀀스는 C++ 게임 엔진 기초: 게임 루프·ECS·씬 그래프·입력 처리에서 다룹니다. 이 글에서 필요한 뼈대는 다음 몇 줄입니다.
constexpr float FIXED_DT = 1.0f / 60.0f; // 60Hz 물리
constexpr float MAX_ACCUMULATED = 0.25f; // 한 프레임에 쌓을 수 있는 최대 시간(초)
accumulated += std::min(frame_time, MAX_ACCUMULATED);
while (accumulated >= FIXED_DT) {
physics_system_.update(FIXED_DT);
game_logic_system_.update(FIXED_DT);
accumulated -= FIXED_DT;
}
render_system_.render(accumulated / FIXED_DT); // alpha: 다음 물리 스텝까지의 진행률(0~1)
MAX_ACCUMULATED가 없으면 디버거에서 멈췄다 재개하거나 로딩으로 프레임이 한 번 길어졌을 때, 그 시간을 따라잡으려고 물리 스텝을 수십 번 돌리고 그 스텝들이 다시 프레임을 늦추는 악순환(spiral of death)에 빠집니다. 0.25초 상한은 60Hz 기준으로 한 프레임에 최대 15스텝이라는 뜻이며, 그 이상 밀린 시간은 버리고 게임이 잠깐 느려지는 쪽을 택하는 것입니다.
보간(Interpolation) 적용
// TransformComponent에 이전 프레임 위치 저장
struct TransformComponent {
glm::vec3 position{0, 0, 0};
glm::vec3 prev_position{0, 0, 0}; // 이전 물리 스텝 위치
void sync_prev() {
prev_position = position;
}
};
// 물리 스텝 전: 이번 스텝이 바꾸기 전의 위치를 저장
void PhysicsSystem::update(float dt) {
for (auto* e : entities_) {
e->get_component<TransformComponent>()->sync_prev();
}
// ... 물리 연산으로 position 업데이트 ...
}
// 렌더 시 alpha로 보간
glm::vec3 get_interpolated_position(const TransformComponent& t, float alpha) {
return t.prev_position + (t.position - t.prev_position) * alpha;
}
sync_prev()를 부르는 위치가 중요합니다. 물리 연산 뒤에 호출하면 prev_position과 position이 항상 같아져서 보간이 아무 효과도 내지 못하고, 화면은 고정 스텝 단위로 뚝뚝 끊겨 움직입니다. 코드가 잘 돌아가는 것처럼 보여서 놓치기 쉬운 실수입니다. 이 방식은 화면이 실제 물리 상태보다 최대 한 스텝 늦게 보인다는 비용이 있지만, 60Hz 물리라면 약 16ms라서 대부분의 게임에서 체감되지 않습니다.
지연 삭제를 갖춘 ECS 구현
ECS 아키텍처 개요
ECS는 게임 객체를 세 가지로 나눕니다. 엔티티는 ID만 가진 빈 껍데기이고, 컴포넌트는 Transform, Sprite, RigidBody처럼 데이터만 담은 구조체이며, 시스템은 특정 컴포넌트 조합을 가진 엔티티들을 골라 처리하는 로직입니다. 상속 계층 대신 컴포넌트 조합으로 객체의 성격을 정하므로, “날아다니는 적”과 “헤엄치는 적” 사이에서 상속 구조를 고민할 필요가 없어집니다.
flowchart TB
subgraph ECS[ECS]
E1[Entity 1] --> C1[Transform]
E1 --> C2[Sprite]
E2[Entity 2] --> C3[Transform]
E2 --> C4[RigidBody]
E2 --> C5[Collider]
end
subgraph Systems[Systems]
S1[RenderSystem] --> C1
S1 --> C2
S2[PhysicsSystem] --> C3
S2 --> C4
S2 --> C5
end
엔티티·컴포넌트 기본 구현
#include <cstdint>
#include <memory>
#include <string>
#include <unordered_map>
#include <typeindex>
#include <vector>
using EntityID = uint32_t;
// 컴포넌트 베이스 (RTTI용)
struct Component {
virtual ~Component() = default;
};
// 위치·회전·스케일
struct TransformComponent : Component {
glm::vec3 position{0, 0, 0};
glm::vec3 prev_position{0, 0, 0};
glm::vec3 rotation{0, 0, 0};
glm::vec3 scale{1, 1, 1};
};
// 스프라이트
struct SpriteComponent : Component {
std::string texture_id;
SDL_Rect src_rect{0, 0, 32, 32};
int z_order = 0;
};
// 물리
struct RigidBodyComponent : Component {
glm::vec3 velocity{0, 0, 0};
float mass = 1.0f;
bool is_static = false;
};
// 충돌체
struct ColliderComponent : Component {
float width = 32.0f;
float height = 32.0f;
bool is_trigger = false;
};
class Entity {
public:
explicit Entity(EntityID id) : id_(id) {}
template <typename T, typename... Args>
T& add_component(Args&&... args) {
auto comp = std::make_unique<T>(std::forward<Args>(args)...);
T* ptr = comp.get();
components_[typeid(T)] = std::move(comp);
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_; }
private:
EntityID id_;
std::unordered_map<std::type_index, std::unique_ptr<Component>> components_;
};
이 구현은 이해하기 쉽지만 컴포넌트마다 힙 할당이 일어나고, 엔티티마다 해시 맵을 들고 있어 메모리 접근이 흩어집니다. 엔티티가 수천 개를 넘어가면 같은 종류의 컴포넌트를 연속된 배열에 모아 두는 방식(EnTT 같은 라이브러리의 sparse set 구조)이 캐시 효율 면에서 훨씬 유리합니다. 여기서는 구조를 이해하는 데 집중하기 위해 단순한 형태를 씁니다.
엔티티 매니저 + 지연 삭제
class EntityManager {
public:
Entity& create_entity() {
EntityID id = next_id_++;
auto entity = std::make_unique<Entity>(id);
Entity* ptr = entity.get();
entities_[id] = std::move(entity);
return *ptr;
}
void destroy_entity(EntityID id) {
entities_.erase(id);
}
// 지연 삭제: 프레임 종료 시 일괄 실행
void mark_for_destruction(EntityID id) {
to_destroy_.push_back(id);
}
void process_destruction() {
for (EntityID id : to_destroy_) {
destroy_entity(id);
}
to_destroy_.clear();
}
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;
}
Entity* get_entity(EntityID id) {
auto it = entities_.find(id);
return it != entities_.end() ? it->second.get() : nullptr;
}
private:
std::unordered_map<EntityID, std::unique_ptr<Entity>> entities_;
std::vector<EntityID> to_destroy_;
EntityID next_id_ = 1;
};
같은 ID가 한 프레임에 두 번 mark_for_destruction되어도 destroy_entity는 없는 키를 지우는 것뿐이라 문제가 없습니다. 반면 엔티티 ID를 재사용하는 구조라면, 지워진 엔티티의 ID를 들고 있던 다른 코드가 새로 생긴 엔티티를 가리키게 됩니다. 이를 막으려면 ID에 세대(generation) 번호를 함께 넣어, 옛 ID로 조회하면 실패하도록 만드는 것이 일반적입니다.
dirty 플래그로 갱신하는 씬 그래프
씬 그래프 개념
씬 그래프는 엔티티를 트리 구조로 묶습니다. 부모가 이동하거나 회전하면 자식도 함께 변환됩니다. 캐릭터가 든 무기, 차량의 바퀴, 움직이는 플랫폼 위의 오브젝트가 전형적인 예입니다.
flowchart TB
Root[Root] --> Player[Player]
Root --> World[World]
Player --> Sword[Sword]
Player --> Shield[Shield]
World --> Platform1[Platform1]
World --> Platform2[Platform2]
씬 노드 구현
월드 행렬을 매 프레임 모든 노드에서 다시 계산하면 낭비이므로, 로컬 변환이 바뀐 노드에만 dirty 플래그를 세우고 필요할 때 다시 계산합니다. 이때 부모가 바뀌면 그 아래 모든 자손의 월드 행렬도 무효가 되므로, dirty 표시를 자손 전체에 즉시 전파해야 합니다.
#include <algorithm>
#include <glm/gtc/matrix_transform.hpp>
class SceneNode {
public:
SceneNode(EntityID entity_id) : entity_id_(entity_id) {}
void set_parent(SceneNode* parent) {
if (parent_) {
auto& siblings = parent_->children_;
siblings.erase(std::remove(siblings.begin(), siblings.end(), this), siblings.end());
}
parent_ = parent;
if (parent_) {
parent_->children_.push_back(this);
}
mark_dirty(); // 부모가 바뀌면 월드 변환도 바뀜
}
void set_local_transform(const glm::vec3& pos, const glm::vec3& rot, const glm::vec3& scl) {
local_position_ = pos;
local_rotation_ = rot;
local_scale_ = scl;
mark_dirty();
}
// 자신과 모든 자손을 dirty로 표시 (이미 dirty인 가지는 건너뜀)
void mark_dirty() {
if (dirty_) return;
dirty_ = true;
for (auto* child : children_) child->mark_dirty();
}
// 부모 변환 × 로컬 변환 = 월드 변환
glm::mat4 get_world_matrix() {
if (dirty_) {
local_matrix_ = glm::mat4(1.0f);
local_matrix_ = glm::translate(local_matrix_, local_position_);
local_matrix_ = glm::rotate(local_matrix_, local_rotation_.z, glm::vec3(0, 0, 1));
local_matrix_ = glm::scale(local_matrix_, local_scale_);
if (parent_) {
world_matrix_ = parent_->get_world_matrix() * local_matrix_;
} else {
world_matrix_ = local_matrix_;
}
dirty_ = false;
}
return world_matrix_;
}
glm::vec3 get_world_position() {
glm::vec4 pos = get_world_matrix() * glm::vec4(0, 0, 0, 1);
return glm::vec3(pos);
}
EntityID get_entity_id() const { return entity_id_; }
SceneNode* get_parent() const { return parent_; }
const std::vector<SceneNode*>& get_children() const { return children_; }
private:
EntityID entity_id_;
SceneNode* parent_ = nullptr;
std::vector<SceneNode*> children_;
glm::vec3 local_position_{0, 0, 0};
glm::vec3 local_rotation_{0, 0, 0};
glm::vec3 local_scale_{1, 1, 1};
glm::mat4 local_matrix_{1.0f};
glm::mat4 world_matrix_{1.0f};
bool dirty_ = true;
};
class SceneGraph {
public:
SceneNode* create_node(EntityID entity_id) {
nodes_.push_back(std::make_unique<SceneNode>(entity_id));
return nodes_.back().get();
}
void update_world_transforms(EntityManager& entities) {
for (auto& node : nodes_) {
glm::mat4 world = node->get_world_matrix();
Entity* e = entities.get_entity(node->get_entity_id());
if (e) {
auto* t = e->get_component<TransformComponent>();
if (t) {
glm::vec4 pos = world * glm::vec4(0, 0, 0, 1);
t->position = glm::vec3(pos);
}
}
}
}
private:
std::vector<std::unique_ptr<SceneNode>> nodes_;
};
dirty 전파를 월드 행렬을 다시 계산할 때 하는 방식(부모를 계산하면서 자식에 표시)은 순서에 따라 한 프레임 늦게 반영되는 버그가 생깁니다. 자식이 부모보다 먼저 조회되면 아직 dirty가 아니라서 이전 행렬을 돌려주기 때문입니다. 위처럼 로컬 변환을 바꾸는 순간 자손 전체에 표시하면 조회 순서와 관계없이 항상 최신 값을 얻습니다.
update_world_transforms는 씬 그래프의 결과로 TransformComponent::position을 덮어씁니다. 그래서 같은 엔티티를 물리 엔진도 움직인다면 두 시스템이 서로의 결과를 덮어쓰게 됩니다. 한 엔티티의 위치는 물리가 정하든 씬 그래프(부모)가 정하든 한쪽만 소유하도록 정해 두어야 합니다. 예를 들어 물리로 움직이는 루트 엔티티의 위치를 씬 노드의 로컬 변환에 반영하고, 그 자식들만 씬 그래프로 계산하는 식입니다.
캐싱과 비동기 로딩을 하는 리소스 매니저
리소스 매니저 요구사항
리소스 매니저는 같은 경로의 파일을 다시 읽지 않도록 캐시하고, 참조 카운트로 아무도 쓰지 않는 리소스만 해제하며, 가능하면 파일 읽기를 메인 루프 밖에서 처리합니다.
텍스처 리소스 매니저
#include <string>
#include <unordered_map>
#include <memory>
#include <mutex>
class TextureResource {
public:
SDL_Texture* get() const { return texture_; }
int ref_count() const { return ref_count_; }
void add_ref() { ++ref_count_; }
void release() { --ref_count_; }
private:
SDL_Texture* texture_ = nullptr;
int ref_count_ = 1;
friend class ResourceManager;
};
class ResourceManager {
public:
explicit ResourceManager(SDL_Renderer* renderer) : renderer_(renderer) {}
// 동기 로드 (캐싱 포함) - 메인 스레드에서만 호출
TextureResource* load_texture(const std::string& path) {
auto it = textures_.find(path);
if (it != textures_.end()) {
it->second->add_ref();
return it->second.get();
}
SDL_Surface* surface = IMG_Load(path.c_str());
if (!surface) {
SDL_Log("Failed to load texture: %s", path.c_str());
return nullptr;
}
return create_from_surface(path, surface);
}
void release_texture(TextureResource* resource) {
if (!resource) return;
resource->release();
if (resource->ref_count() <= 0) {
for (auto it = textures_.begin(); it != textures_.end(); ++it) {
if (it->second.get() == resource) {
SDL_DestroyTexture(it->second->get());
textures_.erase(it);
break;
}
}
}
}
// 워커 스레드가 디코딩까지 마친 surface를 메인 스레드에서 텍스처로 올림
TextureResource* create_from_surface(const std::string& path, SDL_Surface* surface) {
SDL_Texture* tex = SDL_CreateTextureFromSurface(renderer_, surface);
SDL_FreeSurface(surface);
if (!tex) return nullptr;
auto resource = std::make_unique<TextureResource>();
resource->texture_ = tex;
TextureResource* ptr = resource.get();
textures_[path] = std::move(resource);
return ptr;
}
private:
SDL_Renderer* renderer_;
std::unordered_map<std::string, std::unique_ptr<TextureResource>> textures_;
};
release_texture가 전체 맵을 순회해 리소스를 찾는 것은 단순함을 위한 선택입니다. 리소스가 많다면 TextureResource에 경로를 저장해 두고 바로 erase(path)하면 됩니다. 수동 참조 카운트는 add_ref/release 짝이 한 번만 어긋나도 누수나 조기 해제가 생기므로, 실무에서는 std::shared_ptr과 커스텀 삭제자로 대신하는 경우가 많습니다.
비동기 로딩 큐 (메인 스레드 통합)
SDL_Renderer나 OpenGL 컨텍스트는 만든 스레드(보통 메인 스레드)에서만 써야 합니다. 그래서 워커 스레드는 파일 읽기와 이미지 디코딩(IMG_Load)까지만 하고, 텍스처 생성과 완료 콜백은 메인 루프에서 처리합니다.
#include <functional>
#include <mutex>
#include <thread>
#include <vector>
class AsyncLoadQueue {
public:
using Callback = std::function<void(TextureResource*)>;
// 워커 스레드에서 디코딩만 수행
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) {
TextureResource* res = d.surface ? rm.create_from_surface(d.path, d.surface) : nullptr;
d.cb(res);
}
}
~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_;
};
요청마다 스레드를 새로 만드는 것은 예시를 위한 단순화이고, 실제로는 고정된 수의 워커 스레드와 작업 큐를 둡니다. 흔히 std::async(std::launch::async, ...)를 호출하고 반환된 std::future를 버리는 코드를 보는데, 이 future의 소멸자는 작업이 끝날 때까지 기다리므로 사실상 동기 호출이 되어 버립니다. 비동기로 보이지만 로딩 중 화면이 멈추는 원인이 이것인 경우가 꽤 있습니다.
Box2D 물리 엔진을 ECS에 붙이기
Box2D와 ECS 연동
Box2D는 미터 단위를 쓰므로, 게임이 픽셀 단위라면 PPM(Pixels Per Meter)으로 변환합니다.
#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* 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>();
if (entity_to_body_.count(entity->get_id())) continue; // 이미 바디가 있으면 건너뜀
b2BodyDef def;
def.position.Set(t->position.x / PPM, t->position.y / PPM);
def.type = rb->is_static ? b2_staticBody : b2_dynamicBody;
def.userData.pointer = static_cast<uintptr_t>(entity->get_id()); // Box2D 2.4
b2Body* body = world_.CreateBody(&def);
b2PolygonShape box;
box.SetAsBox(col->width / (2 * PPM), col->height / (2 * PPM));
b2FixtureDef fix;
fix.shape = &box;
fix.density = 1.0f;
body->CreateFixture(&fix); // shape은 복사되므로 지역 변수여도 됨
entity_to_body_[entity->get_id()] = body;
}
}
void step(float dt) {
world_.Step(dt, 6, 2); // velocityIterations, positionIterations
}
void sync_from_physics(EntityManager& entities) {
for (auto* entity : entities.get_entities_with<TransformComponent, RigidBodyComponent, ColliderComponent>()) {
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>();
t->prev_position = t->position;
b2Vec2 pos = body->GetPosition();
t->position.x = pos.x * PPM;
t->position.y = pos.y * PPM;
b2Vec2 vel = body->GetLinearVelocity();
rb->velocity.x = vel.x * PPM;
rb->velocity.y = 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미터)에 걸려 빠른 물체가 이상하게 멈추는 증상이 여기서 나옵니다. 또 Box2D의 바디 위치는 도형의 중심인데, SDL 렌더링에 맞춰 TransformComponent를 왼쪽 위 모서리 기준으로 두었다면 변환할 때 width/2, height/2만큼 보정해야 충돌 지점과 그려지는 위치가 맞습니다.
sync_to_physics가 이미 만든 바디를 건너뛰지 않으면 프레임마다 같은 위치에 바디가 하나씩 늘어나고, 겹친 바디들이 서로 밀어내며 튕겨 나갑니다. 엔티티를 지울 때 destroy_body를 함께 호출하지 않으면 보이지 않는 바디가 월드에 남아 충돌을 일으키는 것도 흔한 실수입니다. 이 코드는 Box2D 2.4 API 기준이며, 2024년에 나온 Box2D 3.0은 b2World 클래스 대신 b2CreateWorld 같은 C 스타일 API와 ID 핸들로 바뀌었습니다. 직접 만든 AABB 물리와 비교하려면 C++ 게임 엔진 기초: 렌더링·물리·입력·스크립팅을 참고하세요.
GameEngine으로 묶은 통합 예제
통합 아키텍처
flowchart TB
subgraph Engine[Game Engine]
GL[GameLoop]
EM[EntityManager]
SG[SceneGraph]
RM[ResourceManager]
PS[PhysicsSystem]
RS[RenderSystem]
IS[InputSystem]
end
GL --> EM
GL --> SG
GL --> RM
GL --> PS
GL --> RS
GL --> IS
PS --> EM
RS --> EM
RS --> RM
SG --> EM
통합 클래스 구현
class GameEngine {
public:
bool init() {
if (SDL_Init(SDL_INIT_VIDEO) != 0) return false;
window_ = SDL_CreateWindow("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;
resource_mgr_ = std::make_unique<ResourceManager>(renderer_);
render_system_ = std::make_unique<RenderSystem>(renderer_, resource_mgr_.get());
physics_system_ = std::make_unique<Box2DPhysicsSystem>();
input_system_ = std::make_unique<InputSystem>();
create_sample_scene();
physics_system_->sync_to_physics(entities_); // 초기 바디 생성
return true;
}
void run() {
auto last_time = std::chrono::steady_clock::now();
float accumulated = 0.0f;
while (running_) {
auto now = std::chrono::steady_clock::now();
float frame_time = std::chrono::duration<float>(now - last_time).count();
last_time = now;
accumulated += std::min(frame_time, 0.25f);
input_system_->update();
if (input_system_->is_quit_requested()) break;
while (accumulated >= GameLoop::FIXED_DT) {
physics_system_->step(GameLoop::FIXED_DT);
physics_system_->sync_from_physics(entities_);
scene_graph_.update_world_transforms(entities_); // 물리가 소유하지 않는 노드만
accumulated -= GameLoop::FIXED_DT;
}
entities_.process_destruction(); // 지연 삭제 처리 (바디 제거도 함께)
float alpha = accumulated / GameLoop::FIXED_DT;
render_system_->render(entities_, alpha);
}
}
void shutdown() {
if (renderer_) SDL_DestroyRenderer(renderer_);
if (window_) SDL_DestroyWindow(window_);
SDL_Quit();
}
private:
void create_sample_scene() {
auto& player = entities_.create_entity();
player.add_component<TransformComponent>().position = {100, 200, 0};
player.add_component<SpriteComponent>().texture_id = "player";
player.add_component<RigidBodyComponent>();
player.add_component<ColliderComponent>();
auto& floor = entities_.create_entity();
floor.add_component<TransformComponent>().position = {0, 500, 0};
floor.add_component<SpriteComponent>().texture_id = "floor";
auto& rb = floor.add_component<RigidBodyComponent>();
rb.is_static = true;
floor.add_component<ColliderComponent>().width = 800;
floor.get_component<ColliderComponent>()->height = 100;
}
SDL_Window* window_ = nullptr;
SDL_Renderer* renderer_ = nullptr;
bool running_ = true;
EntityManager entities_;
SceneGraph scene_graph_;
std::unique_ptr<ResourceManager> resource_mgr_;
std::unique_ptr<RenderSystem> render_system_;
std::unique_ptr<Box2DPhysicsSystem> physics_system_;
std::unique_ptr<InputSystem> input_system_;
};
RenderSystem, InputSystem, GameLoop::FIXED_DT는 앞 글에서 만든 것을 그대로 쓴다고 가정했습니다. 프레임 시간을 잴 때는 high_resolution_clock 대신 steady_clock을 씁니다. high_resolution_clock은 구현에 따라 시스템 시계의 별칭이라, 사용자가 시계를 바꾸면 시간이 거꾸로 가거나 크게 튈 수 있습니다.
use-after-free, 물리 터널링, PPM 누락: 엔진 구현 실수
엔티티 삭제 후 use-after-free
destroy_entity를 호출한 직후 다른 시스템이 그 엔티티의 포인터에 접근하면 크래시합니다.
// ❌ 잘못된 예
void PhysicsSystem::on_collision(Entity* a, Entity* b) {
if (is_coin(b)) {
entities_.destroy_entity(b->get_id()); // 즉시 삭제
}
// RenderSystem이 아직 b를 참조 중!
}
// ✅ 올바른 예: 지연 삭제
void PhysicsSystem::on_collision(Entity* a, Entity* b) {
if (is_coin(b)) {
entities_.mark_for_destruction(b->get_id());
}
}
// 프레임 종료 시 entities_.process_destruction() 호출
물리 터널링 (빠른 오브젝트가 벽 통과)
총알이나 빠른 캐릭터가 한 스텝에 이동하는 거리가 벽 두께보다 크면, 이전 위치와 다음 위치 모두 벽 밖이라 충돌이 감지되지 않습니다.
// ❌ dt가 클 때 한 스텝에 이동 거리가 collider보다 큼
physics_system_.update(dt); // dt=0.05면 50ms 분량 한 번에
// ✅ 서브스텝 또는 고정 timestep
const int substeps = 4;
float sub_dt = dt / substeps;
for (int i = 0; i < substeps; ++i) {
physics_system_.update(sub_dt);
}
스텝을 잘게 나누면 확률은 줄지만 아주 빠른 물체에는 여전히 부족할 수 있습니다. Box2D에서는 총알 같은 바디에 bodyDef.bullet = true를 주면 연속 충돌 감지(CCD)가 적용되어, 두 위치 사이의 이동 경로 전체로 충돌을 검사합니다.
PPM 스케일 누락
Box2D와 연동했더니 캐릭터가 화면 밖으로 나가거나 너무 작게 보이는 경우입니다.
// ❌ 픽셀 좌표를 그대로 Box2D에 전달
def.position.Set(transform->position.x, transform->position.y);
// ✅ PPM로 변환
def.position.Set(transform->position.x / PPM, transform->position.y / PPM);
씬 그래프 dirty 전파 누락
부모를 옮겼는데 자식 위치가 갱신되지 않거나, 한 프레임 늦게 따라오는 경우입니다. 로컬 변환을 바꾸는 모든 경로에서 자신과 자손을 dirty로 표시해야 합니다.
// ✅ set_local_transform 시 자손까지 dirty 전파
void set_local_transform(const glm::vec3& pos, ...) {
local_position_ = pos;
mark_dirty(); // 자신 + 모든 자손
}
리소스 해제 시점 오류
아직 쓰이는 텍스처를 해제해서 검은 화면이 나오거나 크래시하는 경우입니다.
// ❌ 참조 카운팅 없이 즉시 해제
textures_.erase(path);
// ✅ 참조 카운팅
void release_texture(TextureResource* res) {
res->release();
if (res->ref_count() <= 0) {
// 실제 해제
}
}
렌더 순서 불안정 (z_order 동일 시)
같은 z_order인 스프라이트끼리 프레임마다 앞뒤가 바뀌며 깜박이는 경우입니다. get_entities_with가 unordered_map을 순회하므로 순서가 보장되지 않고, std::sort는 같은 키끼리의 순서를 유지하지 않습니다. 엔티티 ID를 두 번째 정렬 키로 쓰면 순서가 항상 같아집니다.
// ✅ 2차 키로 순서 고정
std::sort(entities.begin(), entities.end(),
[](Entity* a, Entity* b) {
int za = a->get_component<SpriteComponent>()->z_order;
int zb = b->get_component<SpriteComponent>()->z_order;
if (za != zb) return za < zb;
return a->get_id() < b->get_id();
});
고정 timestep 누적 시 스파이크
게임이 1초 동안 멈췄다 재개되면 accumulated가 1.0이 되어 한 프레임에 물리 스텝을 60번 돌리고, 그 프레임이 다시 길어집니다.
// ✅ 누적 시간 상한
accumulated += std::min(frame_time, 0.25f); // 60Hz 기준 최대 15스텝 분량
Lua/C++ 경계에서 엔티티 ID 오류
스크립트에서 이미 삭제된 ID로 destroy_entity(999)를 호출하는 경우입니다. 스크립트가 넘기는 값은 신뢰할 수 없으므로 C++ 쪽에서 유효성을 확인합니다.
// ✅ 삭제 전 유효성 검사
void destroy_entity(EntityID id) {
if (entities_.find(id) == entities_.end()) return;
entities_.erase(id);
}
워커 스레드에서 렌더러 사용
워커 스레드에서 SDL_CreateTextureFromSurface를 부르거나 완료 콜백에서 렌더러를 건드리면, 렌더링 컨텍스트가 메인 스레드에만 있어서 크래시하거나 드라이버에 따라 조용히 깨진 텍스처가 나옵니다. 앞의 AsyncLoadQueue처럼 워커는 디코딩까지만 하고, 텍스처 생성과 콜백은 메인 루프의 process_completed에서 처리합니다.
Box2D 콜백 안에서 바디 생성·삭제
충돌 콜백(b2ContactListener::BeginContact)은 world.Step() 도중에 불리는데, 이때 월드가 잠겨 있어서 CreateBody나 DestroyBody를 부르면 assert가 걸리거나 nullptr이 돌아옵니다. 콜백에서는 삭제할 엔티티를 표시만 하고, 실제 바디 제거는 Step()이 끝난 뒤 지연 삭제 단계에서 합니다. 앞의 ECS 지연 삭제와 같은 원리입니다.
시스템 순서·컴포넌트 설계·리소스 로딩 원칙
시스템 실행 순서는 입력, 물리, 게임 로직, 씬 그래프 갱신, 렌더링, 지연 삭제 순으로 고정합니다. 물리보다 입력을 먼저 처리해야 플레이어 조작이 같은 프레임에 반영되고, 순서를 고정해 두어야 버그가 났을 때 재현할 수 있습니다.
컴포넌트에는 데이터만 담고 로직은 시스템에 둡니다. 컴포넌트가 작을수록 같은 종류를 배열에 모았을 때 캐시 효율이 좋아집니다.
get_entities_with처럼 매번 전체 엔티티를 훑는 쿼리는 엔티티 수가 늘면 병목이 됩니다. 컴포넌트 조합별로 엔티티 목록을 유지하거나, 컴포넌트 저장소를 직접 순회하는 구조로 바꾸면 조회 비용이 크게 줄어듭니다.
씬을 로딩할 때는 첫 화면에 꼭 필요한 리소스만 동기로 읽고, 나머지는 로딩 화면을 보여 주면서 비동기로 읽습니다.
개발 빌드에서는 엔티티 수, 물리 스텝 시간, FPS를 오버레이로 표시해 두면 프레임 드랍의 원인을 빨리 좁힐 수 있습니다. PPM, FIXED_DT, MAX_ACCUMULATED 같은 값은 설정 파일로 빼 두면 다시 빌드하지 않고 조정할 수 있습니다.
씬 전환·상태 직렬화·이벤트 버스
씬 전환 시스템
아래는 구조를 보여 주는 개념 코드입니다. EntityManager::clear(), SceneGraph::clear()처럼 앞에서 정의하지 않은 함수가 필요합니다.
class SceneManager {
public:
void load_scene(const std::string& name) {
// 순서 중요: 물리 바디 → 엔티티 → 씬 노드 순으로 정리
physics_->destroy_all_bodies();
entities_.clear();
scene_graph_.clear();
if (scene_loaders_.count(name)) {
scene_loaders_[name](entities_, scene_graph_, *resource_mgr_);
}
}
void register_scene(const std::string& name,
std::function<void(EntityManager&, SceneGraph&, ResourceManager&)> loader) {
scene_loaders_[name] = std::move(loader);
}
private:
std::unordered_map<std::string, std::function<void(EntityManager&, SceneGraph&, ResourceManager&)>> scene_loaders_;
};
씬을 바꿀 때 엔티티만 지우고 물리 바디를 남겨 두면 다음 씬에 보이지 않는 벽이 생깁니다. 텍스처는 반대로, 다음 씬이 같은 텍스처를 쓸 수 있으므로 새 씬을 로드한 뒤에 참조 카운트가 0인 것만 해제하면 불필요한 재로딩을 피할 수 있습니다.
게임 상태 직렬화
void save_game(const std::string& path, EntityManager& entities) {
nlohmann::json j;
for (auto& [id, entity] : entities.get_all()) {
j["entities"].push_back(serialize_entity(*entity));
}
std::ofstream f(path);
f << j.dump();
}
저장 파일에는 버전 번호를 넣어 두어야 합니다. 컴포넌트 구조가 바뀐 뒤에도 이전 버전의 세이브를 읽어 변환할 수 있어야 업데이트 후 플레이어의 저장 데이터가 깨지지 않습니다.
디버그 오버레이
void render_debug_overlay(float dt, size_t entity_count) {
ImGui::Text("FPS: %.1f", 1.0f / dt);
ImGui::Text("Entities: %zu", entity_count);
ImGui::Text("Physics steps: %d", physics_step_count_);
}
객체 풀링과 이벤트 버스
총알처럼 자주 생기고 사라지는 엔티티는 지우지 않고 풀에 반환했다가 필요할 때 꺼내 컴포넌트를 초기화해서 재사용하면 할당 비용이 줄어듭니다. 시스템끼리 직접 호출하는 대신 이벤트 버스에 “코인 획득” 같은 이벤트를 발행하고 관심 있는 시스템이 구독하게 하면, 시스템 사이의 결합이 줄어 새 기능을 붙이기 쉬워집니다. 다만 이벤트 핸들러에서도 엔티티를 즉시 지우지 말고 지연 삭제를 써야 한다는 점은 같습니다.
다음 글: 데이터베이스 엔진 #50-4
자주 묻는 질문 (FAQ)
Q. Box2D를 붙였는데 물체가 너무 느리거나 이상하게 움직이는 이유는 무엇인가요?
Box2D는 미터·킬로그램·초 단위를 기준으로 동작하도록 조정되어 있어서, 화면 픽셀 값을 그대로 넣으면 수백 미터짜리 거대한 물체를 시뮬레이션하는 셈이 됩니다. 그러면 중력에 비해 움직임이 둔해 보이거나 속도 제한에 걸려 부자연스럽게 동작합니다. 픽셀과 미터 사이의 변환 상수(PPM)를 한 곳에 정의해 물리 월드에는 미터 단위로 넣고, 렌더링할 때만 픽셀로 변환하는 규칙을 엔진 전체에서 지켜야 합니다.
참고 자료
같이 보면 좋은 글
- C++ 게임 엔진 기초 | 렌더링·물리·입력·스크립팅 시스템 구현 [#50-3]
- C++ 커스텀 메모리 할당자(Memory Pool) 제작기 [#48-3]
- C++ 현대적인 C++ GUI: Dear ImGui로 디버깅 툴·대시보드 만들기 [#36-1]
- C++ 시리즈 전체 보기
- C++ 게임 엔진 기초: 게임 루프·ECS·씬 그래프·입력 처리 구현
- C++ 데이터 지향 설계 실전 | SoA·캐시 친화적 레이아웃·ECS·핫/콜드 분리 가이드
- C++ 채팅 서버 아키텍처: Acceptor-Worker 구조, 방 관리, 메시지 라우팅, 커넥션 풀