베일스트라이크의 화면을 손봤습니다. 그전까지 이 게임의 땅은 단색 한 장이었고, 하늘도 단색 한 장이었습니다. 형상은 멀쩡한데 화면이 종이처럼 보이는 상태였습니다.
세 가지를 얹었습니다. 땅과 건물의 무늬(CC0 자료 일곱 장, 1.9MB), 발밑의 풀과 덤불 (숲 기준 만이천 포기), 담 너머의 하늘과 능선입니다. 판정 규칙은 한 줄도 건드리지 않았습니다 — 셋 다 화면 쪽에서만 도는 물건이라, 늦게 오거나 아예 못 와도 판은 그대로 돌아갑니다.
값을 정한 것은 그리는 횟수였습니다
풀을 심기 전에 가장 걱정한 것은 삼각형 수였습니다. 숲 한 판에 만이천 포기면 잎이 십만 장 가까이 됩니다. 그런데 실제로 재 보니 한 장 그리는 시간은 2.0ms에서 2.7ms로 0.7ms 늘었을 뿐이고, 그리는 횟수(드로우콜)는 260번에서 262번으로 딱 둘 늘었습니다. 삼각형은 39.9만에서 75.6만으로 거의 두 배가 됐는데도 그렇습니다.
종류마다 인스턴스 한 벌씩만 쓰면 만 개를 그리든 하나를 그리든 명령은 한 번이기 때문입니다. 반대로 포기마다 메시를 하나씩 두었다면 명령이 만이천 번이 되어, 삼각형 수는 똑같은데 화면이 무너졌을 겁니다. 하늘과 능선도 같은 이유로 셋만 늘었습니다.
대신 풀은 그림자를 드리우지 않게 했습니다. 그림자를 켜면 장면을 한 번 더 그리는 셈이라, 그 값이 풀에는 맞지 않습니다. 이건 예전에 이 게임에서 한 번 크게 데인 적이 있습니다 — 작은 장식들의 그림자만 껐는데 22.98ms가 1.91ms가 됐습니다.
그리고 값이 하나 더 있었습니다. 심는 시간입니다. 한 지형에 6~22ms가 걸려서, 지형이 바뀌는 순간에 심으면 그 한 프레임이 그대로 멈춥니다. 다음 지형을 미리 지어 두는 자리에서 풀도 같이 심게 했더니 지형 전환이 70~102ms에서 5.9ms가 됐습니다.
곱하면 오히려 어두워집니다
땅 무늬를 한 장 그대로 깔면 96m 짜리 맵에 같은 무늬가 열세 번 반복되는 것이 눈에 띕니다. 흔한 해법은 같은 무늬를 넓게 늘여 한 겹 더 깔고 둘을 곱하는 것입니다. 그렇게 했더니 땅이 더 어두워졌습니다.
당연한 일이었습니다. 무늬의 평균 밝기가 0.4쯤인데 둘을 곱하면 0.16이 되기 때문입니다. 2를 곱해 보정해도 0.32라서 원래보다 어둡습니다. 곱셈은 1보다 작은 값끼리 하면 언제나 작아집니다. 그래서 곱하는 대신 밝기만 흔들게 바꿨습니다 — 넓은 쪽을 0.72~1.34 사이의 배율로만 쓰는 식입니다.
같은 함정을 색에서 또 밟았습니다. 덤불 색을 조금 어둡게 하려고 밝기를 0.15 내렸더니 새까매졌습니다. 색을 다루는 함수가 밝기를 더하고 빼는 공간이 화면에서 보이는 밝기가 아니라 빛의 세기 쪽이어서, 어두운 초록(그 값이 0.13쯤입니다)에 0.15를 빼면 그대로 바닥을 칩니다. 밝기는 더하지 말고 곱하는 것이 안전합니다.
고르게 흩으면 풀밭이 아니라 점박이가 됩니다
풀을 맵 전체에 고르게 뿌렸더니 풀밭이 아니라 잡초 밭이 됐습니다. 96m 짜리 맵에 구천 포기면 1m에 한 포기꼴인데, 그 간격에서는 하나하나가 외딴 잡초로 보입니다. 수를 늘리는 것으로는 잘 안 됐습니다. 고르게 늘리면 밀도만 오르고 결은 그대로입니다.
같은 수를 무리로 묶으니 달라졌습니다. 무리의 중심을 뽑고 그 둘레 2~3m에 열몇 포기씩 모으면, 덤불진 곳과 맨땅이 갈려서 비로소 들판처럼 읽힙니다. 무리 안에서 중심 쪽이 빽빽해야 하는데, 반지름을 그냥 뽑으면 고리처럼 둘러앉으니 주의가 필요합니다.
자리는 씨앗에서 뽑습니다. 같은 지형은 언제 들어와도 같은 자리에 같은 풀이 납니다 — 판마다 달라지면 "저 수풀 뒤"라는 말이 사람마다 다른 곳을 가리키게 됩니다.

