들어가며
「춘식이의 모험」이라는 웹사이트를 보게 됐습니다.
브라우저에서 이 정도의 인터랙티브한 경험이 만들어진다는 게 놀라웠고, 대체 어떻게 구현한 건지 궁금해서 3D 웹을 공부해보기로 했습니다.
이 글은 그 공부의 기록입니다. WebGL이 어디서 왔는지부터 시작해서 Three.js의 핵심 개념을 정리하고, 직접 만들어본 결과물이 왜 그 사이트와 그렇게 달랐는지, 그리고 마지막에는 조금 다른 결론까지 이어집니다.
계보부터 정리하기
3D 웹을 검색하면 WebGL, Three.js, R3F 같은 이름이 한꺼번에 쏟아집니다. 서로 경쟁하는 기술처럼 보이지만, 사실은 위아래로 쌓여 있는 층에 가깝습니다.
| 층 | 무엇인가 | 역할 |
|---|---|---|
| OpenGL | C 기반 그래픽 API | 데스크톱용 원본 |
| OpenGL ES | OpenGL의 경량화 버전 | 모바일·임베디드용 |
| WebGL | OpenGL ES를 웹으로 옮긴 것 | 브라우저에서 GPU 제어 |
| Three.js | WebGL을 감싼 라이브러리 | 복잡한 로직을 객체 단위로 |
| R3F | Three.js의 리액트 렌더러 | 선언적으로 작성 |
OpenGL ES — WebGL의 모태
WebGL의 직접적인 모태는 OpenGL ES(Open Graphics Library for Embedded Systems) 입니다.
데스크톱용 OpenGL에서 모바일 기기나 임베디드 시스템에 불필요한 기능을 덜어내고 핵심만 남긴 경량화 버전입니다. OpenGL 자체는 C 언어를 기반으로 합니다.
대응 관계는 이렇습니다.
- WebGL 1.0 ← OpenGL ES 2.0
- WebGL 2.0 ← OpenGL ES 3.0
두 사양을 자바스크립트에서 호출할 수 있도록 그대로 옮겨놓은 것이라, WebGL은 사실상 웹용 OpenGL ES라고 봐도 무방합니다.
WebGL — 브라우저에서 GPU를 직접 만지는 층
WebGL(Web Graphics Library) 은 웹 브라우저에서 GPU를 직접 제어해 3D 그래픽을 그릴 수 있게 해주는 저수준(Low-level) API입니다.
브라우저에 기본 내장되어 있어 별도 설치가 필요 없다는 게 큰 장점입니다. 대신 난이도가 매우 높습니다.
화면에 삼각형 하나를 띄우기 위해서도 수십 줄의 코드가 필요하고, 행렬 연산 같은 수학적 지식이 요구되며, GLSL이라는 셰이더 언어를 직접 작성해야 합니다.
Three.js — 쓸 만하게 감싼 층
Three.js는 WebGL의 복잡한 로직을 개발자가 쓰기 쉽게 추상화한 자바스크립트 라이브러리입니다.
WebGL에서 수백 줄이 필요했던 작업을 Scene, Camera, Mesh 같은 객체 단위로 명령해서 몇 줄로 끝낼 수 있습니다. 명령형(Imperative) 방식입니다.
R3F — 리액트답게 쓰는 층
R3F(React Three Fiber) 는 Three.js를 리액트 환경에서 사용할 수 있게 해주는 렌더러입니다. Three.js를 대체하는 게 아니라, Three.js를 리액트답게 쓰도록 도와주는 도구입니다.
Three.js의 객체들이 <mesh />, <ambientLight /> 같은 리액트 컴포넌트 형태가 되고, 선언적(Declarative) 으로 작성합니다. 리액트의 Hooks, Props, State를 그대로 쓸 수 있어 코드가 깔끔해집니다.
같은 장면을 두 방식으로 써보면 차이가 분명합니다.
// Three.js — 명령형
const scene = new THREE.Scene()
const geometry = new THREE.BoxGeometry(1, 1, 1)
const material = new THREE.MeshStandardMaterial({ color: 'orange' })
const mesh = new THREE.Mesh(geometry, material)
scene.add(mesh)
scene.add(new THREE.AmbientLight(0xffffff, 0.5))// R3F — 선언적
<Canvas>
<ambientLight intensity={0.5} />
<mesh>
<boxGeometry args={[1, 1, 1]} />
<meshStandardMaterial color="orange" />
</mesh>
</Canvas>Three.js 핵심 3요소
Three.js로 3D를 구축하려면 반드시 다음 세 가지 객체가 있어야 합니다.
Scene (장면)
모든 3D 객체(Mesh), 조명(Light), 배경이 배치되는 가상의 공간입니다. 모든 요소는 scene.add()를 통해 이 공간에 귀속됩니다.
Camera (카메라)
Scene을 어느 시점에서 바라볼지 결정합니다.
- PerspectiveCamera: 멀리 있는 물체가 작게 보이는 원근감을 제공합니다. 일반적인 3D 웹사이트에서 가장 많이 쓰입니다.
- OrthographicCamera: 원근감이 제거된 평행 투영 방식입니다. 설계도나 쿼터뷰 게임을 구현할 때 씁니다.
Renderer (렌더러)
Scene과 Camera 데이터를 조합해 브라우저의 <canvas> 요소에 실제 픽셀로 그려내는 역할입니다. 주로 WebGLRenderer를 사용합니다.
셋을 합치면 최소 구성은 이렇게 됩니다.
const scene = new THREE.Scene()
const camera = new THREE.PerspectiveCamera(
75, // 시야각(FOV)
window.innerWidth / window.innerHeight, // 종횡비
0.1, // near — 이보다 가까우면 안 그림
1000 // far — 이보다 멀면 안 그림
)
camera.position.z = 5
const renderer = new THREE.WebGLRenderer({ antialias: true })
renderer.setSize(window.innerWidth, window.innerHeight)
document.body.appendChild(renderer.domElement)Mesh — 3D 오브젝트의 구성 원리
화면에 보이는 모든 독립적인 물체는 Mesh라는 단위로 관리됩니다. Mesh는 크게 두 요소의 결합입니다.
Mesh = Geometry(뼈대) + Material(껍질)
Geometry (형상)
물체의 뼈대이자 수학적 데이터입니다. 점(Vertex), 선(Edge), 면(Face)으로 구성됩니다.
BoxGeometry, SphereGeometry처럼 기본 도형이 준비되어 있고, 복잡한 형태는 외부 모델을 불러옵니다.
Material (재질)
물체의 표면이 빛에 어떻게 반응하는지, 색상은 무엇인지를 정의합니다.
- MeshBasicMaterial: 빛의 영향을 받지 않는 단색 재질입니다. 조명이 없어도 보입니다.
- MeshStandardMaterial: 물리 기반 렌더링(PBR) 재질입니다. 거칠기(Roughness)와 금속성(Metalness)을 표현할 수 있어 실무에서 가장 선호됩니다.
- MeshToonMaterial: 카툰 렌더링 기법인 툰 셰이더도 준비되어 있습니다.
여기서 한 가지 짚어둘 게 있습니다. 재질에 따라 조명이 없으면 화면이 그냥 검게 보입니다. MeshStandardMaterial을 쓰면서 조명을 안 넣으면 아무것도 안 나오는데, 처음에는 이걸 코드 오류로 착각하기 쉽습니다.
렌더링 루프
단순히 한 번 그리는 게 아니라, 지속적으로 화면을 갱신하는 애니메이션 루프를 만드는 것이 핵심입니다.
function animate() {
requestAnimationFrame(animate) // 브라우저 주사율에 맞춰 반복 실행
// 객체 애니메이션 로직
mesh.rotation.x += 0.01
mesh.rotation.y += 0.01
renderer.render(scene, camera) // 화면 업데이트
}
animate()R3F에서는 이 루프가 useFrame 훅으로 바뀝니다. 매 프레임 호출된다는 점은 같고, 루프를 직접 돌리지 않아도 된다는 점만 다릅니다.
function Box() {
const ref = useRef()
useFrame((state, delta) => {
ref.current.rotation.x += delta
ref.current.rotation.y += delta
})
return (
<mesh ref={ref}>
<boxGeometry />
<meshStandardMaterial color="orange" />
</mesh>
)
}조명
AmbientLight
씬 전체에 균일하게 빛을 비추어 그림자가 없는 기본 밝기를 제공합니다.
- 특징: 모든 방향에서 균일하게 내리쬐는 빛입니다.
- 용도: 그림자가 생기지 않으며, 빛이 닿지 않는 어두운 부분을 최소한으로 보이게 하는 ‘기본 밝기’ 역할입니다.
- 한계: 이것만 쓰면 물체의 명암이 사라져 2D처럼 평면적으로 보입니다.
DirectionalLight
태양광처럼 특정 방향에서 평행하게 쏟아지는 빛이며, 그림자를 생성할 수 있습니다.
- 특징: 아주 먼 곳에서 평행하게 쏟아지는 빛입니다. 태양광과 가장 흡사합니다.
- 용도: 물체에 뚜렷한 명암(Highlight & Shadow)을 만들어 입체감을 부여합니다.
- 그림자 설정:
directionalLight.castShadow = true를 설정해야 실제 그림자가 생성됩니다. 연산량이 많으므로 신중히 써야 합니다.
실전에서는 보통 이 둘을 섞습니다. AmbientLight로 바닥 밝기를 깔고, DirectionalLight로 입체감을 만드는 식입니다.
좌표계와 변환
Three.js는 오른손 좌표계를 따릅니다.
- X축: 가로 방향 (오른쪽이 +)
- Y축: 세로 방향 (위쪽이 +)
- Z축: 깊이 방향 (화면 앞쪽이 +)
모든 Mesh는 position(위치), rotation(회전), scale(크기) 속성을 가지며, 이 값들은 벡터 형태로 관리됩니다.
mesh.position.set(0, 1, 0) // 위로 1
mesh.rotation.y = Math.PI / 4 // Y축 기준 45도 (라디안)
mesh.scale.set(2, 2, 2) // 두 배 크기회전 단위가 도(degree)가 아니라 라디안이라는 점이 처음에 헷갈리는 지점입니다.
최적화 및 자원 관리
Three.js는 GPU 자원을 직접 사용하므로 관리가 엄격해야 합니다.
Dispose (자원 해제)
화면에서 객체를 제거할 때 scene.remove()만으로는 부족합니다. 사용했던 geometry.dispose()와 material.dispose()를 명시적으로 호출해야 메모리 누수를 방지할 수 있습니다. 텍스처를 썼다면 텍스처도 마찬가지입니다.
scene.remove(mesh)
mesh.geometry.dispose()
mesh.material.dispose()자바스크립트의 GC는 GPU 메모리까지 회수해주지 않습니다. 함수형 프로그래밍에서는 GC 덕분에 마음 놓고 객체를 버릴 수 있었는데, 여기서는 그 전제가 통하지 않는다는 게 인상적이었습니다.
Texture Baking (텍스처 베이킹)
복잡한 그림자와 반사 효과를 실시간으로 계산하면 성능이 저하됩니다. Blender 같은 툴에서 이미지 형태로 미리 계산해(Bake) 텍스처로 입히는 방식이 실무 최적화의 핵심입니다.
Draw Calls 최소화
GPU에 그리기 명령을 내리는 횟수를 줄여야 합니다. 수천 개의 나무를 그린다면 각각의 Mesh를 만드는 대신 InstancedMesh를 사용해 단 한 번의 명령으로 렌더링합니다.
그래서 만들어본 결과는
여기까지 정리하고, 실제로 만들어봤습니다. 거의 대부분은 바이브코딩을 통해 생성한 결과는 아래와 같습니다.
그리고 결과는… 「춘식이의 모험」과는 거리가 아주 멀었습니다.
무엇이 부족했는지 뜯어보니, 3D 웹은 브라우저라는 제약이 심한 환경에서 돌아가는 게임 개발에 가깝다는 생각이 들었습니다. 부족했던 건 크게 세 가지였습니다.
1. 물리 엔진
Three.js의 Mesh는 결국 서로 통과하는 물체일 뿐입니다. 질량도 부피도 없습니다.
물체가 부딪히고 굴러떨어지고 튕기게 하려면 Rapier.js나 Cannon.js 같은 외부 라이브러리의 도움을 받아야 합니다. 그래야 질량과 부피가 생기고, 동적인 움직임이 가능해집니다.
R3F를 쓴다면 @react-three/rapier처럼 래핑된 패키지가 있어서 컴포넌트로 붙일 수 있습니다. Cannon.js는 원본이 오래 방치돼 있어서 cannon-es 쪽을 보는 편이 낫습니다.
2. 복잡한 캐릭터 애니메이션
춘식이 모델 자체는 언뜻 보면 구현 가능해 보입니다. 둥근 형태에 단순한 색이니까요.
하지만 실제로는 전체적인 곡선 처리, 부드러운 셰이더 처리, 가벼운 애니메이션이 복합적으로 조합된 모델입니다. 기본 도형 조합으로는 절대 나오지 않습니다. Blender로 만든 모델이 필요합니다. 사실상 무조건 필요합니다.
여기서 깨달은 게, 3D 웹의 상당 부분은 코드가 아니라 에셋의 문제라는 점이었습니다.
또한 물체의 움직임을 더 쉽게 컨트롤하려면 GSAP이나 Framer Motion 같은 애니메이션 라이브러리를 함께 써야 부드러운 연출이 가능해집니다.
3. 시각적 완성도 — 셰이더와 후처리
Three.js 기본 재질만 쓰면 “어딘가 심심하고 게임 같은 느낌” 이 납니다.
춘식이의 몸에서 빛이 나거나(Bloom), 화면에 안개가 끼거나, 카툰 느낌의 외곽선(Toon Shader)을 그리는 작업은 일반적인 설정값만으로는 턱없이 부족합니다.
GPU에 직접 명령을 내리는 GLSL 커스텀 셰이더를 작성하거나, Post-processing 라이브러리로 화면 전체에 필터 효과를 입혀야 비로소 상용 수준의 그래픽이 완성됩니다.
결국 처음에 “Three.js가 감춰줬다”고 생각했던 WebGL의 저수준 영역으로 다시 내려가야 한다는 뜻이었습니다.
결국 무엇을 하고 싶은가
여기까지 오고 나서 든 생각이 있습니다. 이게 이번 공부에서 가장 중요한 지점이었다고 생각합니다.
3D를 쓰지 않아도 인터랙티브한 웹은 충분히 구현할 수 있다.
cmiscm의 작업들을 보면 확실해집니다. 순수한 자바스크립트만으로도 충분히 인상적인 인터랙티브 애니메이션이 구현됩니다.
「춘식이의 모험」을 보고 제가 반응했던 건 “3D다” 가 아니라 “이 웹이 나에게 반응한다” 였습니다. 그런데 공부를 시작하자마자 목표가 어느새 “Three.js를 잘 다루는 것”으로 바뀌어 있었습니다.
기술 스택은 목적이 아니라 선택지입니다. 물리 엔진과 Blender와 GLSL까지 다 익혀야만 답이 나오는 게 아니라, 만들고 싶은 경험이 먼저 있고 그 다음에 필요한 만큼의 도구를 고르는 순서가 맞습니다.
3D가 필요한 순간이 오면 그때 다시 이 문서로 돌아오면 됩니다. 그전까지는 “무엇을 만들고 싶은가”를 먼저 정하는 게 순서라는 결론으로 이번 공부를 정리합니다.
