Devlog · Rendering

브라우저에서 960m 오픈월드를 돌리기

맵 한 변을 320m에서 960m로 늘렸다. 면적은 9배가 된다. 렌더 비용도 9배가 될 거라고 생각했는데, 실측해 보니 1.94배였다. 그 차이가 어디서 나왔는지, 그리고 이 숫자를 그대로 믿으면 안 되는 이유를 적는다.

1. 한 변을 늘리면 무엇이 같이 늘어나는가

맵 크기를 상수로 박아두면 이런 확장은 불가능하다. 처음 구조에서는 WORLD_SIZE가 컴파일 시점 상수였고, 그 값에서 파생된 배열이 다섯 군데에 흩어져 있었다 — 내비게이션 그리드 크기, 유닛 공간 해시, 지형 지오메트리, 숲 산포 범위, 미니맵 투영. 크기를 바꾸려면 다섯 곳을 동시에 맞춰야 했고, 그건 "숫자만 낮춰서 후퇴한다"가 불가능하다는 뜻이다.

그래서 먼저 한 일은 기능 추가가 아니라 런타임 설정화였다. 부팅 시점에 크기를 받아 파생 배열을 전부 할당하도록 바꾸면, 성능이 안 나올 때 코드를 건드리지 않고 숫자만 되돌릴 수 있다. 이건 안전판이고, 큰 변경에서는 안전판을 먼저 만드는 편이 결국 빠르다.

크기에 따라 실제로 무엇이 몇 배가 되는지 정리하면 이렇다.

대상규칙320m960m
높이장 샘플약 1.25m/샘플256²768²
내비 그리드 (정밀 2m)한 변 ÷ 2160² = 25.6k480² = 230k
내비 그리드 (개략 8m)정밀 ÷ 440²120²
지형 청크약 160m씩2×26×6
지형 쿼드1.6m씩200²600²
지면 베이크 텍스처320m당 512px512²1536²
숲 청크240m씩2×24×4
소품(바위·풀 등)면적 비례×1×9

눈여겨볼 것은 내비 그리드다. 셀 수가 25.6k에서 230k로 9배 늘어난다. 이 숫자가 나중에 길찾기를 붕괴시켰고, 그건 별도의 글로 다뤘다.

2. 단일 평면을 청크로 쪼개는 이유

원래 지형은 PlaneGeometry(320, 320, 200, 200) 한 장이었다. 메시가 하나면 드로우 콜도 하나라서 좋아 보이지만, 문제는 프러스텀 컬링이 전혀 작동하지 않는다는 것이다. 카메라가 맵의 한쪽 구석을 보고 있어도 지형 메시 전체가 정점 셰이더를 통과한다. 960m에서 이건 600×600 = 36만 쿼드를 매 프레임 변환한다는 뜻이다.

6×6 청크(각 160m, 64×64 세그먼트)로 쪼개면 드로우 콜이 1개에서 최대 36개로 늘지만, 화면에 실제로 걸리는 청크만 통과한다. 일반적인 RTS 카메라 각도에서는 36개 중 6~10개만 보인다. 드로우 콜 몇십 개는 현대 GPU에서 무시할 수 있는 비용이고, 정점 30만 개는 그렇지 않다.

텍스처도 같은 문제를 겪는다. 960m을 512² 텍스처 한 장으로 덮으면 픽셀당 1.9m — 지면이 완전히 뭉개진다. 해결은 전역 매크로 albedo(1536²)와 타일링 디테일 맵을 블렌딩하는 것이다. 매크로는 큰 색 변화(초지·길·습지)를 담고, 디테일은 가까이서 보이는 질감을 담는다. 둘을 셰이더에서 섞으면 한 장으로 전부 담을 때보다 훨씬 적은 메모리로 더 선명해진다.

3. 숲: 후보 슬롯 7.9k → 71k

