three.js in Next.js

Lemonade Engineering
Lemonade engineering
16 min readMar 23, 2022

2D보다 3D가 낫고, 3D보다는 4D가 낫다. 그저 당연하다. 잘 모르겠다고? 영화 티켓 가격을 생각해보자. 동일한 영화에 대한 2D, 3D, 4D 포맷 (format) 중에 어떤 포맷이 가장 비싼지, 어떤 포맷이 시청자로부터 더 큰 몰입경험을 (immersive experience) 이끌어낼 수 있는지.

아래의 영상을 재생해 핀볼 게임플레이 영상을 보자.

three.js pinball OUIGO

재미있어 보이는가? 해보고 싶은 마음이 들었다면 브라우저 주소창에 letsplay.ouigo.com 를 치면 지금 당장 플레이해볼 수 있다. 그렇다… 무려 브라우저에서 동작하는 게임이다. 너무 아름답다… 😢 원작자의 글을 읽고 알게 된 사실이지만 이 핀볼 게임은 크게 세가지의 컴포넌트로 구성되어있다.

  1. p2.js 물리엔진을 통해 핀볼과 게임 공간 내 물체들의 실시간 2D 좌표를 계산하는 컴포넌트
  2. 계산된 2D 좌표를 3D 좌표로 변환하는 컴포넌트
  3. three.js를 통한 3D 렌더링 컴포넌트

p2.js? three.js? 그게 다 뭔데? 우선 p2.js는 강체역학에 (rigid body dynamics) 기반한 자바스크립트 물리엔진 라이브러리라고 한다. 충돌, 관성, 마찰, 반발계수 등등 물리적 요인을 고려해 핀볼의 실시간 위치를 계산하기 위한 필수적 요소이다. 그리고 three.js는 3D 컴퓨터 그래픽 렌더링을 위한 자바스크립트 라이브러이다. 잊지말자. 이 글의 제목은 three.js라는 걸.

three.js에 대해 설명하기에 앞서, 보다 친숙한 WebGL에 대해 먼저 설명하는 것이 좋을 것 같다. WebGL은 (Web Graphics Library) 말그대로 웹 기반의 그래픽 라이브러리로 인터액티브한 2D 그리고 3D 그래픽 렌더링 API를 제공해준다. JavaScript 언어의 일부이자 HTML5 캔버스 요소의 일부로써 호환되는 모든 웹 브라우저에서 추가 플러그인 없이 동작하고, 자동 메모리 관리자가 제공되며 대부분의 렌더링을 GPU를 통해 효율적으로 수행할 수 있다는 장점이 있다.

하지만 WebG만을 사용해 2D 혹은 3D 렌더링 그리고 그것을 응용해서 애니메이션을 구현하는 것은 상당히 높은 학습 곡선을 요구하고 학습이 완료되었더라도 높은 시간비용을 요구하게 된다. three.js는 WebGL을 보다 적은 양의 코드로 쉽고 편하게 쓸 수 있도록 모듈화한 래퍼 (wrapper) 라이브러리라고 생각하면 된다.

“그래도 나는 힙스터라 three.js 말고 바닐라 WebGL의 맛을 보고싶다” 라는 분들은 아래의 튜토리얼 영상을 보며 WebGL을 찍먹해보길 추천한다. 지금은 구글에서 senior UX engineer로 일하시는 김종민님의 Interactive Developer 채널의 한 영상인데 WebGL을 사용해 선반과 벽에 튕겨지는 공의 애니메이션을 구현하는 매우 아름다운 예제이다. 쉽고 친절한 설명을 따라가면서 WebGL에 대한 기본 원리나 지식을 습득하기 좋은 동시에, 이런 간단한(?) 애니메이션을 구현하는 것에도 생각보다 많은 양의 코드에 여러가지의 로직이 들어간다는 걸 알게 될 것이다.

Interactive Developer — Creative Coding Tutorial

이렇듯 low-level의 WebGL API를 배우기엔 학습 장벽이 높고 이를 사용해 애니메이션이나 게임을 구현하는 것 또한 상당히 비효율적이게 될 수 있다. 참고로 위의 비교적 간단한 예제에서 로직에 해당하는 자바스크립트 코드만 세어도 대략 170줄이 된다. WebGL만을 사용해 이 글의 초반부에서 본 핀볼게임을 구현한다면 얼마나 많은 코드가 필요할지 상상만해도 끔찍하다. three.js가 널리 사랑받으면서 WebGL의 미래라고 불리우는 이유다. 이제 본격적으로 three.js를 사용해보자.