풀이 나면 안 되는 자리도 있었습니다. 도심 한복판의 도로입니다. 상자로 막힌 곳은 자동으로 걸러지는데, 도로는 두께 1.8cm짜리 얇은 판이라 막힌 것으로 치지 않습니다. 그대로 두었더니 잔디가 아스팔트를 뚫고 올라왔습니다. 비울 구역을 따로 적고, 그게 지켜지는지 검사로 못박았습니다.
능선은 담보다 높아야 보입니다
담 너머에 능선 두 겹과 하늘 반구를 세웠습니다. 그리고 아무것도 안 보였습니다.
두 가지가 겹쳤습니다. 하나는 안개입니다. 이 게임의 안개는 57.6m에서 201.6m에 걸쳐 있어서, 그 너머의 능선은 그냥 하늘색 한 장이 됩니다. 능선은 안개를 끄고 흐릿함을 직접 칠하기로 했습니다 — 밑동이 봉우리보다 하늘색에 가깝게요. 대기는 아래쪽이 두꺼우니까요.
다른 하나가 진짜 원인이었습니다. 싸움터를 두르는 담의 윗면이 5m인데, 맵 한가운데 (담에서 48m 떨어진 곳)에서도 그 담이 시야의 3.9도를 가립니다. 제가 세운 능선은 160m 밖에 22m 높이여서 7.8도였습니다. 숫자로는 담보다 높은데, 각도로는 담 뒤에 통째로 숨습니다. 높이를 두 배로 올려서 해결했고, 맵마다 "담 위로 몇 도 나오는가"를 재는 검사를 붙였습니다. 눈으로 확인하는 것보다 이쪽이 빠릅니다.
깊이는 시차를 나눠 만들었습니다. 하늘과 먼 능선은 카메라를 따라다니게 해서 아무리 걸어도 제자리에 있고(무한히 먼 것처럼 보입니다), 가까운 능선만 제자리에 두었습니다. 96m를 걸으면 그것만 비껴 지나가는데, 그 어긋남이 깊이로 읽힙니다.

