Three.js 입문 가이드: 웹 브라우저에서 3D 그래픽 만들기

Three.js 입문 가이드: 웹 브라우저에서 3D 그래픽 만들기

웹 브라우저에서 3D 그래픽을 구현해야 할 때가 종종 있는데요. 제품을 360도로 돌려볼 수 있는 뷰어, 데이터를 입체적으로 보여주는 시각화, 혹은 몰입감 있는 인터랙티브 경험을 만들고 싶을 때 가장 먼저 떠오르는 라이브러리가 바로 Three.js입니다.

Three.js는 WebGL을 추상화해서 복잡한 저수준 API를 직접 다루지 않고도 3D 장면을 만들 수 있게 해주는데요. WebGL로 직접 코딩하면 삼각형 하나 그리는 데도 수십 줄이 필요하지만, Three.js를 쓰면 몇 줄 만에 회전하는 큐브를 화면에 띄울 수 있습니다.

이번 글에서는 Three.js의 핵심 개념을 하나씩 짚어보면서 직접 3D 장면을 만들어 보겠습니다.

설치

Three.js는 npm 패키지로 설치할 수 있습니다.

bun add three
npm install three

TypeScript를 사용한다면 타입 정의도 함께 설치해주세요.

bun add -d @types/three

프로젝트에서는 이렇게 불러와서 사용합니다.

import * as THREE from "three";

빌드 도구 없이 빠르게 실험해보고 싶다면 CDN과 import map을 활용할 수도 있습니다.

<script type="importmap">
  {
    "imports": {
      "three": "https://cdn.jsdelivr.net/npm/three/build/three.module.js",
      "three/addons/": "https://cdn.jsdelivr.net/npm/three/examples/jsm/"
    }
  }
</script>

<script type="module">
  import * as THREE from "three";
  // 여기서부터 Three.js 코드 작성
</script>

핵심 구성 요소

Three.js로 3D 장면을 만들려면 Scene, Camera, Renderer 이 세 가지가 반드시 필요합니다.

Scene(장면)은 3D 공간 그 자체라고 보면 됩니다. 모든 물체와 조명이 이 안에 배치되죠.

Camera(카메라)는 Scene을 어디에서, 어떤 각도로 바라볼지 결정합니다. 우리 눈이나 영화 카메라 같은 역할이에요.

그리고 Renderer(렌더러)가 Scene과 Camera의 정보를 받아서 실제 화면에 그려줍니다. Three.js는 내부적으로 WebGL을 사용해서 <canvas> 요소에 3D 그래픽을 렌더링하는 거예요.

이 세 가지를 코드로 만들어볼까요?

// 장면 생성
const scene = new THREE.Scene();

// 카메라 생성 (원근 카메라)
const camera = new THREE.PerspectiveCamera(
  75, // 시야각 (FOV, 단위: 도)
  window.innerWidth / window.innerHeight, // 종횡비
  0.1, // 가까운 클리핑 평면
  1000, // 먼 클리핑 평면
);

// 렌더러 생성
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);

PerspectiveCamera는 사람의 눈과 비슷한 원근감을 만들어주는 카메라입니다. 첫 번째 인자인 시야각(FOV)은 카메라가 한 번에 볼 수 있는 범위인데, 보통 50~75 정도를 사용합니다. 값이 크면 넓은 범위를 보지만 왜곡이 생기고, 작으면 망원 렌즈처럼 좁은 범위만 보게 됩니다.

antialias: true 옵션은 3D 물체의 가장자리를 부드럽게 처리해서 계단 현상을 줄여줍니다.

첫 번째 3D 물체 만들기

장면에 물체를 넣으려면 Geometry(기하체)Material(재질)이 필요합니다.

Geometry는 물체의 형태를 정의합니다. “정육면체 모양이다”, “구 모양이다”처럼 꼭짓점들의 위치와 면의 구성을 담고 있습니다.

Material은 물체의 겉모습을 정의합니다. 색상, 투명도, 빛에 대한 반응 방식 등을 결정하죠.

이 두 가지를 합쳐서 Mesh(메시)를 만들면 비로소 장면에 추가할 수 있는 3D 물체가 됩니다.

// 정육면체 모양 정의 (가로, 세로, 깊이가 각각 1)
const geometry = new THREE.BoxGeometry(1, 1, 1);

// 재질 정의 (녹청색)
const material = new THREE.MeshPhongMaterial({ color: 0x44aa88 });

// Geometry + Material = Mesh
const cube = new THREE.Mesh(geometry, material);

// 장면에 추가
scene.add(cube);

Three.js에는 다양한 기본 Geometry가 내장되어 있어서 많이 쓰이는 형태는 직접 만들 필요가 없습니다.

  • BoxGeometry — 정육면체, 직육면체
  • SphereGeometry — 구
  • CylinderGeometry — 원기둥
  • PlaneGeometry — 평면
  • TorusGeometry — 도넛 모양
  • ConeGeometry — 원뿔