Hello three.js

이번 섹션에서는 three.js의 기본 개념과 원리를 다지려는 목적이므로 편의를 위해 CodePen을 사용한다. 바로 다음 섹션에서는 React에서 (정확히는 Next.js 프레임워크에서) three.js를 사용해 튜토리얼을 진행한다.

three.js를 이용해 3D world를 만들기 위해서는 다음의 네가지 기본 구성요소가 필요하다.

이 중 한가지라도 누락된다면 텅 빈, 새까만 캔버스 요소를 만나게 된다. 중요도의 역순으로 간략하게 설명하자면, Objects는 그리고자 하는 요소이고, Scene은 그것들을 담을 3D 공간, Camera는 Scene을 비추는 시야로써 Camera의 조작에 따라 3D 공간인 Scene에서 어느 부분을 시야 절두체에 (view frustum) 담을지가 결정된다. 마지막으로, 가장 중요한 Renderer는 전체 Scene에서 Camera에 담기는 부분을 2D 이미지로 캔버스에 실시간 렌더링한다.

먼저 아래에 삽입된 CodePen을 실행시키면 x, y, z 축의 삼방향으로 소심하게 회전하는 정육면체를 볼 수 있다. 계속 회전하게 내버려두고 이 예제에 사용된 코드를 순서대로 살펴보자.

먼저 아래에 삽입된 CodePen을 실행시키면 x, y, z 축의 삼방향으로 소심하게 회전하는 정육면체를 볼 수 있다. 계속 회전하게 내버려두고 이 예제에 사용된 코드를 순서대로 살펴보자.

cube using BoxGeometry

첫번째로, HTML에서 three.js 라이브러리를 불러오는 것을 확인할 수 있다.

<script src="https://threejs.org/build/three.js"></script>

htmlbody 하위 요소가 잘려보이거나 스크롤바가 생기지 않도록 CSS 설정이 되어있다.

* {
margin: 0;
padding: 0;
}
html,
body {
overflow: hidden;
}

자바스크립트 코드를 보자. 먼저 3D 공간인 Scene 객체를 생성한다.

const scene = new THREE.Scene();

추상객체 Camera의 상속을 받는 PerspectiveCamera 객체를 생성하고, z축으로 +5만큼 이동시킨다.

const camera = new THREE.PerspectiveCamera(
75,
window.innerWidth / window.innerHeight,
0.1,
1000
);
camera.position.z = 5;

그 외 다른 4개의 Camera 옵션은 공식문서에서 확인할 수 있다. 이 예제에서 사용되는 PerspectiveCamera는 인간의 눈이 보는 방식을 모방하도록 고안된 투영모델로써 (projection model) 순서대로 field of view, aspect, near, far 값을 인자로 받는다. 이 네개의 인자들은 위에 언 급했던 시야 절두체를 정의하는 인수들로 간략히 설명을 하자면:

  • field of view (fov): 수평이 아닌 수직 시야각을 의미한다.
  • aspect: 화면비율로 보통 너비를 높이로 나눈 값을 사용한다.
  • near: 가시(시야)거리의 하한 임계치.
  • far: 가시(시야)거리의 상한 임계치.
시야절두체

마지막으로 설명을 덧붙이자면, 가시거리의 하한 혹은 상한 임계치를 벗어난 요소는 렌더링되지 않는다. 눈 앞에 있는 공이 각막과 키스할정도로 너무 가까이 있거나, 공이 1km 밖으로 멀어지면 보이지 않는 것처럼 당연한 이치다.

Renderer 객체를 생성, 사이즈를 정의한 뒤, renderer의 DOM 요소를 body의 자식요소로 삽입한다.

const renderer = new THREE.WebGLRenderer();
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);

구성요소가 될 geometrymaterial을 먼저 정의하고 Mesh 생성자 함수의 인수로 넘겨 정육면체를 생성한다. 생성 후에 Scene 객체 add 메소드의 인수로 넘겨주지 않으면 공간에 할당되지 않아 렌더링되지 않으니 유의하자.

const geometry = new THREE.BoxGeometry(2, 2, 2, 3, 3, 3);
const material = new THREE.MeshBasicMaterial({ color: 0x008080, wireframe: true });
const cube = new THREE.Mesh(geometry, material);
scene.add(cube);

