three.js로 나무와 숲 만들기: LOD, 인스턴싱, KTX2, 바람 — 실전 가이드
프레임 레이트와 다운로드 용량을 희생하지 않고 three.js 웹 게임에 진짜 숲을 넣는 방법: 게임용 GLB, 압축, THREE.LOD, InstancedMesh와 BatchedMesh, 잎 컷아웃, 그림자, 가벼운 바람 셰이더.

브라우저용으로 만들어지는 게임이 점점 늘고 있고, 그 코드도 점점 더 AI 어시스턴트와 함께 작성됩니다. 그리고 AI 어시스턴트는 기본적으로 three.js를 택합니다. 예를 들어 Vibe Jam 2026은 다운로드 없이 바로 시작되는 브라우저 게임을 요구하며 three.js를 추천했습니다. 이런 게임도 결국에는 숲이 필요해집니다. 그리고 숲은 보통 60 FPS를 15로, 5 MB짜리 페이지를 150 MB로 만드는 첫 번째 주범입니다.
웹은 모든 플랫폼 중 가장 까다롭습니다. 성능이 약한 노트북과 휴대폰, 브라우저와 나눠 쓰는 GPU, 그리고 플레이어가 무언가를 보기도 전에 모든 메가바이트를 내려받아야 하죠. 이 가이드는 이런 제약을 견디는 식생을 만드는 방법을, 최신 three.js 코드와 함께 단계별로 보여 줍니다.
웹 예산
코드를 쓰기 전에 한도를 정하세요. 아래는 중급 노트북이나 휴대폰을 기준으로 한 출발점일 뿐 법칙이 아닙니다. 직접 자신의 씬을 측정하세요.
| 항목 | 여유 있는 목표치 |
|---|---|
| 프레임당 드로우 콜 | 많아야 수백 개 |
| 프레임당 폴리곤 | 50만~200만 |
| 나무 한 그루, 근거리 레벨 | 5,000~15,000 폴리곤 |
| 나무 한 그루, 원거리 레벨 | 1,500 폴리곤 미만, 임포스터는 2 |
| 숲 전체 다운로드 용량 | 수십 MB가 아니라 몇 MB |
핵심 아이디어는 어떤 엔진에서나 같습니다. 숲의 대부분은 멀리 있으니, 숲의 대부분은 저렴해야 합니다. 각 디테일 레벨은 이전 레벨보다 대략 3~4배 가벼워야 합니다.
나무 한 그루, 네 가지 디테일 레벨: 실루엣은 유지되고 비용은 줄어듭니다.
1단계. 게임용 GLB로 시작하기
게임용 나무는 그냥 예쁜 메시가 아닙니다. 렌더링 코드를 한 줄이라도 쓰기 전에, 파일에 다음이 있는지 확인하세요.
- 실루엣이 같은 LOD 체인(가장 먼 레벨용 임포스터까지 있으면 이상적);
- 수천 장의 개별 잎도, 꽉 찬 '브로콜리' 덩어리도 아닌 잎 뭉치 카드;
- 줄기 밑동에 있는 피벗과 미터 단위의 실제 스케일;
- 단순한 콜리전 — 수관이 아니라 줄기에 캡슐;
- 버텍스마다 저장된 바람 가중치.
이 모든 걸 무료 브라우저 도구로 확인할 수 있습니다. 폴리곤 카운터, LOD 뷰어, glTF 검증기를 써 보세요.
2단계. 파일 줄이기: meshopt와 KTX2
두 가지 압축으로 일반적인 나무 파일을 몇 분의 일로 줄일 수 있습니다.
- 지오메트리 — meshopt(또는 Draco) 압축.
- 텍스처 — KTX2(Basis Universal). PNG나 JPEG와 달리 KTX2는 비디오 메모리 안에서도 압축된 상태를 유지하므로, 다운로드 용량과 VRAM을 모두 아낍니다.
브라우저에서는 저희 glTF 압축기와 KTX2 변환기로, 커맨드 라인에서는 glTF Transform으로 처리할 수 있습니다.
npx @gltf-transform/cli optimize tree.glb tree.min.glb --compress meshopt --texture-compress ktx2
(CLI에서 KTX2 압축을 하려면 KTX-Software가 설치되어 있어야 합니다. --texture-compress webp는 설치 없이도 됩니다.)
three.js에서 압축된 파일 불러오기:
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
import { KTX2Loader } from 'three/addons/loaders/KTX2Loader.js';
import { MeshoptDecoder } from 'three/addons/libs/meshopt_decoder.module.js';
const ktx2 = new KTX2Loader().setTranscoderPath('/basis/').detectSupport(renderer);
const loader = new GLTFLoader().setKTX2Loader(ktx2).setMeshoptDecoder(MeshoptDecoder);
const gltf = await loader.loadAsync('/models/oak.min.glb');
3단계. 나무 몇 그루라면: THREE.LOD
플레이어 근처의 나무 몇 그루—마을 광장, 주인공 나무—라면 내장된 THREE.LOD로 충분합니다. 카메라까지의 거리에 따라 자식 오브젝트 하나를 보여 주며, 세 번째 인자는 경계에서 깜빡임을 막는 히스테리시스입니다.
const lod = new THREE.LOD();
lod.addLevel(lod0, 0);
lod.addLevel(lod1, 30, 0.1);
lod.addLevel(lod2, 70, 0.1);
lod.addLevel(impostor, 150, 0.1);
lod.position.set(x, 0, z);
scene.add(lod);
THREE.LOD는 렌더러가 씬을 그릴 때 자동으로 업데이트됩니다. 한계는 나무 하나하나가 여전히 별개의 오브젝트라는 점입니다. 껍질과 잎마다 드로우 콜이 따로 생깁니다. 나무가 수십 그루를 넘어가면 인스턴싱이 필요합니다.
4단계. 나무가 많다면: 청크 단위 인스턴싱
InstancedMesh는 메시 하나의 복사본 수천 개를 드로우 콜 한 번에 그립니다. 하지만 three.js는 이를 하나의 오브젝트로 취급합니다. 통째로 컬링되고 LOD도 없습니다. 500 m 떨어져 있거나 카메라 뒤에 있는 인스턴스까지 모두 같은 디테일로 그려집니다.
해결책은 Godot이나 Unity와 같습니다. 월드를 청크로 나누고, 청크마다, LOD 레벨마다, 머티리얼마다 InstancedMesh를 하나씩 만드세요. 그러면 각 청크가 거리에 따라 자기 레벨을 고릅니다.
const CHUNK = 32; // 미터 — LOD0 거리에 가깝게
const STARTS = [0, 35, 80, 160]; // 각 LOD가 시작되는 거리
const CULL = 400;
function buildChunk(trees, lodMeshes) {
// lodMeshes[i] = [barkMesh, leavesMesh], GLB에서 가져옴
const levels = lodMeshes.map((parts) => parts.map((src) => {
const im = new THREE.InstancedMesh(src.geometry, src.material, trees.length);
trees.forEach((m, k) => im.setMatrixAt(k, m));
im.computeBoundingSphere();
return im;
}));
const group = new THREE.Group();
levels.flat().forEach((im) => group.add(im));
group.userData.levels = levels;
group.userData.center = centerOf(trees); // THREE.Vector3
return group;
}
function updateChunk(group, camera) {
const d = camera.position.distanceTo(group.userData.center);
group.userData.levels.forEach((parts, i) => {
const visible = d >= STARTS[i] && d < (STARTS[i + 1] ?? CULL);
parts.forEach((im) => { im.visible = visible; });
});
}
updateChunk는 매 프레임이 아니라 청크마다 1초에 몇 번만 호출하면 됩니다. 거리는 천천히 변하니까요. 청크 크기는 중요합니다. 거리는 청크 중심까지 측정되므로, LOD0 구간보다 훨씬 큰 청크라면 플레이어 바로 옆의 나무가 거칠게 보이게 됩니다.
BatchedMesh는 최신 대안입니다. 머티리얼 하나로 많은 서로 다른 지오메트리를 그리고, 오브젝트마다 따로 컬링하며(perObjectFrustumCulled가 기본으로 켜져 있음), setGeometryIdAt으로 어떤 인스턴스의 지오메트리든 바꿀 수 있습니다. 이게 바로 LOD 전환입니다.
const bark = new THREE.BatchedMesh(maxTrees, maxVerts, maxIndices, barkMaterial);
const lodIds = barkLods.map((g) => bark.addGeometry(g));
for (const t of trees) {
t.id = bark.addInstance(lodIds[0]);
bark.setMatrixAt(t.id, t.matrix);
}
// 1초에 몇 번:
for (const t of trees) {
const d = camera.position.distanceTo(t.position);
const lod = STARTS.findLastIndex((s) => d >= s);
bark.setGeometryIdAt(t.id, lodIds[lod]);
}
머티리얼(껍질, 잎)마다 BatchedMesh를 하나씩 쓰세요. 기성 솔루션을 원한다면 커뮤니티 라이브러리 @three.ez/instanced-mesh(인스턴싱에 인스턴스별 컬링과 LOD 제공)와 @three.ez/batched-mesh-extensions(BatchedMesh용 LOD)가 이 작업을 대신해 줍니다.
지름길이 하나 더 있습니다. 파일이 glTF 확장 EXT_mesh_gpu_instancing을 쓴다면, GLTFLoader가 InstancedMesh 오브젝트를 알아서 만들어 줍니다. 숲 전체를 파일 하나에 담을 수 있는 거죠.
인스턴싱된 나무로 이루어진 숲: 나무 수가 늘어도 드로우 콜은 거의 늘지 않습니다.
5단계. 잎: 투명도 말고 컷아웃
웹에서 느린 숲은 대부분 잎 때문에 느립니다.
transparent: true말고alphaTest를 쓰세요. 투명 머티리얼은 정렬과 블렌딩이 필요하고, 깊이를 기록하지 않으며, 오버드로를 몇 배로 늘립니다. 컷아웃이 훨씬 저렴합니다.
leaves.material.transparent = false;
leaves.material.alphaTest = 0.5;
leaves.material.side = THREE.DoubleSide;
leaves.material.alphaToCoverage = true; // 렌더러가 antialias: true일 때 가장자리가 부드러워짐
- 카드 모양에 신경 쓰세요. 둥근 잎 뭉치를 네모난 카드로 감싸면 보이지 않는 픽셀을 잔뜩 셰이딩하게 됩니다. 뭉치의 실루엣대로 잘라 낸 카드는 필레이트를 크게 아껴 줍니다. 그리고 휴대폰에서는 필레이트가 진짜 한계인 경우가 많습니다.
- 저사양 기기에서는 해상도를 낮추세요. 고해상도(High-DPI) 휴대폰에서는
renderer.setPixelRatio(Math.min(devicePixelRatio, 1.5))가 식생에 가장 큰 단일 개선 효과를 주는 경우가 많습니다.
6단계. 그림자와 바람
그림자는 숲을 한 번 더 그립니다. 섀도 카메라는 플레이어 주변으로 좁게 잡고, shadow.mapSize는 적당히 쓰고, 근거리 레벨만 그림자를 드리우게 하세요.
lod2Leaves.castShadow = false;
impostor.castShadow = false;
바람은 버텍스 셰이더의 몫입니다. 모델이 버텍스마다 바람 가중치를 저장하고 있다면—예를 들어 두 번째 UV 채널의 x에 줄기 휨, y에 가지 흔들림—onBeforeCompile로 표준 머티리얼에 움직임을 더할 수 있습니다.
const time = { value: 0 };
geometry.setAttribute('wind', geometry.getAttribute('uv1')); // GLB의 TEXCOORD_1
material.onBeforeCompile = (shader) => {
shader.uniforms.uTime = time;
shader.vertexShader = shader.vertexShader
.replace('#include <common>', '#include <common>\nattribute vec2 wind;\nuniform float uTime;')
.replace('#include <begin_vertex>', `#include <begin_vertex>
float sway = sin(uTime * 1.3 + position.y * 0.2);
transformed.x += (sway * wind.x * 0.25 + sin(uTime * 2.7 + position.x) * wind.y * 0.1);
transformed.z += sway * wind.x * 0.1;`);
};
// 렌더 루프에서:
time.value = clock.getElapsedTime();
속성을 별도 이름으로 복사하는 이유는 three.js와의 충돌을 피하기 위해서입니다. 머티리얼이 두 번째 UV 세트를 쓰면 three.js가 직접 uv1을 선언하거든요.
7단계. 측정하기
renderer.info.render.calls와renderer.info.render.triangles— 프레임당 드로우 콜과 폴리곤 수;- 브라우저의 Performance 패널 — 병목이 CPU인지 GPU인지;
- 실제 중급 휴대폰 — 개발용 컴퓨터만이 아니라.
변경할 때마다 전후로 측정하세요. 수정 하나(잎 컷아웃, 픽셀 비율, 그림자 거리)가 나머지 전부를 합친 것보다 큰 효과를 내는 경우가 많습니다.
체크리스트
- GLB에 LOD 체인, 잎 뭉치 카드, 밑동의 피벗, 콜리전, 바람 가중치가 있다.
- 지오메트리는 meshopt로, 텍스처는 KTX2로 압축했다.
- 근처 나무 몇 그루에는
THREE.LOD, 숲에는 청크 단위InstancedMesh나BatchedMesh를 쓴다. - 잎은
transparent가 아니라alphaTest와alphaToCoverage를 쓴다. - 그림자는 근거리 레벨에만, 섀도 카메라는 좁게 잡았다.
- 휴대폰에서는 픽셀 비율에 상한을 뒀다.
- 드로우 콜, 폴리곤, 프레임 시간을 실제 기기에서 측정했다.
FoliMesh는 어디에 쓰이나
FoliMesh는 브라우저에서 실행되며 게임 예산을 중심으로 설계된 나무·자연 생성기입니다. 레벨마다 폴리곤 예산을 정하면, LOD0–LOD3와 가장 먼 거리용 크로스 빌보드가 담긴 GLB, 뭉치 모양대로 잘라 낸 잎 카드, 밑동의 피벗, TEXCOORD_1에 저장된 바람 가중치(three.js에서는 uv1로 읽힘), 그리고 노드의 extras(three.js에서는 object.userData)에 담긴 LOD 거리와 콜리전 캡슐을 내보냅니다. 숲 전체를 EXT_mesh_gpu_instancing을 쓴 GLB 하나로 내보낼 수도 있으며, GLTFLoader가 이를 인스턴싱된 메시로 바꿔 줍니다.
FoliMesh가 다른 도구와 어떻게 다른지 궁금하다면 3D 나무 생성기 비교를 읽어 보세요. Godot으로 개발 중이라면 Godot 4에서 빠른 숲을 만드는 방법을 참고하세요.
FAQ
three.js에 LOD가 있나요?
네, THREE.LOD가 거리에 따라 자식 오브젝트를 전환합니다. 다만 InstancedMesh 안의 개별 인스턴스에는 작동하지 않습니다. 그럴 때는 청크, setGeometryIdAt을 쓰는 BatchedMesh, 또는 @three.ez/instanced-mesh 같은 라이브러리를 쓰세요.
InstancedMesh와 BatchedMesh 중 무엇을 써야 하나요?
메시 하나를 대량으로 복사할 때는 InstancedMesh가 가장 간단하고 빠릅니다. 오브젝트별 컬링과 오브젝트별 LOD가 필요하거나, 머티리얼 하나로 서로 다른 메시를 많이 그려야 한다면 BatchedMesh가 낫습니다.
나무 잎이 검게 나오거나 정렬이 이상한 이유는 뭔가요?
보통 머티리얼이 transparent: true이기 때문입니다. DoubleSide와 함께 alphaTest로 바꾸면 정렬 문제가 사라지고 성능도 좋아집니다.
나무에 KTX2를 쓸 가치가 있나요? 있습니다. 껍질과 잎 텍스처는 다운로드 용량과 비디오 메모리에서 가장 큰 비중을 차지하는 경우가 많고, KTX2는 둘 다 줄여 줍니다.

