· 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 »

엔진과 게임을 함께 개발하는 구조 — 승격 프로토콜, 파사드, 짝 worktree

게임 splanet.io는 직접 만든 엔진 OFF 위에 있다. 레포는 둘인데 변경은 대개 하나다. 무엇을 엔진에 올릴지는 규칙이 아니라 감각이지만, 어떻게 올릴지는 프로토콜이 있다. 파사드로 경계를 좁히고, worktree를 짝으로 움직이고, 어느 코딩 에이전트가 와도 같은 계약에서 출발하게 만든다.

#OFF #GameDev #Architecture #Git
엔진 독립 풀스택 오픈월드 게임 프레임워크 — 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