three.js에서 정육면체를 포함한 모든 요소는 삼각형으로 이루어진 mesh, 정확히는 polygon mesh의 집합으로 정의하고 렌더링한다. 사실 그 삼각형들은 원시 타입인 꼭짓점들로부터 (vertices) 정의된 것이고, 다시 그 꼭짓점들은 더 원시자료형인 position, normal, color, uv coordinate 네가지의 BufferAttribute들로 정의된다. 위의 CodePen에서 사용한 BoxGeometry 생성자 대신 꼭짓점들의 좌표를 이용해 도형을 그리는 BufferGeometry 생성자 함수를 통해 정육면체를 그리는 코드는 다음과 같다.

cube using BufferGeometry

꼭짓점의 위치를 정의하기 위해 geometry 객체의 setAttribute 메소드를 사용한 것을 볼 수 있다.

const vertices = new Float32Array( [ ... ] )geometry.setAttribute('position', new THREE.BufferAttribute(vertices, 3));

이 메소드의 첫번째 인자인 'position' 대신 'normal', 'color', 'uv'를 사용해 다른 BufferAttribute를 정의할 수 있다. 다른 세가지 BufferAttribute들에 대해 완벽히 이해하려면 선형대수에 대한 지식이 수반되어야하고 이 튜토리얼의 스코프를 벗어나니 굳이 설명하지 않는다.

더자생…

이어서 예제 코드를 보자. animate 함수를 정의하고 실행한다.

const animate = () => {
requestAnimationFrame(animate);
mesh.rotation.x += 0.01;
mesh.rotation.y += 0.01;
mesh.rotation.z += 0.005;
renderer.render(scene, camera);
}
animate();