Material도 용도에 따라 여러 종류가 있습니다.

  • MeshBasicMaterial — 조명의 영향을 받지 않는 단색 재질. 가장 가볍지만 입체감이 없습니다
  • MeshPhongMaterial — 조명에 반응하며 광택이 있는 재질. 플라스틱 같은 느낌을 줍니다
  • MeshStandardMaterial — 물리 기반 렌더링(PBR) 재질. 가장 사실적이지만 계산 비용이 높습니다

조명 추가하기

MeshBasicMaterial을 제외한 대부분의 재질은 조명이 있어야 제대로 보입니다. 조명 없이 MeshPhongMaterial을 사용하면 화면이 까맣게 나올 테니 주의하세요.

// 방향 조명 (햇빛처럼 한 방향에서 오는 빛)
const directionalLight = new THREE.DirectionalLight(0xffffff, 3);
directionalLight.position.set(-1, 2, 4);
scene.add(directionalLight);

// 주변 조명 (모든 방향에서 균일하게 비추는 빛)
const ambientLight = new THREE.AmbientLight(0xffffff, 0.5);
scene.add(ambientLight);

DirectionalLight는 태양처럼 특정 방향에서 평행하게 들어오는 빛입니다. position을 설정해서 빛이 어디서 오는지 정할 수 있는데, 기본적으로 원점(0, 0, 0)을 향해 비춥니다.

AmbientLight는 모든 곳을 균일하게 비추는 환경광입니다. 이것만 사용하면 입체감이 전혀 없어서, 보통 다른 조명과 함께 그림자가 너무 어두운 부분을 밝혀주는 보조 역할로 씁니다.

이 외에도 자주 쓰는 조명 종류가 몇 가지 더 있습니다.

  • PointLight — 전구처럼 한 점에서 모든 방향으로 퍼지는 빛
  • SpotLight — 무대 조명처럼 원뿔 형태로 비추는 빛
  • HemisphereLight — 하늘색과 땅색을 지정하여 자연스러운 야외 조명을 만드는 빛

조명은 추가할수록 렌더링 비용이 늘어나기 때문에 꼭 필요한 만큼만 사용하는 게 좋습니다.

카메라 위치 조정

지금까지의 코드로 렌더링하면 아무것도 안 보일 수 있는데요. 카메라와 큐브가 같은 위치(0, 0, 0)에 있기 때문입니다. 카메라를 뒤로 빼야 큐브가 보이겠죠?

camera.position.z = 3;

position.z에 양수를 넣으면 카메라가 화면 앞쪽(사용자 쪽)으로 이동합니다. Three.js는 오른손 좌표계를 사용하기 때문에 z축 양의 방향이 화면 밖을 향합니다.

이제 한 번 렌더링해볼까요?

renderer.render(scene, camera);

이 한 줄로 장면이 캔버스에 그려집니다. 녹청색 정육면체가 화면에 나타나는 걸 확인할 수 있을 겁니다.

애니메이션 만들기

정지된 큐브도 좋지만, 회전하는 큐브가 훨씬 3D답죠. requestAnimationFrame을 사용해서 매 프레임마다 물체를 조금씩 변형하고 다시 렌더링하면 애니메이션이 됩니다.

function animate(time) {
  time *= 0.001; // 밀리초를 초 단위로 변환

  cube.rotation.x = time;
  cube.rotation.y = time;

  renderer.render(scene, camera);
  requestAnimationFrame(animate);
}

requestAnimationFrame(animate);

requestAnimationFrame은 브라우저가 다음 화면을 그리기 직전에 콜백을 호출해주는데요. 보통 초당 60번(60fps) 호출됩니다. 콜백의 인자로 밀리초 단위의 타임스탬프가 전달되기 때문에 이를 이용하면 프레임률에 관계없이 일정한 속도로 애니메이션을 만들 수 있습니다.

반응형 처리

브라우저 창 크기가 바뀌었을 때 캔버스도 함께 조정되도록 처리해주는 것도 중요합니다.

window.addEventListener("resize", () => {
  camera.aspect = window.innerWidth / window.innerHeight;
  camera.updateProjectionMatrix();

  renderer.setSize(window.innerWidth, window.innerHeight);
});

카메라의 종횡비를 새 창 크기에 맞게 업데이트하고, updateProjectionMatrix()를 호출해서 변경 사항을 반영합니다. 렌더러의 크기도 함께 조정해야 캔버스가 늘어나거나 찌그러지지 않습니다.

전체 코드

지금까지 배운 내용을 모아서 회전하는 정육면체를 만드는 전체 코드를 정리하겠습니다.

