2D 손전등 던전을 3D로: 시뮬레이션은 그대로 두고 렌더러만 바꾼 이유
ECHO는 2026년 10월 8일 2D 캔버스 게임으로 처음 공개됐고, 나흘 뒤 2.0.0에서 Three.js r170 기반 3D로 바뀌었다. 화면은 완전히 달라졌지만 게임 규칙을 담당하는 시뮬레이션 코드는 거의 그대로다. 이 글은 왜 그렇게 나눴는지, 그리고 3D에서 새로 생긴 문제인 카메라 각도와 벽 가림을 어떻게 풀었는지를 기록한다.
1.0의 구조: 처음부터 렌더와 규칙이 갈라져 있었다
1.0의 파일 구성은 단순했다. level.js가 시드로 층을 만들고, world.js가 플레이어 이동·핑 파면·사냥꾼의 청각과 상태기계를 계산하고, render.js가 그 결과를 캔버스에 그렸다. 시뮬레이션의 단위는 처음부터 "칸"과 "초"였다. 벽은 2D 격자의 타일이고, 핑은 타일 중심까지 DDA로 시야를 검사한 뒤 도착 시각 = 발신 시각 + 거리 / 14를 타일마다 기록한다. 렌더러는 그 시각과 현재 시각의 차이로 밝기를 계산하는 소비자일 뿐이었다.
이 분리 덕분에 3D 전환의 범위를 처음부터 좁게 잡을 수 있었다. 계획서(UPGRADE_3D_PLAN)의 기술 구성 항목에는 "시뮬레이션은 그대로"라는 문장이 맨 앞에 있다. 2D 좌표 (x, y)는 3D에서 (x, 0, z)로 옮기기만 하면 된다. 게임의 재미를 결정하는 수치, 즉 핑 반경 9칸, 파면 속도 초당 14칸, 사냥꾼 경계 속도 같은 값을 건드리지 않으니, 1.0에서 맞춰 둔 난이도가 3D에서도 그대로 유지된다.
실제로 2.0 작업 커밋에서 world.js는 20줄 남짓만 바뀌었다. 추가된 것은 손전등 원뿔 판정 함수 inCone, 손전등 켜기·끄기 setFlash, 그리고 빛을 보는 "눈" 사냥꾼의 시각 판정이다. 원래 있던 핑·발소리·추적 로직은 한 줄도 다시 쓰지 않았다. 반면 render.js(92줄)는 통째로 지워지고 render3d.mjs가 새로 생겼다.