맵을 키우면 배경이 안으로 들어옵니다
마지막은 새로 만든 것이 아니라 되돌린 것입니다.
하루 전에 이 게임의 맵을 48m에서 96m로 두 배 키웠습니다. 그런데 배경 장식의 좌표는 옛 크기 기준 그대로였습니다. 담 바깥에 있어야 할 나무 마흔두 그루(반지름 27~32m)와 언덕 열두 개(반지름 42m)가 담 안쪽, 그러니까 싸움터 한가운데에 들어와 있었습니다. 장식이라 충돌 판정이 없으니, 총싸움 중에 나무가 몸을 통과하는 상태였습니다. 항만의 바다는 더 조용히 망가져 있었습니다 — 땅에 묻혀 아예 보이지 않았습니다.
이건 화면에 오류가 뜨지 않습니다. 검사도 통과합니다. 무늬와 풀을 입혀서 주변이 다 또렷해진 뒤에야 혼자 매끈한 민무늬 언덕이 눈에 띄었습니다. 전부 담 너머로 옮기고, 언덕은 면을 각지게 했습니다(다면체를 만드는 함수가 세밀도를 올리면 표면을 공처럼 매끈하게 처리해서, 각진 장면에서 혼자 풍선처럼 보입니다).
같은 자리에서 하나 더 나왔습니다. 담과 배경 벽에 무늬가 안 붙고 있었습니다. 무늬를 입힐 때 "무엇으로 지었는지"를 적어 두는데, 담을 만드는 통로에서만 그걸 빠뜨렸습니다. 화면에서 가장 큰 면이 마지막까지 민무늬로 남아 있었던 셈입니다.
눈으로 찾지 말고 규칙으로 찾습니다
바로 앞 절에서 배경 장식을 "전부 담 너머로 옮겼다"고 썼는데, 다시 훑어보니 셋이 틀려 있었습니다.
- 나무를 반지름 58m 원 위에 세웠는데, 싸움터는 원이 아니라 정사각형(±48m)입니다. 반지름 58m 원은 대각선에서 (41, 41)을 지나는데, 그 자리는 담 안쪽입니다. 마흔두 그루 가운데 세 그루가 여전히 싸움터 안에 서 있었습니다. 반지름이 아니라 정사각형 기준 거리로 밀어서 고쳤습니다.
- 바다는 옮긴 뒤에도 땅에 묻혀 있었습니다. 따져 보니 담 윗면이 5m라서, 96m 밖의 수면은 싸움터 어디에서 봐도 지평선 아래에 깔립니다. 애초에 보일 수 없는 자리라 옮기지 않고 지웠습니다.
- 높이 6m짜리 가로등 기둥 여섯 개가 옛 맵에서는 담 바로 앞(±21m)이었는데, 맵을 키운 뒤에는 한복판에 서 있었고 충돌 판정도 없었습니다.
그래서 이번에는 눈으로 찾지 않고 규칙을 검사로 만들어 여섯 지형을 훑었습니다. 하나는 "발밑에서 솟은 것은 막혀야 한다", 또 하나는 "아무것에도 닿지 않고 떠 있는 것은 없어야 한다"입니다.
두 번째 규칙에서 세 가지가 더 나왔습니다. 창고 선반 열세 개가 허공에 떠 있었습니다 — 선반 높이를 0.1·1.1·2.1·3m로 고정해 두었는데, 바깥 블록은 크기가 무작위라 키가 3m가 안 되는 블록에서는 선반이 공중에 걸렸습니다. 정제시설의 42m 배관 두 줄은 받쳐 주는 기둥과 자리가 어긋나 있었고, 담 너머 배경 건물은 땅을 따라가지 않아 0.7~1.3m씩 떠 있었습니다.
처음 만든 규칙은 너무 넓었습니다. "아래에 받침이 있는가"로 재니 컨테이너 옆면의 사다리와 늘어진 줄까지 수백 개가 걸렸습니다. 사다리는 옆이, 줄은 위가 잡고 있습니다. "아무것에도 안 닿는가"로 바꾸고 나서야 진짜 떠 있는 것만 남았습니다.
지형을 고치면 서버도 고쳐야 합니다
바깥 엄폐물이 네모 상자뿐이라 넓힌 맵이 창고 재고처럼 보였습니다. 바깥 무리의 약 22%를 네 가지 지형지물로 바꿨습니다 — 앉으면 가려지는 모래주머니 둑, 한 짝을 어긋나게 놓아 총구를 낼 틈이 있는 방호벽 줄, 키가 제각각인 바위, 드럼 더미. 지도마다 어울리는 것만 씁니다. 모래주머니 한 둑은 자루가 스물네 개라 따로 그리면 명령도 스물네 번이어서, 색깔별로 두 번에 묶어 그립니다(사막 지형 681번 → 609번).

바위는 처음에 또 매끈한 풍선이었습니다(앞 절의 언덕과 같은 이유입니다). 꼭짓점을 흔들어 각지게 하고 바위 무늬를 입혔는데, 흔드는 값을 정할 때 한 가지를 조심했습니다. 셰이더에서 흔히 쓰는 난수 공식은 sin 값에 큰 수를 곱해 소수점 아래만 쓰는데, sin은 자바스크립트 엔진마다 마지막 자리가 조금씩 다를 수 있습니다. 이 바위는 충돌 판정에 쓰이기 때문에 서버와 아이폰이 서로 다른 바위를 가지면 안 됩니다. 그래서 정수 곱셈만 쓰는 방식으로 흔들었습니다.