index.html
<!doctype html>
<html lang="ko">
  <head>
    <meta charset="UTF-8" />
    <title>Three.js 첫 번째 장면</title>
    <style>
      body {
        margin: 0;
        overflow: hidden;
      }
    </style>
  </head>
  <body>
    <script type="importmap">
      {
        "imports": {
          "three": "https://cdn.jsdelivr.net/npm/three/build/three.module.js"
        }
      }
    </script>
    <script type="module">
      import * as THREE from "three";

      // 장면
      const scene = new THREE.Scene();

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

      // 렌더러
      const renderer = new THREE.WebGLRenderer({ antialias: true });
      renderer.setSize(window.innerWidth, window.innerHeight);
      document.body.appendChild(renderer.domElement);

      // 조명
      const directionalLight = new THREE.DirectionalLight(0xffffff, 3);
      directionalLight.position.set(-1, 2, 4);
      scene.add(directionalLight);

      const ambientLight = new THREE.AmbientLight(0xffffff, 0.5);
      scene.add(ambientLight);

      // 큐브
      const geometry = new THREE.BoxGeometry(1, 1, 1);
      const material = new THREE.MeshPhongMaterial({ color: 0x44aa88 });
      const cube = new THREE.Mesh(geometry, material);
      scene.add(cube);

      // 애니메이션
      function animate(time) {
        time *= 0.001;
        cube.rotation.x = time;
        cube.rotation.y = time;
        renderer.render(scene, camera);
        requestAnimationFrame(animate);
      }
      requestAnimationFrame(animate);

      // 반응형 처리
      window.addEventListener("resize", () => {
        camera.aspect = window.innerWidth / window.innerHeight;
        camera.updateProjectionMatrix();
        renderer.setSize(window.innerWidth, window.innerHeight);
      });
    </script>
  </body>
</html>

이 HTML 파일을 브라우저에서 열면 녹청색 정육면체가 천천히 회전하는 모습을 볼 수 있습니다.

여러 물체 배치하기

하나의 Geometry를 여러 Mesh에서 재사용하면 메모리를 절약하면서 다양한 물체를 만들 수 있습니다.

function createCube(geometry, color, x) {
  const material = new THREE.MeshPhongMaterial({ color });
  const cube = new THREE.Mesh(geometry, material);
  cube.position.x = x;
  scene.add(cube);
  return cube;
}

const geometry = new THREE.BoxGeometry(1, 1, 1);

const cubes = [
  createCube(geometry, 0x44aa88, 0),
  createCube(geometry, 0x8844aa, -2.5),
  createCube(geometry, 0xaa8844, 2.5),
];

같은 BoxGeometry로 세 개의 큐브를 만들었는데, 색상과 위치만 다르게 지정했습니다. Geometry는 꼭짓점 데이터를 담고 있어서 크기가 클 수 있는데, 이렇게 공유하면 GPU 메모리를 효율적으로 쓸 수 있습니다.

애니메이션 루프에서 각 큐브를 서로 다른 속도로 회전시키면 더 재미있는 장면이 됩니다.

function animate(time) {
  time *= 0.001;

  cubes.forEach((cube, index) => {
    const speed = 1 + index * 0.5;
    cube.rotation.x = time * speed;
    cube.rotation.y = time * speed;
  });

  renderer.render(scene, camera);
  requestAnimationFrame(animate);
}

OrbitControls로 마우스 조작

3D 장면을 마우스로 자유롭게 돌려보려면 OrbitControls를 추가하면 됩니다. Three.js의 핵심 라이브러리에는 포함되어 있지 않지만 애드온(addon)으로 제공됩니다.

import { OrbitControls } from "three/addons/controls/OrbitControls.js";

const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true; // 부드러운 감속 효과

enableDamping을 켜면 마우스를 놓았을 때 관성이 적용되어 자연스럽게 멈춥니다. 이 옵션을 사용할 때는 애니메이션 루프에서 controls.update()를 호출해야 합니다.

function animate(time) {
  time *= 0.001;

  cube.rotation.x = time;
  cube.rotation.y = time;

  controls.update(); // 감속 효과 적용

  renderer.render(scene, camera);
  requestAnimationFrame(animate);
}

마우스 왼쪽 버튼으로 드래그하면 회전, 오른쪽 버튼으로 드래그하면 이동, 스크롤하면 줌인/아웃이 됩니다.

마치며

지금까지 Three.js의 기본적인 구성 요소를 살펴보았는데요. Scene, Camera, Renderer라는 세 기둥 위에 Geometry와 Material로 물체를 만들고, Light로 비추고, requestAnimationFrame으로 움직이게 하는 흐름이었습니다.

사실 Three.js로 할 수 있는 건 이보다 훨씬 많습니다. 텍스처를 입혀서 사실적인 표면을 만들 수도 있고, 3D 모델 파일을 불러와서 캐릭터나 건축물을 렌더링할 수도 있으며, 물리 엔진과 연동해서 중력이나 충돌을 구현할 수도 있습니다. React와 함께 사용하고 싶다면 React Three Fiber라는 라이브러리도 살펴보시면 좋겠습니다.

Three.js를 더 깊이 파보고 싶다면 아래 자료들을 추천드립니다.

This work is licensed under CC BY 4.0 CC BY

개발자를 위한 뉴스레터

달레가 정리한 AI 개발 트렌드와 직접 만든 콘텐츠를 전해드립니다.

Discord