AI와 three.js로 브라우저 3D 액션 게임 만든 제작기 공개
개발자가 AI로 모델링을 만들고 three.js로 브라우저에서 실행되는 3D 액션 게임 '우라이'를 구현한 과정을 공유했다.
중국어 원문을 AI로 번역했습니다. 고유명사와 수치는 원문 표기를 우선하며, 중요한 판단에는 아래 출처 원문을 함께 확인하세요.
온라인 체험: arxun.github.io/wulai/ (PC와 모바일 모두 플레이 가능, 모바일은 가로 화면 권장)
아주아주 먼저: 시작은 그저 토큰을 다 못 쓰고 만료가 임박해서였다. 우예(五爷)에게 물어봤다. 어떻게 하면 좋을까. 그가 토큰을 태워서 모델링을 생성해 보라고 했다... 그래서 좋아, 그의 위챗 모멘트에서 사진 한 장을 캐 왔다. 모델링을 만들었다. 모델링을 다 하고 나니, 사람이 생겼으니 이제 날아다니게 하자~~ 하는 생각이 들었다.
머리말
《무래(武来)》는 브라우저에서 바로 돌아가는 로우 폴리 3D 액션 게임이다. 주인공은 샤오우(小武)로, 빨간 방한 자켓을 입고 등산 스틱을 짚은 채 황산(黃山) 산책로를 걷는다. 가는 길에 몬스터를 베고, 레벨을 올리고, 무기를 바꾼다. 쿨다운이 찼으면 대형 기술 "무래"를 터뜨려 용암이 솟는 큰 구덩이를 만든다. 지치면 SU7을 소환해 산길에서 드리프트하고 질소를 분사하며 달릴 수도 있다.
이 게임은 어떤 게임 엔진도 쓰지 않았고, 외부 미술 리소스도 거의 없다. 캐릭터, 지형, 나무, 돌, 몬스터, 스킬 이펙트가 전부 코드로 실시간 생성한 기하 도형이고, 유일한 외부 모델은 그 SU7이다. 기술 스택은 프런트엔드 개발자에게 익숙한 React + Three.js다.
이 글은 두 부분으로 나뉜다. 먼저 게임 자체를 소개하고, 그다음 개발 사고를 이야기한다. 중점은 성능과 로딩 속도, 그리고 오직 온라인과 실기기에서만 드러나는 몇 가지 함정이다.
1. 게임 소개
세계
- 맵은 260미터 × 260미터의 황산 계곡이다. 구불구불한 산책로가 남에서 북으로 계곡을 가로지르고, 사방을 산등성이가 둘러싸며, 북서쪽 모서리에 주봉이 하나 있다.
- 공기벽 밖에도 한 겹의 산비탈을 깔아 두었고, 지평선에는 먼 산이 있어 카메라를 맵 경계에 붙여도 "세계의 끝"이 보이지 않는다.
- 왼쪽 위에는 나침반과 지도 좌표(남서쪽 모서리가 원점)가 있어 Boss와 보물을 찾기 편하다.
전투
- 콤보: J를 연타하면 "찌르기 → 내리찍기 → 베기 → 회전" 네 단계가 차례로 나가고, 공중에서 J를 누르면 "내려찍기"다. PC에서는 마우스를 몬스터에 올리면 빨간 작은 검으로 변하며, 좌클릭으로 몬스터를 클릭하는 것과 J를 누르는 것은 효과가 같다.
- 스킬: E 회선참(3초 동안 계속 돌며 사방을 공격), K 붕격(고배율 강타), R 대형 기술 "무래".
- 대형 기술 "무래": 3초의 연출, 충격파는 최대 13미터까지 닿고, 배율은 10배다. 착지 후 반경 6.5미터의 용암 구덩이가 남아 5초 동안 지속되며, 그 안에 서 있는 몬스터는 지속적으로 피해를 입는다.
성장
- 레벨 상한은 99레벨이며, 레벨업하면 공격, 방어, 체력, 마나가 오른다.
- 무기는 6자루: 목제 등산 스틱 → 현철 단극 → 상화장 → 적염모 → 뇌문도 → 혈극마검, 공격력은 10에서 156까지 오르고, 숫자 키 1~6으로 전환한다.
- 맵에는 165마리의 잡몹이 있고 5종류로 나뉜다. 청석게, 무리, 적아, 암령, 산요이며, 높은 곳으로 갈수록 강해진다. 맵 두 모서리에는 각각 Boss가 하나씩 지키고 있다. 북동쪽 모서리의 현갑천괴(체력 4200)와 남동쪽 모서리의 적린염룡(체력 4800)이다.
- 몬스터는 드롭 표에 따라 독립적으로 주사위를 굴려 장비와 회혈단을 떨어뜨리며, 이미 가진 무기는 중복으로 떨어지지 않는다. 북서쪽 모서리에는 혈극마검이 하나 숨겨져 있다.
탈것
- 맵에 SU7 한 대가 서 있는데, 가까이 가서 F를 누르면 탑승한다. PC에서는 V, 모바일에서는 "탈것" 버튼을 누르면 차를 바로 옆으로 소환할 수 있다.
- 운전 중 드리프트하면 질소가 쌓인다. 20미터를 드리프트하면 한 병이 쌓이고, 최대 3병까지 저장되며, 한 병당 3초간 분사해 속도가 3배가 된다.
모바일
모바일은 가로 화면 배치다. 왼쪽 아래는 방향키, 오른쪽 아래는 일반 공격 키이며, 스킬 키는 일반 공격 키를 둘러싸고 호를 그리며 배치된다. 두 손가락으로 집으면 카메라를 멀리 당기거나 가까이 당길 수 있다. 세로 화면으로 열면 전체 페이지를 90° 회전해 가로로 눕혀서 플레이한다.
조작 일람
조작 / PC / 모바일 이동 / WASD / 방향키 / 왼쪽 아래 방향키 일반 공격 콤보 / J, 또는 마우스로 몬스터 클릭 / 오른쪽 아래 검 모양 버튼 회선참 / 붕격 / 대형 기술 / E / K / R / E / 붕 / 무 점프 / 스페이스 / 점프 회혈단 먹기 / Q / 혈 탑승·하차 / 탈것 소환 / F / V / 탑승 / 탈것
2. 기술 스택
- React 18 + React Three Fiber v8 + drei v9 + three.js r168
- Vite 5로 빌드, Vitest로 단위 테스트 작성
- GitHub Actions: main에 푸시하면 자동으로 빌드해 GitHub Pages에 배포
3. 개발 사고
1. 모든 것은 절차적으로 생성된다
미술 리소스를 쓰지 않는 가장 큰 장점은 "코드를 고치는 것이 곧 미술을 고치는 것"이며, 패키지 크기도 아주 작다는 점이다.
- 로우 폴리 스타일의 기본선: 유기적 형체(피부, 이목구비, 손)는 부드러운 노멀을 쓰고, 인공물과 식생(옷, 머리카락, 암석, 나무, 지형)은 면 단위의 하드 노멀을 유지한다. 여기에 각 삼각면마다 약간의 무작위 명암을 곱하면 "수작업으로 채색한" 색면 느낌이 난다.
- 로프트 모델링: 몸통, 손가락, 입술은 모두 하나의 경로를 따라 로프트한 연속 곡면이며, 형체 디테일은 단면의 프로파일 함수에 적혀 있다. 작은 구체를 잔뜩 쌓는 방식이 아니라—구체를 쌓으면 이음매가 남는다.
- 절차적 텍스처: 피부 모공, 직물 질감은 모두 canvas로 그 자리에서 그리며, 텍스처와 버텍스 컬러를 곱한다. 버텍스 컬러가 색면을 결정하고, 텍스처는 디테일만 담당한다.
2. 주인공은 사진 한 장을 보고 만들어졌다
샤오우의 원형은 황산에서 찍은 사진 한 장이다. 모델링할 때 먼저 사진 속 핵심 부위를 세계 좌표 앵커 표로 재었다(정수리 1.78미터, 눈 1.598미터, 턱 1.435미터……). 모든 부위를 이 표에 맞춰 작성했다.
골격은 스키닝을 쓰지 않고 "기하체의 지점 옮기기" 방식을 썼다. 기하체는 여전히 세계 좌표로 작성하고, 전체를 관절 지점으로 평행 이동해 회전할 수 있는 group에 넣는다. 이렇게 하면 정지 자세가 뼈대를 넣기 전과 픽셀 단위로 동일하고, 걷기·달리기 동작은 group만 회전하면 된다.
디테일 두 가지:
- 자세는 ref를 통해 각 부품에 전달되고, 부품 자신의 useFrame이 rotation을 직접 작성하므로 애니메이션이 React 재렌더링을 유발하지 않는다.
- 보행 위상은 시간이 아니라 이동한 거리에 따라 진행된다(phase += speed * dt / stride). 이렇게 하면 발이 절대 땅에서 미끄러지지 않으며, 걷기와 달리기 파라미터는 두 세트의 독립 애니메이션이 아니라 속도에 따라 보간된다.
3. 지형은 순수 함수다
export function terrainHeight ( x, z ) { const raw = baseHeight (x, z); // 노이즈 기복 + 둘러싼 산등성이 + 주봉 const { dist, y } = nearestPath (x, z); // 산책로에서 얼마나 먼지, 산책로는 여기서 얼마나 높은지 // 산책로 근처를 노면 고도로 평평하게 하고 살짝 파서 바퀴 자국 느낌을 낸다 ... }
지면 메시, 식생 분포, 몬스터 스폰, 플레이어 발밑의 높이, 바퀴의 지면 밀착이 모두 이 하나의 함수를 조회한다. 그래서 사람이 절대 공중에 뜨지 않고, 땅속에 꺼지지도 않는다.
보도의 고도는 원본 지형에서 샘플링한 뒤 경로를 따라 여러 번 평활화해서 얻었고, 마지막에 다시 원본 지형 근처로 클램프해 능선에서 깊은 골이 파이거나 저지대에 높은 제방이 세워지는 것을 막았다.
4. 전투는 수치 표로 구동된다
각 초식은 한 줄의 설정이다:
stab : { name : '刺' , time : 0.3 , strike : [ 0.24 , 0.7 ], mult : 1 , range : 2.55 , cone : 0.22 , knock : 0.85 },
cone은 허용되는 방향 각도의 코사인 값이다: 1이면 정면만 공격하고, 0이면 전방 반원, -1이면 사방을 공격한다. 명중 판정은 세 가지만 본다: 피해 발생 구간 안에 있는지, 거리가 충분한지, 부채꼴 안에 있는지. 범위 밖이면 반드시 빗나가고 범위 안이면 반드시 맞아, 손맛이 매우 확실하다.
5. 매 프레임 변하는 상태는 React에 넣지 않는다
플레이어 위치, 속도, 드리프트 거리처럼 매 프레임 변하는 수치는 모듈 레벨의 싱글턴 객체에 둔다: 컨트롤러가 매 프레임 쓰고, HUD가 50~100ms마다 한 번 읽는다. 씬에는 플레이어가 하나뿐이라 싱글턴으로 충분하고, 매 프레임 setState로 React를 끌어내리지도 않는다.
6. 렌더링 성능: 프레임률을 안정적으로 유지하기
이 부분에 가장 많은 시간을 썼다. 효과가 가장 뚜렷했던 몇 가지 방법:
광원 수는 절대 변하지 않는다. three.js에서 보이는 점광원의 수가 바뀌면 모든 수광 머티리얼이 셰이더를 다시 컴파일한다. 초기에 스킬을 쓸 때 이펙트에 점광원이 딸려 있어서, 처음 스킬을 쓸 때 2~3초간 끊겼다. 지금은 씬에 점광원 4개가 상시 풀을 이루고 있고, 이펙트는光源을 "신청"만 하며 풀이 거리와 밝기에 따라 골라 준다. 런타임 중 광원 수는 항상 4개다.
셰이더를 미리 예열한다. 맵에 들어간 뒤 브라우저가 유휴 상태일 때 스킬, 궁극기 이펙트 루트 노드를 한 번 컴파일해 두면, 처음 스킬을 쓸 때 끊기지 않는다. 런타임 중에도 새로운 셰이더 변형을 만드는 머티리얼 속성(transparent, vertexColors 등)을 전환하지 않고, 두 상태가 필요하면 머티리얼을 미리 두 벌 만든다.
반복되는 물체는 모두 배칭한다. 165마리의 몬스터와 대량의 스킬 파티클은 모두 InstancedMesh를 쓴다. 부품이 여러 개인 몬스터는 먼저 부품 행렬을 하나의 지오메트리에 베이크한 뒤 머티리얼 파라미터별로 그룹을 합친다. 파티클의 인스턴스별 투명도는 instanceAlpha 속성을 따로 써서 색에 곱하지 않는데, 톤 매핑이 있을 때 두 결과가 다르기 때문이다.
몬스터 클릭은 메시 레이캐스트로 하지 않는다. 마우스가 몬스터를 가리켰을 때 맞았는지 판정해야 하는데, 165마리에 대해 메시 레이캐스트를 하면 너무 비싸다. 지금은 몬스터마다 수직 캡슐로 근사하고, 레이와 캡슐의 교차는 몇 줄의 해석적 계산이라 메모리 할당이 없다.
프레임 단위 로직은 객체를 할당하지 않는다. 모든 몬스터가 하나의 useFrame으로 구동되고, useFrame 안에서 모듈 레벨의 Vector3, Matrix4를 재사용한다.
감에 의지하지 않기 위해 프로젝트에 성능 프로브를 내장했다: ?perf=auto로 접속하면 콤보, 스킬, 궁극기, 무기 교체, 드롭을 자동으로 한 번 실행하고 각 단계의 지표를 출력한다. 검수 기준은:
지표 요구 사항 새로 컴파일된 셰이더 newPrograms 매 단계 0 점광원 수 항상 4 최장 프레임 시간 약 20ms 이하 draw call 평시 약 214, 궁극기 시 약 660, 베이스라인보다 높으면 안 됨
7. 첫 화면 로딩: 모바일이 마지노선
맵에 들어갈 때 메인 스레드는 모든 동기 구축(지형 메시 생성, 나무 뿌리기, 몬스터 스폰)을 먼저 끝내야 첫 프레임을 그릴 수 있는데, 휴대폰은 개발기보다 4~6배 느리다.
- 먼저 피드백을 준다 : HTML에 로딩 오버레이를 직접 써 두어 JS가 아직 다 다운로드되지 않아도 진행 상황을 볼 수 있다.
- 모바일 정밀도 하향 : 지형 메시 분할 176 → 120, 그림자 맵 1024 → 512, 픽셀 비율 1로 고정, 풀숲 개수 880 → 520. 이 스위치는 첫 구축 전에 설정해야 하며, 그렇지 않으면 먼저 데스크톱 정밀도로 한 번 구축하게 된다.
- 계산한 것은 다시 계산하지 않는다 : 지형 셰이딩에 경사가 필요한데, 원래는 버텍스마다 높이를 두 번 더 계산했지만 지금은 인접 버텍스에서 이미 계산된 높이를 바로 조회한다.
- 첫 프레임에 쓰지 않는 것은 뒤로 미룬다 : SU7과 환경광을 별도 번들로 분리해, 첫 프레임을 그린 뒤 브라우저가 유휴 상태일 때 배치 단위로 붙인다. 차량 리소스는 낮은 우선순위 prefetch로 가져와 첫 화면 스크립트와 연결을 다투지 않는다.
- 화면이 나오면 바로 진입 : 오버레이는 메인 씬이 첫 프레임을 그릴 때 닫고, SU7도 스킬 예열도 기다리지 않는다.
- 동기 구축에는 예산이 있다 : 모바일 파라미터로 실측해 보면 지금 맵 진입 시 동기 계산 합계는 약 110ms(개발기 기준)다. 새 기능마다 추가되는 동기 소요는 약 20ms를 넘지 않아야 하고, 넘으면 정밀도를 낮추거나 맵 진입 이후로 옮긴다.
8. 모바일 대응
- 전체 페이지 회전 : 세로 화면일 때 전체 페이지를 CSS로 90° 회전하고, 터치 좌표를 회전 각도에 따라 다시 환산해야 조향 휠과 카메라 드래그가 반대로 되지 않는다.
- 안전 영역도 회전에 따라 변이 바뀐다 : iPhone 가로 화면에는 좌우에 다이내믹 아일랜드와 둥근 모서리가 있다. 페이지를 90° 회전하면 물리 화면의 위아래 변이 화면의 좌우 변이 되므로, 회전 각도마다 다른 env(safe-area-inset-*)를 매핑해야 한다.
- 두 손가락 핀치로 카메라 조절 , 동시에 브라우저 자체의 페이지 확대를 막는다.
4. 겪어 본 함정
함정 1: 개발 환경은 멀쩡한데, 온라인에서 Cannot access 'Ya' before initialization 오류
번들을 나누기 위해 vite.config.js의 manualChunks에서 디렉터리 기준으로 비즈니스 코드를 game과 lowpoly 두 패키지로 나눴다. 그런데 캐릭터 모델이 lowpoly 디렉터리에 있으면서 게임의 전투 상태에 의존하고, 게임도 반대로 캐릭터 모델에 의존해서 두 패키지가 순환 참조를 형성했다.
개발 환경은 모듈을 하나씩 로드하기에 문제가 없다; 프로덕션 번들에서는 두 청크가 서로 import하면서 실행 순서가 틀어져, 한 모듈이 다른 모듈의 아직 초기화되지 않은 변수를 사용하게 된다. 빌드 로그에는 사실 계속 Circular chunk: lowpoly -> game -> lowpoly 경고 한 줄이 있었는데, 내가 대수롭지 않게 넘겼다.
해결: manualChunks는 node_modules 안의 서드파티 라이브러리에만 번들을 나눈다(three, r3f, drei는 반대로 비즈니스 코드를 참조하지 않는다). 비즈니스 코드는 lazy(() => import(...))로 Rollup이 자동으로 쪼개게 맡긴다.
교훈 : import 구조나 번들 설정을 건드렸다면 반드시 프로덕션 번들을 빌드해 프리뷰 서버에서 열어 콘솔을 확인해야 한다; Circular chunk 경고는 전부 오류로 취급한다.
함정 2: 휴대폰에서 로딩 페이지에 멈춰 진입 불가
바깥쪽에는 모바일용 회전 외피가 있는데 이는 지연 로딩되며, 나는 그 Suspense에 fallback={body}를 적었다. 즉 외피가 도착하기 전에는 외피 없는 본문을 먼저 렌더링한다. 문제는 본문 안에 게임 캔버스 전체가 들어 있다는 점이다. 휴대폰은 먼저 장면과 WebGL 컨텍스트를 완전히 한 번 구축하고, 외피가 도착하면 그것을 다시 뜯어내고 재구축한다. 지형이 복잡해진 뒤에는 휴대폰이 첫 번째 구축 단계에서 그대로 멈춰 버렸다.
해결: fallback을 null로 바꾸고, 대기하는 동안에는 로딩 오버레이가 가리도록 해서 장면이 한 번만 구축되게 했다.
교훈: Suspense의 fallback에는 절대 캔버스나 본문과 같은 무거운 하위 트리를 넣어서는 안 된다.
함정 3: iPhone 가로 화면에서 방향키와 스킬 키가 "사라짐"
회전 외피의 너비와 높이는 원래 resize 이벤트에서 visualViewport를 읽어 계산한 픽셀이었다. iOS에서 화면을 돌릴 때 resize 이벤트가 먼저 도착하고 크기는 나중에 바뀌기 때문에, 읽어 들인 높이가 아직 세로 화면 값일 수 있다. 그러면 외피가 화면보다 한참 높아져서, 하단에 붙어 있던 방향키와 스킬 키가 전부 화면 밖으로 밀려난다. 그런데 화면과 상단 HUD는 모두 정상이라 매우 혼란스럽다.
해결: 회전이 필요 없을 때는 외피를 CSS의 100%로 바로 꽉 채우고, 회전이 필요할 때는 ResizeObserver로 페이지 컨테이너의 실제 크기를 잰다. 이는 레이아웃이 실제로 바뀐 뒤에야 트리거되므로 예전 값을 읽지 않는다.
교훈: 레이아웃 크기는 resize 이벤트에서 읽은 뷰포트를 신뢰하지 말고, 100vh도 쓰지 말 것(iOS에서는 이것이 가시 영역보다 높다).
함정 4: 샤오미 휴대폰에서 방향키를 길게 누르면 시스템 이미지 인식이 뜸
샤오미의 이미지 인식, iOS의 돋보기, 브라우저의 길게 누르기 메뉴는 모두 페이지에서 취소되지 않은 touchstart 길게 누르기로부터 촉발된다. 버튼의 pointerdown에서 preventDefault를 호출해도 이것들은 막을 수 없다.
해결: 눌러야 하는 조작 구역(조향 휠, 공격 및 스킬 키, 캔버스)은 일괄적으로 touchstart를 취소한다. 하지만 onClick을 쓰는 버튼(무기栏, 탑승물 버튼)은 이렇게 해서는 안 된다. touchstart가 취소되면 브라우저가 더 이상 click을 디스패치하지 않아 버튼이 눌리지 않기 때문이다. 그래서 누르고 있는 유형의 버튼은 모두 pointerdown / pointerup으로 바꿨다.
함정 5: 차가 달리기 시작해도 바퀴가 안 돎
SU7 모델을 압축할 때 gltf-transform optimize를 썼는데, 이것이 노드 계층을 평탄화해서 바퀴처럼 따로 회전해야 하는 자식 노드가 사라져 버렸다. 그래서 나중에는 Draco 압축(gltf-transform draco)만 사용해 노드 계층을 보존했다. 또 GLTFLoader는 노드 이름을 정리하기 때문에(Wheel.001이 Wheel001이 됨), 이름으로 노드를 찾을 때는 정규식이 두 가지 표기를 모두 호환해야 한다.
五、AI와 함께 개발하기
이 프로젝트는 나와 Cursor의 Agent가 함께 작성했다. 실제로 해 보니 AI에게 코드를 작성하게 하는 것은 어렵지 않고, 어려운 것은 AI가 같은 실수를 반복하지 않게 하는 것이었다. 나의 방법은 매번 함정에 빠진 결론을 프로젝트 규칙(.cursor/rules/*.mdc)과 스킬 문서(.cursor/skills/)로 축적하는 것이다.
- perf-first.mdc: 매 대화마다 적용된다. "렌더링 효율과 로딩 속도 우선, 변경은 버그를 유발해서는 안 됨"을 명시하고, 위의 함정 1, 2의 강제 제약을 적어 두었다.
- wulai-perf-standards/SKILL.md: 광원 풀, 셰이더 워밍업, 배칭, 첫 화면 예산, 패키징과 검수 절차의 구체적인 방법으로, 관련 코드를 수정하기 전에 먼저 읽도록 한다.
- mobile-controls.mdc: 모바일 파일을 수정할 때 자동으로 적용되며, 길게 누르기 차단, 가로세로 화면 크기, 안전 영역, 실기기 검수 방법을 기록한다.
- person-model.mdc: 인물의 앵커 표, 뼈대 부호 규약, 그리고 이미 빠졌던 모델링 함정들이다.
버그를 하나 고칠 때마다 "이 오류를 앞으로 어떻게 하면 다시 겪지 않을 수 있을까"를 한 번 물었고, 그 답을 규칙에 적어 넣었다. 규칙이 쌓일수록 AI가 코드를 수정할 때 같은 함정에 부딪히는 일이 줄어들었다. 검수도 최대한 자동화했다. 헤드리스 Chrome + CDP로 휴대폰의 가로세로 화면, 안전 영역, 실제 터치를 시뮬레이션하고, 성능 프로브와 함께 지표를 확인했다. 육안으로 "문제 없어 보인다"고 판단하는 대신 그렇게 했다.
다만 일부 문제는 시뮬레이터로 재현할 수 없었다. 예컨대 iOS에서 화면을 돌릴 때 뷰포트 지연, 샤오미의 시스템 이미지 인식 같은 것은 결국 실기기로 확인해야 했다.
맺음말
프런트엔드에서 익숙한 React + Three.js로 플레이 가능한 3D 액션 게임을 만드는 것은 생각보다 실현 가능하다. 진짜 어려운 점은 "그려내는 것"이 아니라 "휴대폰에서도 빠르고 안정적인 것"이다. 광원, 셰이더, draw call, 첫 화면 동기 계산, 이 모든 항목에는 숫자와 예산이 있어야 한다.
놀러 오세요: arxun.github.io/wulai/ , 문제나 제안은 댓글로 환영합니다.
모히칸식 대머리
프런트엔드 엔지니어
17
글
24k
조회
132
팬