소개
Three.js Journey — Learn WebGL with Three.js라는 유료 튜토리얼 사이트에서 할로윈 챌린지를 했습니다.
Halloween 2 Challenge — Three.js Journey
이중 우승작인 CubeScape는 간단하지만 완성도 높은 게임을 선보였습니다.
고맙게도 소스코드(Snokke/cubescape: CubeScape Game)를 오픈하여 완성도 높은 게임의 구현체를 옅볼 수 있었습니다.
그간 three.js에 대해서는 기능과 API 정도를 익혀 왔는데요. 완성도 높은 결과물을 만들어 내기에는 경험과 품이 많이 든다고 생각이 들었습니다. 특히나 react의 선언적인 프로그래밍 방식에 익숙해지다 보니 three.js의 API 구성이 다소 올드하게 느껴져 흥미도 떨어졌습니다.
그러다 R3F를 발견하고 다시 관심있게 3d 프로그래밍을 틈틈히 익혀 보고 있었는데요. 완성도 높은 게임의 소스 코드를 발견한 이상 이를 r3f로 포팅하고 싶은 호기심이 생겼습니다.
CubeScape
이 게임은 맵을 돌아다니면서 코인을 먹고, 탈출구를 찾는 게임인데요. Cube라는 정사각형 표면 위에 미로를 구성하고 6면체를 회전해 가면서 탈출구를 찾는 방식이 매우 흥미로웠습니다.
기술 스텍
three.js와 pixi.js를 사용하여 3d와 2d 렌더링을 합니다.
type safe한 event emitter를 위해 mitt
라는 패키지를 사용하였습니다.
vite로 번들링합니다.
디렉터리 구조
완성도 높은 게임 답게 꼼꼼한 컨벤션과 코드 품질을 가지고 있었는데요.
src/core
디렉터리는 asset loader 등 게임 독립적인 라이브러리를 구현하였습니다.
src/UI
디렉터리는 2D 사용자 인터페이스를 구현하였습니다.
src/scene
디렉터리는 실제 계임 관련 코드가 있습니다.
Enums
, Interfaces
는 타입 정의를 Configs
는 다양한 magic number를 포함하고 있습니다.
GameScene
, Helpers
는 Configs
의 설정값들을 기반으로 게임 화면과 플레이 방식을 구성합니다.
아쉬운 것은 소프트웨어 기능별로 파일 구분을 하지 않고 언어적인 형식 별로 디렉터리가 나우어져 있는 점 입니다. 각 기능별로 enum, interface, config 및 asset과 실제 렌더링 및 게임 로직을 같은 디렉터리에 구성을 했다면 분석하는데 조금 수월했지 않을까란 생각입니다.
GameScene
이 중심이되어 cube, player, enemy, coin, light 등을 생성하고 연결합니다.
R3F 포팅 전략
게임 로직에 앞서 3d 객체의 구성을 해보았습니다.
THREE.Group
을 확장하는 3d rendering tree에 속하는 객체를 React 컴포넌트로 구성합니다.
모서리를 표현하는 Edge
컴포넌트를 살펴 봅니다.
import * as THREE from 'three';
import * as React from 'react';
import MainMaterial from '../Materials/MainMaterial';
import { useInstancedMesh, useModelGeometry } from '../../../core/hooks';
export type Props = {
asset: string;
edgeCells: THREE.Object3D<THREE.Object3DEventMap>[];
};
function Edge(props: Props) {
const { asset, edgeCells } = props;
const geometry = useModelGeometry(asset);
const ref = useInstancedMesh(edgeCells);
// console.log('Edge', { edgeCells, geometry });
return (
<instancedMesh ref={ref} args={[geometry, undefined, edgeCells.length]} receiveShadow castShadow>
<MainMaterial />
</instancedMesh>
);
}
export default React.memo(Edge);
asset
으로 glb
파일의 경로를 전달하면 useModelGeometry
훅을 사용하여 glb 파일로 부터 geometry 정보를 얻어옵니다.
원본 코드는 필요한 어셋이나 텍스쳐 이미지를 미리 로딩하지만 React는 suspense 기능등이 있기 때문세 사용하는 곳에서 선언작으로 asset 파일을 로딩할 수 있도록 하였습니다.
useInstancedMesh
는 THREE.InstanceMesh
의 초기화를 도와 줍니다.
THREE.InstanceMesh
는 하나의 mesh 정보만 사용하여 여러개의 인스턴스를 생성해 줍니다.
다음 예제는 정사각형을 geometry로하는 instancedMesh
를 사용하여 많은양의 각기 다른 인스턴스를 생성 및 제어하는 모습을 보여 줍니다.
이렇게 게임에서는 instanceMesh
를 사용해서 동일한 모델을 여러위치에 배치할 수 있습니다.
Configs
에 있는 magic number 대신에 컴포넌트의 attribute로 직접 전달하도록 합니다.
function Edges(props: Props) {
const { levelMap } = props;
/// 생략
// console.log('Edges', { edgeCellsByProbability });
return (
<>
<Edge asset={glbEdge01} edgeCells={edgeCellsByProbability[0]} />
<Edge asset={glbEdge02} edgeCells={edgeCellsByProbability[1]} />
<Edge asset={glbEdge03} edgeCells={edgeCellsByProbability[2]} />
<Edge asset={glbEdge04} edgeCells={edgeCellsByProbability[3]} />
</>
);
}
마치며
Three.js만을 사용하여 구현한 CubeSpace를 R3F를 사용하여 표현해 보았습니다. 순차적이고 imperative한 three.js 코딩 방식에서 react 기반의 declarative하고 상태 지향적인 코딩 방식으로 변환해 보았습니다.
개인적으로는 후자의 방식이 구조를 조금더 명확히 드러내고, 로직을 분산하는 객체지향 보다는 상대적으로 react의 hook 방식이 aspect 지향적인 프로그램을 하기가 조금더 용이하다고 생각합니다.
따라서 이러한 변환 과정을 통해서 어려움도 있었지만 좋은 경험을 하게 되었습니다.
다음에 여유가 된다면 실제 게임로직을 react 스럽게 포팅해 보려 합니다.
3d 객체를 단순히 표한하는 것 까지는 선언적 방식이 빛을 발하는데, 게임과 같이 이벤트를 받아 상태에 따라 처리하는 로직에 적합할지는 두고봐야 겠습니다.