2D 규칙을 3D 그림으로 옮기기
시뮬레이션을 그대로 두면 규칙은 지켜지지만, 그 규칙이 화면에 어떻게 보일지는 새로 정해야 한다. 가장 고민한 것은 핑 에코였다. 2D에서는 핑을 맞은 벽 타일의 테두리를 시안색으로 칠하면 끝이었다. 3D에서 벽 상자 전체를 빛나게 하면 덩어리만 보이고 공간감이 사라진다.
최종 방식은 "바닥을 향한 벽면의 윤곽선"이다. 층을 만들 때 바닥 칸과 맞닿은 벽면마다 세 개의 선분(왼쪽 세로, 위 가로, 오른쪽 세로)을 하나의 LineSegments에 모아 두고, 매 프레임 그 벽 타일의 밝기를 정점 색으로 넣는다. 밝기 공식은 시뮬레이션의 WORLD.brightness를 그대로 쓴다. 갓 맞은 에코는 2.5초 시정수로 지수 감쇠하고, 기억은 12초에 걸쳐 0.22에서 0으로 선형 감소한다. 선과 함께 아주 옅은 면 채움(0.03)을 깔고, 바닥 칸 중심에는 같은 밝기의 점을 찍어 "어디까지 걸어 다닐 수 있는지"도 보이게 했다.
벽 자체는 바닥과 맞닿은 것만 그린다. 사방이 벽으로 둘러싸인 내부 벽 타일은 어떤 각도에서도 보이지 않으므로 처음부터 제외한다. 벽 상자 하나하나를 메시로 만들면 큰 층에서 드로콜이 수백 개가 되기 때문에 InstancedMesh로 묶었다. 벽 높이는 2.4칸이고, 돌 벽돌과 판석 텍스처는 외부 이미지 없이 캔버스에 FBM 노이즈를 그려 만든다.
손전등은 시뮬레이션과 렌더가 일을 나눠 가진다. 시뮬레이션은 원뿔(8칸, 반각 0.5rad) 안의 수정과 출구에만 "지금 비쳤다"는 표시를 남기고, 벽 타일은 건드리지 않는다. 벽과 바닥을 밝히는 일은 Three.js의 SpotLight와 그림자가 실제 조명으로 처리한다. 수치로 따지면 렌더 쪽 스포트라이트의 도달 거리는 15칸이라 판정 거리 8칸보다 길다. 빛이 희미하게 닿는 곳까지 보여 주되, 게임 판정은 보수적으로 잡은 것이다.
카메라 각도: 35°, 50°, 62°를 나란히 놓고 고르다
3D로 바꾸면서 생긴 가장 큰 결정은 카메라였다. 탑다운 2D에서는 "위에서 본다"가 자연스러웠지만, 3D에서 수직으로 내려다보면 벽 높이가 사라져 굳이 3D로 바꾼 의미가 없다. 반대로 3인칭 뒤쪽 카메라로 가면 등 뒤를 핑으로만 알 수 있다는 게임의 핵심이 흐려지고, 모바일 세로 화면에서 시야가 너무 좁아진다.
그래서 고정 피치 부감 카메라로 방향을 정하고, 시안 페이지(docs/concept3d/)에 35°·50°·62° 세 각도를 버튼으로 바꿔 볼 수 있게 만들어 비교했다. 35°는 벽이 높고 위압적이라 분위기는 가장 좋았지만 앞쪽 벽이 캐릭터를 자주 가렸고, 바닥이 짧게 보여 사냥꾼을 늦게 발견했다. 62°는 동선 파악이 가장 쉬웠지만 벽이 납작해져 2D와 큰 차이가 없었다. 50°에서 벽 높이감과 바닥 정보가 균형을 이뤘고, 이 값을 설계 문서에 "가설"로 적어 두었다.
확정된 값은 피치 50°, 거리 8.6칸, FOV 40이다. 세로 화면에서는 같은 각도로 거리를 9.6칸, FOV를 52로 넓혀 위아래 시야를 확보한다. 카메라는 캐릭터의 진행 방향 0.5칸 앞을 바라보며 exp(−6·dt) 비율로 따라간다. 핑을 보낼 때는 0.25칸 뒤로 살짝 밀렸다가 돌아오고, 잡히면 거리가 최대 2.2칸 줄어드는 줌인이 들어간다. 이 흔들림은 "모션 줄이기" 설정에서 끌 수 있다.
앞벽이 캐릭터를 가리는 문제와 컷어웨이
50°로 정해도 남쪽 벽 바로 위에 서면 벽이 캐릭터를 가린다. 쿼터뷰 액션 게임들이 흔히 쓰는 방법대로, 카메라와 캐릭터 사이에 있는 벽을 반투명하게 만들기로 했다. 판정은 카메라의 수평 방향(요) 기준으로 계산한다. 캐릭터에서 카메라 쪽으로 0.5~3.4칸, 좌우 ±1.3칸 안에 있는 벽이 대상이다.
구현은 벽 인스턴스를 두 벌로 나누는 방식이다. 불투명 재질의 InstancedMesh와 투명도 0.22 재질의 InstancedMesh를 같은 크기로 만들어 두고, 각 벽을 어느 쪽에 넣을지 다시 배치한다. 이 재배치는 캐릭터가 다른 칸으로 넘어갈 때만 일어난다.
// render3d.mjs 발췌 (가독성을 위해 줄바꿈)
const key = Math.floor(px)+','+Math.floor(pz)+','+Math.round(yaw*20);
if(!force && key===lvl.cutKey) return;
for(const wl of lvl.walls){
const rx=wl.x+.5-px, rz=wl.y+.5-pz;
const dz=rx*sy+rz*cy, dx=Math.abs(rx*cy-rz*sy);
wl.front = dz>0.5 && dz<3.4 && dx<1.3;
...
}
요를 계산에 넣은 이유는 타이틀 화면 때문이다. 타이틀에서는 고정 시드 층 위를 카메라가 천천히 원을 그리며 도는데, 이때도 컷어웨이가 카메라 방향을 따라가야 자연스럽다. 키에 요를 1/20 라디안 단위로 넣어 두었기 때문에 회전 중에도 필요한 만큼만 재배치가 일어난다. 반투명 벽의 윤곽선은 25%로 줄여, 앞벽 너머의 캐릭터와 바닥이 선에 가려지지 않게 했다.

버린 선택지: 2D 폴백
계획서 초안에는 "WebGL 미지원 기기는 기존 2D 렌더러로 자동 폴백"이 들어 있었다. 시뮬레이션이 공통이니 렌더러 두 개를 공존시키는 일은 기술적으로 어렵지 않다. 하지만 2.0에서 새로 생긴 손전등 규칙은 2D 렌더러에 대응하는 그림이 없었다. 원뿔 조명과 그림자가 화면 정보의 절반을 차지하는 게임에서, 그것이 없는 2D 화면은 다른 게임이 되어 버린다. 두 렌더러를 계속 맞춰 유지하는 비용도 고려해 사용자와 상의한 끝에 폴백을 없애기로 했다. 지금은 WebGL을 만들 수 없으면 안내문만 띄운다.
Three.js는 CDN 대신 echo/vendor/에 라이선스 파일과 함께 복사했다. 번들러 없이 import map과 네이티브 ES 모듈로 불러오기 때문에, 저장소의 다른 게임처럼 빌드 단계 없이 그대로 배포된다. 기존 전역 스크립트(world.js, level.js)는 일반 스크립트로 두고, 새 렌더러와 메인 루프만 .mjs 모듈로 만들었다.
정리
ECHO의 3D 전환은 "규칙은 이미 맞춰져 있으니 그림만 바꾼다"는 원칙 덕분에 1.0 공개 나흘 뒤에 2.0을 낼 수 있었다. 2D 시뮬레이션을 그대로 둔 결정은 난이도를 보존했고, 덤으로 window.ECHO를 통한 자동 플레이 검증도 1.0과 같은 방식으로 이어갈 수 있었다. 아직 측정하지 못한 가설도 남아 있다. 설계 문서는 "컷어웨이 밖에서 캐릭터가 가려지는 프레임이 5%를 넘으면 컷 범위를 넓힌다"는 기준을 적어 두었고, 실제 플레이 데이터로 확인하는 것이 다음 과제다.