그리고 여기서 제 실수를 하나 발견했습니다. 이 게임은 서버도 같은 코드로 맵을 짓고, 그 결과로 총알이 벽에 막히는지를 판정합니다. 앞의 가로등 기둥을 "막히게" 바꿨을 때, 저는 그걸 화면 쪽 수정이라고 생각해서 게임 화면만 배포했습니다. 서버는 옛 코드 그대로였고, 온라인에서는 화면에서는 막히는데 서버는 모르는 기둥이 생겼습니다. 오류도 경고도 없었습니다. 12cm짜리 기둥이라 영향은 작았지만, 이번 지형지물은 바깥 배치 순서를 통째로 바꾸기 때문에 같은 식으로 올렸다면 온라인의 벽이 전부 어긋났을 겁니다.
그래서 두 가지를 넣었습니다.
- 지형 지문 — 서버가 충돌 상자 목록의 요약값을 보내고, 화면이 자기 것과 비교해서 다르면 판을 시작하지 않습니다("새로고침한 뒤 다시 들어와 주세요"). 다음에 제가 또 서버 배포를 빠뜨려도 여기서 걸립니다.
- 통신 규약 번호 올리기 — 배포하는 몇 분 동안 옛 화면이 새 서버에 붙으면, 조용히 어긋나는 대신 접속을 거절합니다.
지문을 만들면서 검사가 약점 하나를 잡았습니다. 좌표를 5cm 단위로 반올림해서 요약했는데, 좌표를 나노미터만 흔들어도 요약값이 바뀌었습니다. 이 맵에는 "폭의 절반 + 2.5cm"처럼 반올림 경계에 정확히 떨어지는 좌표가 많았기 때문입니다. 그대로 뒀다면 계산 끝자리가 서버와 조금 다른 기기에서 멀쩡한 판을 거절했을 겁니다. 반올림 격자를 살짝 비껴 놓아 이런 좌표들이 경계에서 1.8mm 이상 떨어지게 했습니다. 여섯 지형을 다섯 번씩 흔드는 검사로 확인했고, 배포한 뒤에는 지형마다 빈 방을 새로 열어 실제 서버에 붙어 봤습니다. 여섯 곳 모두 판이 돌았고 지문 불일치는 0이었습니다.
폰에서 재 보니 깎을 데가 없었습니다
여기까지 하고 나니 화면이 네 배쯤 무거워졌습니다. 당연히 폰에서는 낮춰야 할 것 같았고, 해상도를 제한하고 그림자를 끄고 풀을 줄이는 설정을 먼저 만들 뻔했습니다.
그런데 예전에 다른 게임에서 발열 원인을 찾다가 두 번 헛짚은 적이 있습니다. CPU만 재다가 놓쳤고, 진짜 원인은 렌더 해상도였습니다. 그래서 이번에는 추측을 코드로 옮기기 전에 폰을 USB로 연결해서 직접 쟀습니다. 안드로이드 크롬은 개발자 프로토콜을 열어 주기 때문에, PC에서 폰 화면 위의 자바스크립트를 그대로 실행하고 숫자를 받아올 수 있습니다.
결과는 예상과 정반대였습니다.
- 여섯 지형 모두 60fps 고정이었습니다(프레임 간격 중앙값 16.6ms).
- 한 장을 그리는 데 GPU가 쓴 시간은 1.1~1.9ms. 삼각형이 79만 개인 숲도 1.7ms였습니다.
- 렌더 해상도를 13배 올려도(0.11 → 0.93 백만 픽셀) 1.8ms에서 1.5ms로, 사실상 그대로였습니다. 즉 채움 속도가 막힌 상태가 아니었습니다.
- 껐을 때 시간이 눈에 띄게 준 것은 그림자 하나뿐이었습니다(1.7 → 1.1ms). 풀과 능선은 껐다 켜도 차이가 안 나왔습니다.
- 8분을 연달아 돌렸더니 28,954장 중 33ms를 넘긴 것이 1장, 폰 온도는 0.9도 올랐습니다 (충전 중이었으니 그 몫도 섞여 있습니다). 발열로 눌리는 기미는 없었습니다.
그래서 기기를 보고 미리 깎지 않기로 했습니다. 코어 수나 기기 이름으로 추측해서 내리면, 멀쩡한 기기의 화면만 버리게 됩니다. 대신 실제로 장을 빠뜨릴 때만 한 단계 내립니다 — 프레임 간격의 중앙값이 22ms를 넘으면 한 칸, 바뀐 결과를 지켜본 뒤에 다시 판단합니다. 자동으로 올라가지는 않습니다. 올렸다 내렸다 하면 화면이 계속 흔들리기 때문입니다.
단계는 셋이고, 시작 화면에서 직접 고를 수도 있습니다(고른 값은 기기에 남습니다). 아래는 실제 폰에서 잰 값입니다.
| 화질 | 렌더 해상도 | 그림자 | 숲의 풀 | 한 장 |
|---|---|---|---|---|
| 높음 | 612x1094 | 2048 | 12,000포기 | 1.5ms |
| 보통 | 450x805 | 1024 | 7,200포기 | 1.5ms |
| 낮음 | 360x644 | 끔 | 4,200포기 | 0.8ms |

