· 15 min read

OFF에 행성을 얹다 — Topology-Agnostic 아키텍처

무한 평면 분산 서버 프레임워크였던 OFF에 cube-sphere 행성을 얹은 이야기. 평면도 구체도 같은 코어 위에서 도는 구조로 어떻게 바꿨나.

무한 평면 분산 서버 프레임워크였던 OFF에 cube-sphere 행성을 얹은 이야기. 평면도 구체도 같은 코어 위에서 도는 구조로 어떻게 바꿨나.

이전 글 — OFF 소개 의 후속편. OFF 위에 cube-sphere 행성 을 얹은 이야기 — 평면도 구체도 같은 코어에서 도는 구조로 만들기.

출발점 — 평면 위 분산 셀 인프라

OFF 는 무한한 XZ 평면을 격자로 잘라 셀(cell) 단위로 서버에 배분하는 프레임워크다. 셀 1 개당 GameServer 1 대, 인접 셀 서버끼리 경계 영역 동기화, 엔티티가 셀 경계를 넘으면 다음 서버로 통째 이전 (handover) — 이게 뼈대.

OFF — 평면 위 분산 셀 인프라
OFF — 평면 위 분산 셀 인프라

서버를 추가할수록 월드가 가로 / 세로로 무한 확장. 자세한 건 이전 글 에.

의문 — “구체도 돌릴 수 있지 않을까”

이 인프라가 한참 동작하던 어느 시점, 의문이 떴다.

“셀들이 펼쳐지는 바닥 모양 만 평면에서 구체로 바꾸면 행성이 되지 않을까?”

분산 인프라 / 핸드오버 / ECS / 렌더러 다 그대로 두고 바닥 만 갈아끼우는 발상. 단순한데, 손대보니 단순하지 않았다 — 평면 가정이 코드 거의 모든 곳에 박혀있었다.

audit — 평면 가정이 박혀있던 곳들
audit — 평면 가정이 박혀있던 곳들

추상화로 풀어야 한다는 건 처음부터 명확했다. 진짜 문제는 “어떤 구조로, 어디까지” 추상화할지. 면을 잘못 그으면 새 위상 추가할 때마다 또 다 헤집어야 하고, 너무 위에 그으면 추상화 비용만 들고 분기는 그대로 남는다. 면을 어디에 긋는가 가 이번 작업의 본질이었다.

공간을 4 차원으로 분해

면을 긋기 전에 “월드의 공간이라는 게 뭘로 구성돼 있나” 부터 분해. 4 개 차원이 나왔다.

4-Layer 모델 — 월드의 공간 결정을 4개 독립 층으로 분리
4-Layer 모델 — 월드의 공간 결정을 4개 독립 층으로 분리

각 층을 차근히.

L1 — 월드 형태 (토폴로지)

게임이 펼쳐지는 바닥의 모양 자체. 평면 / 닫힌 구체 / 도넛 (토러스). 토폴로지가 결정되면 같은 점 두 개 사이 거리는 어떻게 계산되는지, 직진하면 어디로 가는지 같은 가장 근본적인 공간 규칙이 결정된다.

L1 — 월드 형태: 평면 vs 구체 vs 도넛
L1 — 월드 형태: 평면 vs 구체 vs 도넛

이 글의 주인공은 평면(Planar) 과 구체(Cube-sphere) 두 개. 도넛(Toroidal) 은 인터페이스가 잘 그어졌는지 검증하기 위한 스캐폴딩으로만 박아뒀다 (실제 활성화 안 됨).

L2 — 표면 분할 (셀 자르기)

L1 의 바닥 위에 어떻게 격자 무늬를 그어 셀로 자를 것인가. 평면이라면 단순 사각 grid (X, Y 정수 인덱스) 가 자연스럽다. 구체라면 — 여기서 수학적인 제약 이 하나 끼어든다.

구면을 다각형 셀로 분할하면 반드시 720° 의 각 결손이 발생한다. (Gauss-Bonnet)

평면에서는 한 점 주위에 모인 각이 360° 로 깔끔하게 닫히지만, 구면에서는 다각형들의 모서리 각을 다 합쳐도 720° 가 모자란다. 이 720° 를 어디로 분배하느냐가 구체 분할의 본질이다.