나무는 면적에 비례하므로 정직하게 9배가 된다. 후보 슬롯이 7,900개에서 71,000개로 늘었다. 전부 개별 메시로 두면 즉사한다. 세 가지를 겹쳐 썼다.

결과적으로 인스턴스는 32.5k에서 140k로 4.3배 늘었지만 InstancedMesh 개수는 357개에서 520개로 1.5배만 늘었다. 드로우 콜은 인스턴스 수가 아니라 메시 수를 따라간다는 점이 여기서 이득으로 돌아온다.

4. 실측

유닛 327기, 1600×900, 같은 기기에서 같은 방법으로 두 열을 측정했다. 단위는 호출당 밀리초, 20~30회 평균이다.

항목320m960m배율
면적0.10 km²0.92 km²9.0×
engine.render()2.414.671.94×
units.tick()0.470.300.6×
맵 횡단 orderMove0.211.738.2×
InstancedMesh3575201.5×
인스턴스32.5k140k4.3×
씬 정점81k412k5.1×

면적 9배에 렌더 비용 1.94배. 청킹과 프러스텀 컬링이 제 일을 한 것이다. 화면에 들어오는 양은 맵 크기와 무관하게 카메라가 정하기 때문에, 맵을 넓히는 것은 렌더러에게 거의 공짜다 — 보이지 않는 것을 실제로 건너뛸 수 있게 구조를 짜 두었다면.

한편 units.tick()더 빨라진 것은 최적화의 성과가 아니다. 같은 유닛 수가 9배 넓은 공간에 퍼지면 공간 해시의 셀당 밀도가 낮아지고, 근접 질의가 검사할 후보가 줄어든다. 맵이 넓어서 싸진 것이지 코드가 좋아진 게 아니다. 이런 항목은 개선으로 보고하면 안 된다.

진짜 대가는 orderMove의 8.2배다. 맵을 횡단하는 경로 하나가 0.21ms에서 1.73ms로 뛰었다. 면적 배율과 거의 정확히 일치한다 — 즉 길찾기는 청킹으로 숨길 수 없고, 알고리즘 자체를 바꿔야 한다는 신호다.

5. 이 측정을 믿으면 안 되는 부분

위 숫자는 백그라운드 상태의 미리보기 창에서 측정됐다. 브라우저는 보이지 않는 탭의 requestAnimationFrame을 스로틀하고, 실제 GPU 작업을 건너뛰기도 한다. 이 환경에서 프레임 간격은 120ms까지 벌어졌고, 렌더러의 드로우 콜 카운터는 유닛 344기가 있는 씬에서 1을 보고했다. 344기짜리 씬이 드로우 콜 1개일 수는 없다. 그 숫자는 무효다.

그래서 위 표에서 신뢰할 수 있는 것은 배율뿐이다. 두 열을 완전히 같은 조건에서 측정했으므로 비교는 유효하다. 하지만 절대 프레임레이트는 검증되지 않았다. "960m에서 60fps가 나온다"는 주장은 이 데이터로 할 수 없다.

CPU 측 비용은 rAF 스로틀과 무관하게 잴 수 있어서 따로 측정했다. 유닛 약 280~315기에서 units.tick()의 정상상태는 p50 0.4ms, p95 1.7ms였다. 다만 처음 뽑은 p95는 7.7ms였는데, 이건 측정 루프의 워밍업/JIT 구간이 섞인 것이었다. 원계열을 눈으로 보고 나서야 앞쪽 몇 샘플만 5ms대이고 그 뒤는 0.3~0.4ms로 안정된다는 걸 알았다. 백분위수만 보고 스파이크라고 보고했으면 없는 문제를 고치고 있었을 것이다.

6. 남은 것

목표는 "960m·유닛 720기에서 60fps"였다. 현재 상태는 이렇다.

이걸 "통과"로 적어두면 나중에 실기기에서 문제가 터졌을 때 어디서부터 의심해야 할지 알 수 없게 된다. 못 잰 건 못 쟀다고 적어두는 편이 나중의 자신에게 훨씬 친절하다.