주소 뒤에 ?diag=1을 붙이면 위 그림처럼 프레임과 함께 지금 어느 화질로 돌고 있는지가 뜹니다. 나중에 "느리다"는 이야기를 들었을 때 그 사람이 어느 단계였는지를 모르면 숫자를 읽을 수가 없어서 같이 넣었습니다.
다만 이건 최신 기종 하나로 잰 것입니다. 보급형이나 오래된 기기에서는 채움 속도가 먼저 막힐 수 있고, 거기서는 해상도 제한이 첫 번째 손잡이가 됩니다. 그래서 단계를 지우지 않고 남겨 두었습니다.
바람 소리 — 껐는지는 시계로 확인합니다
지형마다 바람 소리 한 겹과, 이따금 들리는 소리 하나를 넣었습니다. 항만은 갈매기, 숲은 새, 창고와 정제시설은 쇳소리, 도심은 먼 찻길이고 사막은 바람만 붑니다. 이 게임의 다른 소리처럼 음원 파일 없이 그 자리에서 합성합니다. 흰 잡음을 그대로 쓰면 바람이 아니라 라디오 잡음처럼 들려서, 한 번 걸러 낮은 소리 쪽으로 기울였습니다.
소리를 끌 수 있게도 했는데, "정말 꺼졌는지" 확인하다가 한 번 속았습니다. 소리 파형을 읽는 분석 장치로 재 봤더니 끈 뒤에도 값이 나왔습니다. 자세히 보니 여러 번 잰 값의 중앙값과 최댓값이 똑같았습니다. 오디오 장치를 멈추면 분석 장치의 버퍼가 마지막 순간에서 얼어붙어 같은 값만 계속 나오는 것이었습니다. 그래서 파형 대신 오디오 장치의 시계가 흐르는지를 봤습니다. 끈 동안 1.5초를 기다려도 시계는 0초 흘렀습니다. 실제로 멈춘 것입니다.
정해 놓고 어디에도 안 보이는 설정
닉네임은 저장되고 온라인 서버에도 전달되고 있었습니다. 그런데 게임 화면에는 그 이름이 나오는 곳이 없었습니다. HUD에는 이름 칸이 없었고, 킬 소식에는 늘 "YOU"로만 나왔습니다. 정해 놓고 어디에서도 보이지 않으니 설정이 적용됐는지 알 수가 없었습니다.
이제 HUD 왼쪽 아래 체력 위에 닉네임이 뜨고, 킬 소식도 "YOU ▸ HOSTILE 01" 대신 "까치 ▸ HOSTILE 01"로 나옵니다. 온라인 점수판에서는 내 줄을 따로 표시합니다.

같은 자리에서 더 조용한 문제가 나왔습니다. 느낌표나 이모지처럼 쓸 수 없는 글자가 들어간 닉네임을 저장해 두면 온라인 접속이 거절되고 있었습니다. 저장할 때 글자를 검사하지 않고 그대로 서버에 보냈는데, 서버는 그런 이름을 받지 않기 때문입니다. 거절된 이유는 화면 어디에도 나오지 않았습니다. 지금은 입력하는 순간 "쓸 수 없는 글자가 있습니다"라고 알려 주고 저장하지 않습니다. 화면에 보이는 이름과 서버로 가는 이름은 한 곳에서 정하도록 합쳤습니다.
이걸 확인하려고 만든 검사 하나는 처음에 아무것도 검사하지 않고 통과했습니다. 저장 위치 이름을 한 글자 틀리게 적어서, 이상한 이름이 애초에 저장되지 않은 상태로 "빈 이름이 나온다"를 확인하고 있었습니다. 지금은 먼저 실제로 저장됐는지부터 확인합니다.
남은 것
여섯 지형을 모두 실제 배포본에서 재고 찍었습니다. 땅 밝기는 65~117(0~255 기준), 무늬의 결은 표준편차 20~30, 셰이더 프로그램 수는 14~16으로 그대로입니다. 검사는 381개가 통과합니다.
처음 이 글을 올릴 때는 "같은 형상에 살갗만 입혔고, 대전 서버는 다시 배포하지 않았다"고 적었습니다. 지형지물을 더한 뒤로는 그 말이 맞지 않습니다. 바깥 엄폐물의 배치가 바뀌었고, 대전 서버도 같은 코드로 다시 배포했습니다. 총알이 맞는 규칙과 이동 속도는 그대로지만, 총알을 막는 벽이 달라졌습니다.