Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ add_library(ecs_engine STATIC
src/engine/EngineStepContext.h
src/engine/EngineSystem.h
src/engine/FrameClock.h
src/engine/ResourceStore.h
src/engine/WorldQuery.h
src/engine/CommandBuffer.h
src/engine/UpdatePhase.h
Expand Down Expand Up @@ -128,6 +129,7 @@ if (BUILD_TESTING)
tests/CommandBufferTests.cpp
tests/SystemSchedulerTests.cpp
tests/EngineIntegrationTests.cpp
tests/ResourceStoreTests.cpp
)

target_include_directories(safecrowd_tests
Expand Down
62 changes: 60 additions & 2 deletions docs/제출용/종합설계/Pathfinder_반영_발표_초안.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,5 +87,63 @@ run 요약, variation 요약, 비교, 누적 기록의 분리는, 개별 실행
# 슬라이드 10
일부 User Story의 Acceptance Criteria가 변경되어 Sprint 별 배치도 조금씩 변경되었습니다.

# 슬라이드 11 - 구현내용
(추가 예정)
# 슬라이드 11 - Engine 파트 구현 범위
이제 구현 내용 중에서 제가 담당한 Engine 파트를 간단히 설명드리겠습니다.
저희 프로젝트는 `application -> domain -> engine` 계층 구조를 유지하고 있는데, 여기서 Engine은 SafeCrowd에서 범용 ECS 실행 기반을 담당합니다.
제가 구현한 범위는 크게 다섯 가지로, `EcsCore`, `WorldQuery`, `CommandBuffer`, `EngineRuntime`, `SystemScheduler`입니다.
이 작업은 한 번에 큰 덩어리로 프로젝트에 구현한 것이 아니라, 이슈 단위로 쪼개서 브랜치와 PR 흐름으로 진행했습니다.
예를 들어 이슈 `#8`에서는 `ComponentRegistry`와 `EcsCore`를, 이슈 `#9`에서는 `WorldQuery`를, 이슈 `#10`에서는 `CommandBuffer`를, 이슈 `#12`에서는 `EngineRuntime`을, 그리고 이슈 `#47`에서는 `SystemScheduler`를 구현했습니다.
즉 엔티티와 컴포넌트를 저장하고 관리하는 코어부터, 시스템이 월드를 읽고 변경하는 방식, 그리고 실제 실행 루프와 스케줄링까지 단계적으로 확장한 것입니다.
이번 구현의 목표는 단순히 데이터를 담는 자료구조를 만드는 것이 아니라, 이후 시뮬레이션 로직이 안정적으로 올라갈 수 있는 실행 환경을 먼저 구축하는 것이었습니다.

# 슬라이드 12 - ECS 실행 구조 설계
구조를 보면 먼저 `EcsCore`가 엔티티 생성과 삭제, 컴포넌트 추가와 제거를 통합 관리합니다.
이때 컴포넌트가 추가되거나 제거되면 엔티티의 `Signature` 비트셋이 자동으로 갱신되도록 해서, 각 엔티티가 어떤 컴포넌트 조합을 가지고 있는지 일관되게 추적할 수 있도록 했습니다.
그 위에서 `WorldQuery`는 시스템이 필요한 엔티티만 조회할 수 있는 읽기 전용 인터페이스를 제공합니다.
반대로 구조 변경은 즉시 반영하지 않고, `CommandBuffer`와 `WorldCommands`를 통해 먼저 명령으로 기록한 뒤 나중에 한 번에 반영하도록 했습니다.
이렇게 읽기와 쓰기를 분리하도록 구현한 이유는, 시스템 실행 중 월드 구조를 바로 바꾸면 순회 안정성이 깨질 수 있기 때문입니다.

# 슬라이드 13 - Runtime과 Scheduler 구현 및 검증
이 구조 위에서 `EngineRuntime`은 전체 실행을 담당하는 최소 오케스트레이터 역할을 합니다.
프레임 시간을 누적한 뒤 `fixed timestep` 기준으로 필요한 만큼 simulation step을 반복 실행하고, 각 step마다 시스템이 호출되도록 구성했습니다.
그리고 `SystemScheduler`에서는 시스템마다 `phase`와 `order`를 지정할 수 있게 해서, `Startup`, `PreSimulation`, `FixedSimulation`, `PostSimulation`, `RenderSync` 같은 단계별 실행 순서를 제어할 수 있도록 했습니다.
또 각 phase가 끝나는 경계에서 `CommandBuffer`를 flush하도록 하여, 시스템이 실행되는 도중에는 구조 변경이 쌓이기만 하고 실제 반영은 일관된 시점에 일어나도록 했습니다.
마지막으로 이 구현은 `EcsCore`, `WorldQuery`, `CommandBuffer`, `EngineRuntime`, `SystemScheduler` 각각에 대해 테스트 코드를 작성해 생명주기 관리, 조회 필터링, deferred mutation, fixed-step 실행, phase 순서 보장을 검증했습니다.
정리하면, SafeCrowd에서 engine 파트 구현은 이후 도메인 시뮬레이션 로직을 안정적으로 올릴 수 있도록 ECS의 저장 구조와 실행 구조를 함께 구축한 작업이라고 말씀드릴 수 있습니다.

# 슬라이드 14 - 2DMap
도메인 계층의 구현 내용을 설명하기에 앞서 계층의 역할에 대해 설명하겠습니다. 도메인 계층은 시설 레이아웃 정의와 에이전트 이동 로직 등 시뮬레이션의 핵심 규칙과 데이터 구조를 포함하는 계층입니다. 시뮬레이션의 토대가 되는 Map은 FacilityLayout2D, Zone2D, Connection2D, Barrier2D, SpawnZone2D로 이루어져 있습니다.
FacilityLayout2D는 전체 시뮬레이션의 최상위 컨테이너입니다.
Zone2D는 보행자가 이동할 수 있는 구역,
Connection2D는 구역과 구역을 잇는 통로,
Barrier2D는 에이전트의 이동을 막는 고정 장애물,
SpawnZone2D 에이전트의 생성 지점 역할을 수행합니다.
출구는 Zone2D의 하위 속성으로 지정되고, 출구와 연결된 Connection2D 객체도 출구 속성을 갖습니다. 이 요소들은 에이전트의 경로 계산에 사용되고, 에이전트가 exitzone에 도달하면 도착한 것으로 판단하게 됩니다.

# 슬라이드 15 - Agent
스폰된 에이전트는 Position, Velocity, Goal, Agent 컴포넌트를 가집니다.
Position은 에이전트의 좌표, Velocity는 에이전트의 이동 방향과 속도, Goal은 목적지와 도착 여부, Agent는 에이전트 개인의 물리적 크기나 이동 능력치 등의 고유 속성을 정의합니다.
에이전트는 방향 벡터에 maxspeed 값을 곱해 이동 속도를 정합니다. 만약, 에이전트가 목적지에 도달했다면 goal의 도착 여부 값을 true로 설정하고 속도를 0으로 조정합니다.
충돌 여부 탐지를 위해서는 이동 경로상에 있는 벽 데이터를 가져와 다음 프레임 위치가 벽과 충돌이 발생하는지 계산합니다. 충돌이 발생하지 않은 경우, 속도를 그대로 유지하고 충돌이 발생한 경우, 벽의 법선 벡터를 구해 벽을 따라 이동하도록 처리합니다.
각 에이전트는 goal에 도착할 때까지 이 과정을 반복합니다.

# 슬라이드 16 - Import module
이 슬라이드는 Domain 계층에서 import 모듈이 도면 파일을 시뮬레이션에 사용할 수 있는 구조로 바꾸는 과정을 보여줍니다. 먼저 DxfImportService가 DXF 도면을 읽어 필요한 선과 도형 정보를 가져옵니다. 이 원본 정보는 RawImportModel에 한 번 보관해서, 나중에 문제가 생겼을 때 어떤 도면 요소에서 나온 것인지 추적할 수 있게 합니다. 그다음 CanonicalGeometry 단계에서 도면의 여러 요소를 벽, 출구, 보행 가능 영역처럼 공통된 의미로 정리합니다. 이후 FacilityLayoutBuilder가 이 정보를 방, 복도, 연결 통로 같은 시뮬레이션용 공간 구조로 변환합니다. 마지막으로 ImportValidationService가 출구 누락이나 끊긴 동선 같은 문제를 검사하고, ImportResult가 최종 결과와 문제 목록을 함께 넘겨서 사용자가 확인한 뒤 다음 단계로 넘어가게 합니다.

# 슬라이드 17 - 구현내용 - UI
이번 슬라이드에서는 Application, UI 구현 내용을 설명드리겠습니다.

먼저 요구 사항으로는, 처음 맵을 만들 때 사용자가 기본 도구의 위치를 쉽게 찾을 수 있어야 하고, 현재 작업이나 진행 상태가 화면에 보였으면 좋겠고,
또 도면을 import할 수 있는 기능이 필요하다는 점이 있었습니다.

이 요구를 반영해서, 맵 에디터를 처음 켰을 때 바로 사용할 수 있는 기본 도구 4가지를 화면 왼쪽에 배치했습니다.
또 사용자가 현재 진행 상황을 직관적으로 확인할 수 있도록 우측 속성 영역에 progress bar를 두었습니다.
그리고 메뉴바의 File - Import를 통해 도면 파일을 불러올 수 있도록 UI 흐름도 추가했습니다.

즉, 이번 UI 구현은 단순히 화면을 꾸민 것이 아니라, 사용자가 처음 진입했을 때 바로 도구를 찾고, 현재 상태를 확인하고,
외부 도면을 가져오는 기본 작업 흐름이 자연스럽게 이어지도록 구성한 것입니다.

아직 구현 예정인 부분도 있습니다.
예를 들어 결과 지표를 확인하는 전용 창과, 대안 시뮬레이션을 설정하는 창은 후속 작업으로 추가할 예정입니다.

정리하면, 현재 Application UI는 기본 편집 도구 배치, 진행 상태 표시, 그리고 import 진입 경로를 우선 반영한 상태라고 볼 수 있습니다.
4 changes: 3 additions & 1 deletion src/engine/EngineRuntime.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ EngineConfig normalizeConfig(EngineConfig config) {
EngineRuntime::EngineRuntime(EngineConfig config)
: config_(normalizeConfig(config)),
scheduler_(core_, buffer_),
world_(EngineWorld::ConstructionToken{}, core_, buffer_),
world_(EngineWorld::ConstructionToken{}, core_, resources_, buffer_),
frameClock_(config_) {
}

Expand All @@ -36,6 +36,7 @@ void EngineRuntime::addSystem(std::unique_ptr<EngineSystem> system,
void EngineRuntime::initialize() {
frameClock_.reset();
core_ = EcsCore{};
resources_ = ResourceStore{};
buffer_ = CommandBuffer{};
stats_ = {};
stats_.state = EngineState::Ready;
Expand Down Expand Up @@ -70,6 +71,7 @@ void EngineRuntime::pause() {
void EngineRuntime::stop() {
frameClock_.reset();
core_ = EcsCore{};
resources_ = ResourceStore{};
buffer_ = CommandBuffer{};
stats_ = {};
stats_.state = EngineState::Stopped;
Expand Down
2 changes: 2 additions & 0 deletions src/engine/EngineRuntime.h
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
#include "engine/EngineSystem.h"
#include "engine/EngineWorld.h"
#include "engine/FrameClock.h"
#include "engine/ResourceStore.h"
#include "engine/SystemDescriptor.h"
#include "engine/SystemScheduler.h"

Expand Down Expand Up @@ -39,6 +40,7 @@ class EngineRuntime {
EngineConfig config_;
EngineStats stats_;
EcsCore core_;
ResourceStore resources_;
CommandBuffer buffer_;
SystemScheduler scheduler_;
EngineWorld world_;
Expand Down
1 change: 0 additions & 1 deletion src/engine/EngineSystem.h
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
#include "engine/EngineStepContext.h"

namespace safecrowd::engine {

class EngineSystem {
public:
virtual ~EngineSystem() = default;
Expand Down
13 changes: 9 additions & 4 deletions src/engine/EngineWorld.h
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
#pragma once

#include "engine/CommandBuffer.h"
#include "engine/ResourceStore.h"
#include "engine/WorldQuery.h"

namespace safecrowd::engine {
Expand All @@ -13,6 +14,8 @@ class EngineWorld {
public:
[[nodiscard]] WorldQuery& query() noexcept { return query_; }
[[nodiscard]] const WorldQuery& query() const noexcept { return query_; }
[[nodiscard]] WorldResources& resources() noexcept { return resources_; }
[[nodiscard]] const WorldResources& resources() const noexcept { return resources_; }
[[nodiscard]] WorldCommands& commands() noexcept { return commands_; }

private:
Expand All @@ -25,14 +28,16 @@ class EngineWorld {
};

EngineWorld() = delete;
explicit EngineWorld(ConstructionToken, EcsCore& core, CommandBuffer& buffer)
: query_(core), commands_(buffer) {}
explicit EngineWorld(ConstructionToken, EcsCore& core, ResourceStore& resources,
CommandBuffer& buffer)
: query_(core), resources_(resources), commands_(buffer) {}

friend class EngineRuntime;
friend class internal::EngineWorldFactory;

WorldQuery query_;
WorldCommands commands_;
WorldQuery query_;
WorldResources resources_;
WorldCommands commands_;
};

} // namespace safecrowd::engine
81 changes: 81 additions & 0 deletions src/engine/ResourceStore.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
#pragma once

#include <any>
#include <stdexcept>
#include <typeindex>
#include <unordered_map>
#include <utility>

namespace safecrowd::engine {

class ResourceStore {
public:
template <typename T>
void set(T resource) {
resources_[std::type_index(typeid(T))] = std::move(resource);
}

template <typename T>
[[nodiscard]] bool contains() const {
return resources_.contains(std::type_index(typeid(T)));
}

template <typename T>
[[nodiscard]] T& get() {
return getInternal<T>();
}

template <typename T>
[[nodiscard]] const T& get() const {
return getInternal<T>();
}

private:
template <typename T>
[[nodiscard]] T& getInternal() const {
const auto it = resources_.find(std::type_index(typeid(T)));
if (it == resources_.end()) {
throw std::runtime_error("Requested resource is not present in ResourceStore");
}

auto* value = std::any_cast<T>(&it->second);
if (value == nullptr) {
throw std::runtime_error("Stored resource type does not match requested type");
}

return *value;
}

mutable std::unordered_map<std::type_index, std::any> resources_;
};

class WorldResources {
public:
explicit WorldResources(ResourceStore& store)
: store_(store) {}

template <typename T>
void set(T resource) {
store_.set(std::move(resource));
}

template <typename T>
[[nodiscard]] bool contains() const {
return store_.contains<T>();
}

template <typename T>
[[nodiscard]] T& get() {
return store_.get<T>();
}

template <typename T>
[[nodiscard]] const T& get() const {
return store_.get<T>();
}

private:
ResourceStore& store_;
};

} // namespace safecrowd::engine
5 changes: 3 additions & 2 deletions src/engine/internal/EngineWorldFactory.h
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@ class EngineWorldFactory {
public:
EngineWorldFactory() = delete;

[[nodiscard]] static EngineWorld create(EcsCore& core, CommandBuffer& buffer) {
return EngineWorld(EngineWorld::ConstructionToken{}, core, buffer);
[[nodiscard]] static EngineWorld create(EcsCore& core, ResourceStore& resources,
CommandBuffer& buffer) {
return EngineWorld(EngineWorld::ConstructionToken{}, core, resources, buffer);
}
};

Expand Down
45 changes: 45 additions & 0 deletions tests/EngineRuntimeTests.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@
namespace {

struct Marker {};
struct SharedCounter {
int value{0};
};

class UpdateCounterSystem : public safecrowd::engine::EngineSystem {
public:
Expand Down Expand Up @@ -64,6 +67,16 @@ class RecordPhaseSystem : public safecrowd::engine::EngineSystem {
}
};

class ResourceSetupSystem : public safecrowd::engine::EngineSystem {
public:
void configure(safecrowd::engine::EngineWorld& world) override {
world.resources().set(SharedCounter{7});
}

void update(safecrowd::engine::EngineWorld&, const safecrowd::engine::EngineStepContext&) override {
}
};

} // namespace

SC_TEST(EngineRuntimePlayAndStepUpdatesStats) {
Expand Down Expand Up @@ -259,3 +272,35 @@ SC_TEST(EngineRuntimePauseAndStopResetLifecycleState) {
SC_EXPECT_EQ(stats.fixedStepIndex, 0ULL);
SC_EXPECT_NEAR(stats.alpha, 0.0, 1e-9);
}

SC_TEST(EngineRuntime_WorldResources_AccessibleThroughEngineWorld) {
safecrowd::engine::EngineRuntime runtime;

runtime.addSystem(std::make_unique<ResourceSetupSystem>());
runtime.initialize();

SC_EXPECT_TRUE(runtime.world().resources().contains<SharedCounter>());
SC_EXPECT_EQ(runtime.world().resources().get<SharedCounter>().value, 7);
}

SC_TEST(EngineRuntime_Stop_ClearsWorldResourcesBeforeNextRun) {
safecrowd::engine::EngineRuntime runtime;

runtime.world().resources().set(SharedCounter{11});
SC_EXPECT_TRUE(runtime.world().resources().contains<SharedCounter>());

runtime.stop();

SC_EXPECT_TRUE(!runtime.world().resources().contains<SharedCounter>());
}

SC_TEST(EngineRuntime_Initialize_ClearsExistingWorldResources) {
safecrowd::engine::EngineRuntime runtime;

runtime.world().resources().set(SharedCounter{13});
SC_EXPECT_TRUE(runtime.world().resources().contains<SharedCounter>());

runtime.initialize();

SC_EXPECT_TRUE(!runtime.world().resources().contains<SharedCounter>());
}
47 changes: 47 additions & 0 deletions tests/ResourceStoreTests.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
#include "TestSupport.h"

#include <stdexcept>
#include <string>

#include "engine/ResourceStore.h"

namespace {

struct SampleResource {
int value{0};
};

} // namespace

SC_TEST(ResourceStore_SetGetAndContainsWork) {
safecrowd::engine::ResourceStore store;

store.set(SampleResource{42});

SC_EXPECT_TRUE(store.contains<SampleResource>());
SC_EXPECT_EQ(store.get<SampleResource>().value, 42);
}

SC_TEST(ResourceStore_GetMissingResourceThrows) {
safecrowd::engine::ResourceStore store;

bool threw = false;
try {
(void)store.get<SampleResource>();
} catch (const std::runtime_error&) {
threw = true;
}

SC_EXPECT_TRUE(threw);
}

SC_TEST(WorldResources_DelegatesToUnderlyingStore) {
safecrowd::engine::ResourceStore store;
safecrowd::engine::WorldResources resources{store};

resources.set(std::string{"seeded"});

SC_EXPECT_TRUE(resources.contains<std::string>());
SC_EXPECT_EQ(resources.get<std::string>(), std::string{"seeded"});
SC_EXPECT_EQ(store.get<std::string>(), std::string{"seeded"});
}
Loading
Loading