animate 함수 안의 **requestAnimationFrame**은 (rAF) WebAPI로써, 브라우저의 다음 **repaint**가 ( 진행되기 전에 인수로 받은 콜백함수를 호출한다. 콜백 루틴 내에서 반드시 requestAnimationFrame을 호출해야한다는 점이 중요하다. 반복문이나 setInterval과 같은 타이머와 달리 requestAnimationFrame가 가지는 장점들은 다음과 같다:

  • 기본적으로 콜백의 호출 횟수는 1초에 60회이지만 (60 fps), client의 디스플레이 주사율이 이보다 높을 경우엔 (144Hz, 240Hz) 호출 횟수를 자동 일치시킨다
  • 단일 프레임에서 다중 콜백이 내부의 동일한 타임스탬프를 참조하며, 이 타임스탬프는 밀리초 단위의 십진수지만 최소 정밀도는 1ms (1000 µs) 단위에서 제어
  • 대부분의 최신 브라우저에서 백그라운드 동작 및 비활성화 시에 자동 중지

결국 매 frame 마다 큐브를 x, y, z 축으로 이동시킨 후 renderer를 통해 렌더링하게 되고, 현재 디스플레이의 주사율이 60Hz라면 16.6ms마다, 144Hz라면 6.9ms마다 렌더링이 일어나면서 부드러운 애니메이션을 보여줄 수 있게되는 것이다.

three.js in Next.js (React)

본격적으로 Next.js에서 three.js를 사용해보자. 사실 React에서 three.js를 사용하는 예제는 쉽게 찾을 수 있고, 편리성을 위해 WebGL을 래핑한 three.js을 또 다시 래핑한… 🤮 react-three-fiber, A-Frame, ECSY와 같은 래퍼 라이브러리들도 있다. 하지만 이런 라이브러리들이 three.js의 짧은 업데이트 주기를 따라갈 수 있을지도 의문이고 이것들에 의존하기보단 바닐라 (vanilla) three.js를 사용하면서 배우고 결과적으로 정복하는 것이 시간과 노력을 아끼는 길이라고 생각된다. 그러나 React와는 다른 Next.js의 특성 때문인지 Next.js에서의 바닐라 three.js 사용법에 대한 예제를 찾기가 힘들다. 심지어 Next.js 공식 repo의 examples 디렉토리에서도 react-three-fiber와 drei를 통해 three.js를 사용하는 예제만 제시할 뿐이다. 이번 섹션에서는 아래 Codepen에 보이는 것처럼 도넛, 혹은 원환체를 만드는, Next.js에서 바닐라 three.js를 사용하는 방법이자 패턴을 하나 제시한다. 부족한 점이 있다면 지적이나 의견 제시를 부탁드린다.

Donut

먼저, 터미널에서 다음 커맨드를 실행하고 원하는 이름으로 Next.js 프로젝트를 생성한다.

yarn create next-app

생성된 프로젝트 디렉토리로 이동해서 three.js를 설치해준다.

yarn add three

다음엔 pages 디렉토리 안에 three.js 파일을 생성하고 다음 코드를 붙여넣도록 한다.

import Donut from "../components/Donut";const Three = () => <Donut />;export default Three;

최상위 디렉토리에 components 디렉토리를 생성하고 그 안에 Donut.js 파일을 생성한다. 사용자가 /three 경로로 접근 시, 위의 페이지 컴포넌트가 반환하게 되는 three.js를 통해 도넛을 그리는 컴포넌트이다. 일단 다음과 같이 코드의 기초를 구성하도록 한다.

import * as THREE from "three";const Donut = () => {// TODO};export default Donut;

최상위엔 사용할 함수들에 전달할 인수들을 모아 parameters 객체 변수로 정의해두었다.

import * as THREE from "three";const Donut = () => {
const parameters = {
wireframe: true,
color: 0x008080,
radius: 10,
tube: 3,
radialSegments: 20,
tubularSegments: 20,
arc: Math.PI * 2,
};
const canvasRef = React.useRef(null);return <div ref={canvasRef} />;
};
export default Donut;

useRef hook을 사용해 ref 객체 canvasRef를 생성하고 컴포넌트가 반환하는 <div> 요소에 부착한다.

이런 방식으로 구성하는 이유는 크게 세가지이다:

  1. React DOM 외부가 아닌 내부 노드의 자식으로 캔버스 요소 및 three.js renderer.domElement를 삽입하기 위해
  2. 의도치않은 reflow가 일어나도 동일한 요소에 대한 ref를 유지하기 위해
  3. 원하는 때에, 원하는 곳에서 unmount하기 위해 — 특히 컴포넌트 unmount 시에 useEffect hook의 정리 함수 (clean-up function)를 이용한 메모리 누수 방지 그리고 우아한 퇴장을 (graceful exit) 위해

이어서 컴포넌트 내에 useEffect hook에게 넘겨줄 effect를 정의한다.

const Donut = () => {...React.useEffect(() => {
const renderer = new THREE.WebGLRenderer();
renderer.setSize(window.innerWidth, window.innerHeight);
canvasRef.current?.appendChild(renderer.domElement);return () => canvasRef.current?.removeChild(renderer.domElement);
}, []);
...};

useEffect hook을 사용해 컴포넌트 마운트 후에 three.js 렌더러를 생성하고 사이즈를 정해준 뒤, 렌더러 DOM 요소를 canvasRef가 가르키는 div의 자식으로 삽입한다. unmount 시에는 removeChild 메소드를 사용해 렌더러 DOM 요소를 삭제하도록 해준다.

계속해서 useEffect hook 안에 다음 코드를 작성한다.

React.useEffect(() => {...const geometry = new THREE.TorusGeometry(
parameters.radius,
parameters.tube,
parameters.radialSegments,
parameters.tubularSegments,
parameters.arc
);

const material = new THREE.MeshBasicMaterial({
color: parameters.color,
wireframe: parameters.wireframe,
});
const mesh = new THREE.Mesh(geometry, material);scene.add(mesh);camera.position.z = 30;const animate = () => {
requestAnimationFrame(animate);
mesh.rotation.x += 0.01;
mesh.rotation.y += 0.01;
mesh.rotation.z += 0.005;
renderer.render(scene, camera);
};
animate();return () => canvasRef.current?.removeChild(renderer.domElement);
}, []);

geometry 그리고 material 을 생성한 뒤 이 둘을 인수로 mesh를 생성하고 scene에 넣어주었다. 렌더하고자 하는 모든 요소는 Scene 객체의 add 메소드를 사용해 공간에 더해줘야한다는 것을 잊지말자. 그 다음 라인에서는 우리의 도넛의 크기를 고려해 카메라를 z축으로 +30만큼 이동시켰다. 그 다음엔 animate 함수를 정의하고 실행시킨다. 이 animation loop 안의 코드는 위 큐브 예시에서의 그것과 동일하다. Donut.js 컴포넌트의 전체 코드는 이곳에 올려두었다.

🎉 축하한다! Next.js에서 three.js를 Reactful하게 사용하는 튜토리얼을 성공적으로 끝마쳤다. 여기까지 잘 따라왔다면 터미널에서 yarn dev 커맨드를 실행하고 http://localhost:3000/three 경로로 이동하면 하늘을 비행하는 도넛을 볼 수 있을 것이다.

author: Sung

--

--

Lemonade Engineering
Lemonade engineering

레모네이드 개발팀 기술 블로그입니다. This is an engineering blog from Lemonade Engineering.