Gauss-Bonnet — 720° 의 각 결손을 어디로 분배할 것인가
Gauss-Bonnet — 720° 의 각 결손을 어디로 분배할 것인가

선택지는 — Geodesic / icosahedral (12 vertex × 60° 분배), HEALPix (천체 데이터 표준), Cube-sphere (8 corner × 90° 분배) 등. OFF 는 cube-sphere 를 골랐다:

  1. 평면 코드가 이미 사각 셀 + 4/8-way 인접 기반이라 마이그레이션 비용 최저
  2. Quadtree LOD 가 산업 표준 (Google Maps · S2 · Cesium 다 cube-sphere 패턴)
  3. 외부 의존 없이 자체 구현 가능
L2 — 표면 분할: 평면 grid vs cube-sphere quadtree
L2 — 표면 분할: 평면 grid vs cube-sphere quadtree

이 층의 출력물은 CellId (셀 식별자) + 인접 규칙. 분산 인프라가 가장 직접적으로 보는 게 이 층 — handover, AOI, snapshot 키, wire 라우팅 전부 여기 의존.

L3 — 셀 데이터 (셀 안에는 뭐가 있나)

한 셀의 내용물. 보셀 게임이라면 TerrainWidth × TerrainDepth × LayerCount 의 블록 격자 — 작게는 한 셀에 수십만, 크게는 수천만 개 보셀까지. 이 격자가 디스크 / wire 로 흐를 때는 ChunkSize 단위로 청크화한다 (청크는 저장 / 스트리밍 단위 지 셀의 구조 단위가 아님).

여기에 더해 멀리서 본 미리보기용 heightmap 피라미드 — 같은 지형을 4× / 8× / 16× / 32× 다운샘플한 텍스처 스택. 우주에서 행성을 봤을 때 voxel mesh 도착 전까지 행성 모양만 보여주는 데 쓴다.

L3 — 셀 데이터: 블록 격자 + heightmap preview
L3 — 셀 데이터: 블록 격자 + heightmap preview

셀의 내용물 이지 모양 이 아니라는 게 핵심. L1 이 cube-sphere 가 되어도 L3 는 한 줄도 안 바뀐다.

L4 — 수직 (고도 처리)

같은 (X, Y) 셀 위에 여러 층의 콘텐츠를 쌓을 수 있게 하는 차원. “지하 던전 / 지표 / 하늘섬” 같은 다층 구조. LayerCount 한 파라미터로 제어. 평면이든 구체든 “고도라는 축이 따로 있다” 는 사실은 그대로다 — 다만 구체에서는 셀 좌표계의 Y 축이 receiver tangent plane 기준 고도라서 절대 world Y 와 다르다 (뒤에서 자세히).

L4 — 수직 레이어: 같은 (X,Y) 위 다층 구조
L4 — 수직 레이어: 같은 (X,Y) 위 다층 구조

면을 어디에 그었나

분해가 끝나니 답이 명확해졌다. L1/L2 만 위상별로 다르고, L3/L4 는 위상 무관. 추상화 면은 L2 와 L3 사이. L2 까지의 모든 결정 (월드 형태 + 셀 자르기 + 인접 규칙) 을 한 인터페이스로 묶고, 위는 그 인터페이스만 보고 살게 하면 된다.

이 인터페이스가 IWorldTopology 다.

IWorldTopology — 한 인터페이스 뒤로

L2 까지의 결정을 한 인터페이스로 묶는 건 결국 공간에 대한 모든 질문 을 이 인터페이스로만 묻겠다는 뜻이다. 게임 로직 / ECS / 분산 인프라 / 클라이언트 렌더러가 던지는 공간 질문은 사실 몇 가지 안 된다.

public interface IWorldTopology
{
    // "이 좌표가 속한 셀이 뭐냐?"
    CellId LocateCell(WorldPoint point);
    // "이 셀의 중심 좌표가 뭐냐?"
    WorldPoint CellCenter(CellId cell);
    // "이 셀의 이웃은 누구누구냐?"
    IEnumerable<CellId> Neighbors(CellId cell);
    // "이 셀에서 저 셀로 넘어갈 때 좌표축이 어떻게 회전하느냐?"
    EdgeTransform GetEdgeTransform(CellId cell, int edge);

