코드로 성을 세우다
모델링 툴 없이 코드로만 4단계로 자라는 성을 만들었다. 기법은 단순하지만 기하가 틀리는 방식이 다양했다. 부유하는 문루, 벽 한가운데 박힌 타워, 90도 돌아간 성문 — 전부 실제로 있었던 일이다.
1. 머티리얼별로 병합한다
절차적 건물을 만들 때 가장 쉬운 방법은 부품마다 Mesh를 하나씩 만들어
Group에 담는 것이다. 성 하나에 벽 8개, 타워 4개, 흉벽 128개, 깃발 5개...
금방 수백 개의 드로우 콜이 된다. 성이 여러 개면 즉사한다.
해결은 지오메트리를 머티리얼별로 모아서 한 번에 병합하는 것이다.
Three.js의 BufferGeometryUtils.mergeGeometries가 이걸 해준다.
부품을 만들 때 머티리얼별 배열에 넣고, 마지막에 배열마다 한 번씩 병합한다.
const stone = [], timber = [], plank = [], roofs = [], iron = [];
// ... 부품을 만들면서 해당 배열에 push ...
const wall = new THREE.BoxGeometry(len, height, thick);
wall.rotateY(yaw);
wall.translate(mx, height / 2, mz);
stone.push(wall);
// 마지막에 머티리얼당 메시 1개
const add = (parts, mat) => {
const geo = mergeGeometries(parts);
if (geo) group.add(new THREE.Mesh(geo, mat));
};
add(stone, m.rock);
add(timber, m.bark);
add(plank, m.planks);
성 한 채가 4~6 드로우 콜로 끝난다.
핵심은 지오메트리를 만든 뒤 rotateY/translate로
지오메트리 자체를 변형해 두는 것이다.
mesh.position을 쓰면 병합할 수 없다 — 병합은 정점 데이터를 합치는 것이므로
변환이 정점에 이미 구워져 있어야 한다.
대가가 있다. 병합된 메시는 부품 단위로 숨기거나 색을 바꿀 수 없다. 건설 중 연출을 만들 때 이게 문제가 됐는데, 클리핑 평면이나 머티리얼 복제 대신 모델 전체를 땅 밑으로 내리고 지형의 깊이 버퍼가 가리게 하는 방법으로 피했다. 묻힌 부분은 그냥 그려지지 않는다. 병합 배칭을 지키면서 건설 연출을 얻는 방법이었다.
2. 다각형 성벽의 기본 산수
원형 성벽을 만들 때 실제로는 정n각형을 쓴다. 여기서 거의 모든 기하 버그의 원인이 되는 값이 하나 있다.
반지름 R인 정n각형에서, 꼭짓점은 중심에서 R만큼 떨어져 있지만
변의 중점은 R·cos(π/n)만큼 떨어져 있다.
8각형(n=8)이면 cos(π/8) ≈ 0.924다. 변의 중앙은 꼭짓점보다
중심에 7.6% 가깝다. R이 9.6m라면 0.73m 차이다.
로우폴리 게임 카메라에서 0.73m는 명확하게 보이는 간격이다.
또 하나 자주 필요한 값은 변의 길이다.
변의 길이 = 2·R·sin(π/n)
그리고 벽 안쪽에 통로를 붙일 때, 반지름 r에서의 현 길이는
2·r·tan(π/n)이다. 이 세 공식을 헷갈리면 뒤에 나오는 버그들이 전부 나온다.
3. 실제로 틀렸던 것들
3-1. 성문이 90도 돌아가 있었다
성문 방향을 계산할 때 -ga를 yaw로 썼다.
ga는 게이트 만(bay)의 바깥 방향 방위다.
그런데 문짝과 상인방이 놓여야 하는 방향은 벽선의 방향이고,
그건 바깥 방위에서 90도 돌아간 값이다.
const gyaw = -(ga + Math.PI / 2); // 벽선 방향
// -ga 를 쓰면 문짝이 개구부에 대해 날 세우고 서 있다
결과적으로 문짝이 문틀에 평행하지 않고 수직으로 서 있었다. 정면에서 보면 문이 안 보이고 얇은 널 하나만 보인다. 성벽 빌더는 같은 값을 자기 현(chord)에서 유도하고 있었기 때문에 벽은 정상이었고, 그래서 "문만 이상하다"로 보였다.
3-2. 문루 타워가 공중에 떠 있었다
게이트 양옆 타워를 "만 방향으로 R만큼 간 지점에서 좌우로 오프셋"해서 배치했다.
하지만 2절의 이유로 만을 가로지르는 벽면은 R·cos(π/n)에 있다.
타워는 R에, 벽은 0.92R에 있으니 양쪽 벽 끝과 타워 사이에 틈이 생기고,
그 사이에 걸린 문짝은 공중에 매달렸다.
해결은 오프셋 계산을 버리고 만 자신의 두 꼭짓점에 타워를 놓는 것이다.
꼭짓점은 정의상 벽이 실제로 만나는 지점이므로 틈이 생길 수 없다.
그리고 아치의 폭은 2·R·sin(π/n) — 만의 실제 폭이어야 한다.
3-3. 드럼 타워가 전부 벽 한가운데 박혀 있었다
주석에는 "꼭짓점을 번갈아 배치"라고 적혀 있었지만,
코드는 임의의 +0.4rad 오프셋에서 균등 분할하고 있었다.
결과적으로 모든 타워가 가장 가까운 꼭짓점에서 22도(호 길이 4.4m) 떨어진,
직선 벽의 중앙에 앉았다. 벽면은 0.92R인데 타워는 R에 있으니
성벽을 관통해 튀어나온 독립 실린더로 보였다.
이 버그가 오래 살아남은 이유는 주석이 의도를 정확히 적어두었기 때문이다. 코드를 읽는 사람은 주석을 믿고 넘어갔다. 고친 뒤에는 배치를 순수 함수로 분리하고, 캔버스 없이도 검증할 수 있게 테스트를 붙였다.
export function keepTowerCorners(level) {
// 정수 꼭짓점 인덱스만 반환한다 — 이게 계약이다
}
테스트는 "타워 위치가 정수 인덱스인가", "게이트 만을 피하는가", "한 꼭짓점에 두 개가 겹치지 않는가"를 검사한다. 좌표를 검사하는 게 아니라 계약을 검사하는 편이 오래 간다.
3-4. 성벽 통로가 벽에서 1.1m 떠 있었다
벽 안쪽 순찰로를 배치할 때 만의 중점 좌표를 0.82배로 스케일했다.
비율 스케일은 방향을 유지하지만 거리를 정확히 통제하지 못한다.
결과는 세 가지 오류의 조합이었다 — 통로가 담당 만보다 양쪽으로 0.65m씩 길어서
이웃 통로와 교차하고, 벽에서 1.1m 떠 있고, 지지물이 없어 공중 선반처럼 보였다.
비율 스케일 대신 안쪽 법선 방향으로 정해진 거리만큼 옮기고, 판자 길이는 그 반지름에서의 실제 현 길이로 잘랐다.
const midR = Math.hypot(midX, midZ);
const walkR = midR - 0.55; // 안쪽으로 정확히 0.55m
const walkLen = 2 * walkR * Math.tan(Math.PI / n); // 그 반지름의 현 길이
그리고 통로를 받치는 브래킷을 추가했다. 구조가 보이면 부유감이 사라진다.
3-5. 화살 구멍 6개 중 4개가 벽 속에 묻혀 있었다
최상위 티어의 아성(donjon)은 사각 기둥인데, 화살 구멍을 원주에 6등분으로 배치했다. 사각형의 면은 4개다. 6등분하면 4개가 모서리에 걸려 내부에 묻힌다. 바깥에서 보이는 건 2개뿐이었다.
원주 분할은 원기둥에는 맞지만 각기둥에는 맞지 않는다. 면당 하나씩, 면의 중심에 놓아야 한다. 당연해 보이는데 원형 배치 코드를 복사해 쓰면 이런 식으로 어긋난다.
4. 지형 위에 올릴 때: 묻히는 지오메트리는 공짜다
건물은 중심점 한 곳의 높이 샘플로 배치된다. 하지만 지름 23m짜리 성 아래의 땅은 평평하지 않다. 실제 맵에서 재보니 성벽 링을 따라 지형 높이가 2.0m 변했는데, 성 아래 스커트(치마 부분)는 1.7m였다. 0.05m만 더 기울면 성이 지면에서 들리면서 아래가 보인다.
해결은 스커트를 2.6m로 깊게 파는 것이다. 묻히는 지오메트리는 렌더 비용이 거의 없다 — 드로우 콜은 이미 병합됐고, 가려진 픽셀은 깊이 테스트에서 탈락한다. 더 급한 경사에서 문제가 드러날 때까지 기다릴 이유가 없다.
5. 티어 사이의 시각적 격차를 재는 방법
4단계 성을 만들고 나서 "낮은 티어가 빈약하다"는 피드백을 받았다. 코드를 읽어서는 판단할 수 없는 종류의 문제다 — 각 티어의 코드는 각자 합리적으로 보인다.
한 씬에 4단계를 나란히 놓고, 1.8m 사람 크기 박스를 옆에 세워 게임 카메라 거리에서 렌더해 봤다. 그러자 명확해졌다. 3·4단계는 성벽·흉벽·드럼 타워·문루가 갖춰져 있는데 1단계는 흙더미 위 천막 하나와 1.5m 말뚝 18개가 전부였다. 실루엣 질량의 80%가 맨 흙이었다.
즉 "모델이 조잡하다"가 아니라 "티어 간 격차가 크다"가 정확한 문제였고, 고칠 대상은 전체가 아니라 1·2단계였다. 그리고 하필 그 두 티어가 점령한 거점에 세워지는 전초성의 티어였다. 문제를 정확히 좁히면 작업량이 4분의 1이 된다.
절차적 모델은 실제 카메라 거리에서 봐야 한다. 코드 리뷰로는 비율 문제가 보이지 않고, 가까이서 보면 다 괜찮아 보인다.
6. 정리
- 머티리얼별 병합으로 성 한 채를 4~6 드로우 콜에 담을 수 있다. 변환은 지오메트리에 구워라.
R·cos(π/n)— 꼭짓점과 변 중점은 다른 반지름에 있다. 대부분의 기하 버그가 여기서 나온다.- 바깥 방위와 벽선 방향은 90도 다르다.
- 주석이 코드보다 정확할 때가 있다. 그러면 버그가 오래 산다.
- 배치 규칙은 순수 함수로 빼서 계약을 테스트하라. 좌표가 아니라 계약을.
- 원주 분할은 각기둥에 맞지 않는다.
- 묻히는 지오메트리는 공짜다. 아끼지 마라.