    // "셀 내부 좌표 (lx, ly, lz) ↔ 절대 좌표"
    WorldPoint CellLocalToWorldPoint(CellId cell, double lx, double ly, double lz);
    (double lx, double ly, double lz) WorldPointToCellLocal(CellId cell, WorldPoint point);

    // "이 점에서 반경 R 안 셀들" (AOI 용)
    IEnumerable<CellId> CellsInRadius(WorldPoint center, double radius);
    // quadtree 부모/자식 (cube-sphere 만 의미 있음)
    IEnumerable<CellId> Children(CellId cell);
    bool TryGetParent(CellId cell, out CellId parent);
}

이게 L1/L2 의 모든 위상-별 지식이 갇히는 면이다. 새 위상 추가 = 이 인터페이스 구현체 하나 + Factory 한 줄.

IWorldTopology — 한 인터페이스 뒤로 위임된 구조
IWorldTopology — 한 인터페이스 뒤로 위임된 구조

함께 도입한 위상-중립 데이터 타입 셋 — CellId (셀 식별자, 128-bit, wire/디스크/URL 공통) · WorldPoint (전역 cartesian, 송신자가 어떤 위상인지 모르고 받음) · EdgeTransform (셀 사이 좌표 변환, 평면 = Identity / 구체 face 경계 = 90° quaternion).

두 짝의 메서드 — 그림으로

Neighbors() + GetEdgeTransform() — 이웃이 누구냐 + 넘어갈 때 좌표축이 어떻게 도느냐.

Neighbors + EdgeTransform — Planar vs Cube-Sphere
Neighbors + EdgeTransform — Planar vs Cube-Sphere

평면은 8-way 인접에 EdgeTransform 이 항상 Identity. Cube-sphere 는 4-way + cube face 경계만 90° 회전. 같은 face 안 인접은 평면처럼 Identity 지만, face 를 가로지르면 90° 회전이 발동된다. 24-entry lookup table (6 face × 4 edge) 이 어느 face 의 어느 edge 로 갔고 어떻게 회전됐는지 박아둔다.

CellLocalToWorldPoint / WorldPointToCellLocal — 셀 안 좌표 ↔ 전역 좌표.

CellLocal ↔ WorldPoint — Planar vs Cube-Sphere
CellLocal ↔ WorldPoint — Planar vs Cube-Sphere

평면은 셀의 local frame 이 world frame 과 정렬돼서 변환이 그냥 translation. Cube-sphere 는 셀 local frame 이 그 셀이 sphere 표면에 닿는 지점의 tangent frame 이라, ly 가 셀마다 radial outward 방향이 다 다르다. 어느 셀의 ly 도 world Y 와 같지 않다.

큰 행성에서만 일어나는 일들

인터페이스 모양이 깔끔해도, 평면 가정이 박혀있던 코드를 옮기는 과정에서 구체로 가니까 비로소 보이는 일들 이 한둘이 아니었다. 종류만 풀어둔다 — 각각은 후속글로 따로 쓸 만한 분량이다.

좌표 정밀도. 지구 크기 행성에서는 표면 좌표 절댓값이 수백만 미터 단위라 32-bit float 가 미터 단위로 떨림. 모든 좌표를 카메라 상대로 변환해서 셰이더로 보내는 origin shift 가 거의 모든 렌더 경로에 침투해야 했다.

Stream-time 렌더링. 우주 멀리서도 행성 모양이 보여야 하고, 가까이 가면 voxel mesh 가 채워져야 하고, 그 전환이 매끄러워야 한다. Per-cell tier state machine (None / Preview / Live) 으로 풀었다.

Tier state machine — None / Preview / Live 전환
Tier state machine — None / Preview / Live 전환

진입/이탈 임계값을 다르게 두어 thrashing 막고, “1 cell = 1 mesh at any instant” invariant 로 preview / live depth-fight 차단.

Cross-face handover. cube 의 6 face 가 만나는 모서리에서 셀 좌표축이 90° 돌아간다는 사실은 카메라뿐 아니라 엔티티 핸드오버에도 박혀있다. cube corner (한 점을 3 셀이 공유) 에서 발생하는 ping-pong 문제, 그리고 cell-local Y 와 world Y 를 혼동해서 entity 가 셀 경계마다 50~100m 솟구치는 텔레포트 — 둘 다 평면 가정이 코드 깊숙이 박혀있던 흔적이고, 둘 다 invariant 테스트로 회귀 방지를 박아뒀다.

지금 동작

평면 OFF 가 그대로 돌면서 (회귀 0) 같은 코어 위에 cube-sphere 행성이 얹혔다. 우주 멀리서 행성 전체를 preview 로 보다가, 가까이 들어가면 voxel mesh 가 채워지고, 표면 위를 비행하면서 face 경계를 매끄럽게 통과하는 데까지 모두 같은 인프라로 동작한다.

우주 → 표면 줌인 + 행성 회전 시연

상용 게임 엔진/미들웨어 중 분산 서버 simulation + LOD streaming + 평면 / 구체 듀얼 위상 을 한 코드베이스로 굴리는 건 거의 못 봤다.

  • Unity / Unreal — 월드는 평면 또는 specific level
  • SpatialOS — 분산은 잘 하지만 위상은 평면 가정
  • Outerra / Cesium — 행성 렌더링은 잘 하지만 분산 simulation 은 안 함

OFF 의 시도는 셋이 동시에 한 코어 위에 얹힌 형태다.

후속글들

이번 글은 전체 그림 한 번이 목적이라 디테일은 의도적으로 잘랐다. 후속으로 풀 만한 갈래들:

  • 좌표 정밀도와 origin shift — 큰 행성에서 f32 살아남기, cam-relative 셰이더 변환
  • LOD pyramid + screen-space-error — atlas / scheduler / dirty-tracker / memory(LRU) 4 컴포넌트
  • Stream-time renderingdrawMissing (stale-self), child-dirty re-enqueue, invalidation coalescing
  • Handover 의 cross-face quaternion — 24-entry edge lookup table, valence-3 corner
  • Terrain scripting 의 footgun — 큰 행성에서 noise.octave3D 가 cell-uniform 결과 내는 케이스

링크는 글이 올라가는 대로 여기에 붙인다.

참고

  • 본문 SoT: docs/architecture/TOPOLOGY_AGNOSTIC_ARCHITECTURE.md (OFF 리포 내부)
  • 행성 사이징: docs/design/SPHERICAL_WORLD_SCENARIOS.md
  • 외부: Google S2, Outerra cube-sphere blog, Cesium 3D Tiles spec, Snyder “An Equal-Area Map Projection For Polyhedral Globes” (1992)

Related Posts

View All Posts »
엔진 독립 풀스택 오픈월드 게임 프레임워크 — OFF

엔진 독립 풀스택 오픈월드 게임 프레임워크 — OFF

엔진에 종속되지 않는 풀스택 오픈월드 게임 프레임워크 OFF(Open Field Framework) 소개. 분산 .NET 서버, Three.js/WebGPU 웹 클라이언트, 월드 저작 툴링까지 한 번에 제공한다.

#OFF #Architecture #CSharp #Multiplay

C# 로직을 TypeScript로 옮기는 transpiler를 만들었다 — Mirrorgen

타입만 생성하는 codegen으로는 부족했다. C#의 로직을 byte-exact한 TypeScript로 옮기고, 두 구현이 같은 값을 내는지 자동 검증하는 transpiler를 만들어 OSS로 뗐다.

#dotnet #typescript #transpiler #Roslyn
NuGet 프라이빗 패키지로 .NET 코드 분리하기 — 멀티 레포 9개 함정

NuGet 프라이빗 패키지로 .NET 코드 분리하기 — 멀티 레포 9개 함정

OFF를 NuGet 프라이빗 패키지로 떼어, 협업자에게 프레임워크·서버 소스를 노출하지 않고도 풀스택 dev 루프를 유지한 멀티 레포 구조와, 옮기면서 밟은 9개의 함정.

#OFF #Aethelgard #GameDev #DotNet
AI에게 게임 월드를 만들라고 시켰더니 — AI-Driven Game Engine Development #1

AI에게 게임 월드를 만들라고 시켰더니 — AI-Driven Game Engine Development #1

AI가 월드를 자율적으로 만들려면 뭘 준비해야 하는가. 도구를 설계하고, 시행착오를 거치고, 벽 없는 미로에서 교훈을 얻기까지.

#OFF #AI #Claude #GameDev