BabylonJS 22회차 : 인스턴싱/씬 최적화
목표: 동일 오브젝트를 많이 띄워도 버티게 만듭니다.
핵심 개념: instances, thin instances
실습: 박스 1만 개 찍고도 버티게 만들기(가능한 범위에서)
산출물: 인스턴싱 전/후 비교
요약
동일한 박스를 1만 개 배치할 때, 가장 흔한 실수는 “메시를 1만 번 생성”하는 것입니다. 이 방식은 화면에 그리는 작업보다도 CPU에서 오브젝트를 관리하고 드로우콜을 제출하는 비용이 먼저 한계에 닿는 경우가 많습니다. 이번 글은 “같은 형태를 반복해서 그리는” 상황에서 BabylonJS가 제공하는 Instances와 Thin Instances를 이용해, 드로우콜과 CPU 부하를 줄여 성능을 개선하는 흐름을 단계적으로 정리합니다.
목차
- 1) 핵심 포인트
- 2) 왜 느려지는가: 드로우콜과 CPU 병목
- 3) Instances vs Thin Instances 개념 정리
- 4) 언제 무엇을 써야 하나
- 5) 실습: 1만 박스 비교 데모 만들기
- 6) 인스턴싱 전/후 비교 포인트
- 7) 실행 단계 체크리스트
- 8) 추가로 생각해볼 점
- 9) 블로그 최적화 정보
1) 핵심 포인트
- 같은 형태를 여러 번 그릴수록 ‘개별 메시 생성’은 가장 비싼 선택이 됩니다.
- Instances는 “메시 1개 + 인스턴스 N개”로, 드로우콜을 크게 줄이고 메모리도 절약합니다.
- Thin Instances는 “메시 1개 + 변환 행렬 버퍼”로, 더 많은 개수(1만~수십만)에서 유리합니다.
- 대신 Thin Instances는 ‘개별 오브젝트처럼 다루기 어렵다’는 제약이 있습니다(개별 피킹/충돌/디버그 등).
- 실습은 ‘같은 조건’에서 비교해야 의미가 있습니다(카메라, 해상도, 같은 재질, 같은 개수).
2) 왜 느려지는가: 드로우콜과 CPU 병목
오브젝트가 많아지면, “GPU가 그리는 비용” 이전에 CPU가 다음을 처리하느라 바빠집니다.
- 오브젝트 업데이트: 개별 메시의 월드 매트릭스 계산, 바운딩 업데이트 등
- 렌더 제출: 오브젝트마다 렌더 상태를 설정하고 드로우콜을 GPU로 제출
- 컬링/정렬: 카메라에 보이는지 판단하고, 렌더 순서를 정리
즉, 동일한 형태를 1만 개 그리는 상황은 “개별 오브젝트 관리 비용”을 줄이는 방향이 먼저 효과적입니다. Instances/Thin Instances는 이 지점을 직접적으로 개선해줍니다.
3) Instances vs Thin Instances 개념 정리
| 구분 | 핵심 아이디어 | 장점 | 제약/주의 |
|---|---|---|---|
| 일반 메시 N개 | 오브젝트마다 메시 생성 | 개별 제어가 가장 쉬움 | 드로우콜/CPU/메모리 비용이 빠르게 폭증 |
| Instances | 원본 메시 1개 + 인스턴스 | 드로우콜 감소, 메모리 절약, 개별 인스턴스가 “오브젝트처럼” 존재 | 개별 메시보단 가볍지만, 인스턴스 자체 관리 비용은 남음 |
| Thin Instances | 변환 행렬(및 색/속성) 버퍼로 대량 렌더 | 대량(1만~)에서 매우 유리, CPU 부담 감소 폭이 큼 | 개별 피킹/충돌/디버그가 제한적, 업데이트 전략 필요 |
4) 언제 무엇을 써야 하나
- 개별 상호작용이 중요: 문, 버튼, 적 캐릭터처럼 “각 오브젝트를 클릭/충돌/삭제/교체”해야 한다면 먼저 Instances가 안전합니다.
- 대량 배치가 목적: 풀/돌/탄피/파편처럼 “개별 상호작용이 약하고, 화면에 많이 깔리는 오브젝트”는 Thin Instances가 유리합니다.
- 중간 지점: 수천 개 수준에서 인스턴스로 충분히 버티는 경우도 많습니다. 먼저 Instances로 단순하게 개선하고, 부족하면 Thin Instances로 넘어가는 흐름을 권장합니다.
5) 실습: 1만 박스 비교 데모 만들기
실습은 “같은 박스 10,000개”를 세 방식으로 구성해 비교합니다.
- MODE 1: 일반 메시 10,000개(의도적으로 무거운 기준선)
- MODE 2: Instances 10,000개
- MODE 3: Thin Instances 10,000개
데모에는 다음 기능을 넣습니다.
- FPS / Frame Time(ms) / Mesh 수를 HUD로 표시
- 모드 전환 버튼(초기화 후 다시 생성)
- “스냅샷 기록” 표로 전/후 비교 결과를 남김
5-1) 완성 코드: 인스턴싱 전/후 비교 데모
아래 코드는 index.html 하나로 실행할 수 있습니다(정적 서버 권장). 기기/브라우저에 따라 1만 개 “일반 메시”는 매우 느릴 수 있으니, 멈춘 것처럼 보여도 모드 전환으로 비교해보시면 차이가 명확히 드러납니다.
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>BabylonJS Instancing vs Thin Instances (10K Boxes)</title>
<style>
html, body { width:100%; height:100%; margin:0; overflow:hidden; font-family:system-ui, -apple-system, Segoe UI, Roboto, sans-serif; }
#renderCanvas { width:100%; height:100%; display:block; touch-action:none; }
#hud {
position: fixed; left: 12px; top: 12px;
width: min(460px, 94vw);
padding: 12px 12px 10px;
border-radius: 14px;
background: rgba(0,0,0,0.55);
border: 1px solid rgba(255,255,255,0.18);
color: rgba(255,255,255,0.92);
z-index: 10;
}
#hudTitle { margin:0 0 8px 0; font-size:14px; font-weight:700; }
#stats { font-size:12px; line-height:1.55; }
.row { display:flex; justify-content:space-between; gap:10px; }
.k { color: rgba(255,255,255,0.75); }
.v { font-variant-numeric: tabular-nums; }
#btns { display:flex; flex-wrap:wrap; gap:8px; margin-top:10px; }
button {
appearance:none; border:1px solid rgba(255,255,255,0.22);
background: rgba(255,255,255,0.10);
color: rgba(255,255,255,0.92);
padding: 7px 10px; border-radius: 10px;
font-size:12px; cursor:pointer;
}
button:hover { background: rgba(255,255,255,0.16); }
#snapWrap {
position: fixed; left: 12px; bottom: 12px;
width: min(760px, 96vw);
padding: 10px 12px;
border-radius: 14px;
background: rgba(0,0,0,0.45);
border: 1px solid rgba(255,255,255,0.14);
color: rgba(255,255,255,0.92);
z-index: 10;
overflow:auto;
max-height: 38vh;
}
#snapWrap h3 { margin:0 0 8px 0; font-size:13px; }
table { width:100%; border-collapse:collapse; font-size:12px; }
th, td { border:1px solid rgba(255,255,255,0.18); padding:6px 8px; text-align:left; }
th { color: rgba(255,255,255,0.85); background: rgba(255,255,255,0.06); }
</style>
<script src="https://cdn.babylonjs.com/babylon.js"></script>
</head>
<body>
<div id="hud">
<p id="hudTitle">인스턴싱 비교 데모 (Boxes: 10,000)</p>
<div id="stats">
<div class="row"><span class="k">Mode</span><span class="v" id="mode">-</span></div>
<div class="row"><span class="k">FPS</span><span class="v" id="fps">-</span></div>
<div class="row"><span class="k">Frame Time(ms)</span><span class="v" id="ft">-</span></div>
<div class="row"><span class="k">Scene Meshes</span><span class="v" id="meshes">-</span></div>
<div class="row"><span class="k">Thin Instances Count</span><span class="v" id="thinCount">-</span></div>
<div class="row"><span class="k">Note</span><span class="v" id="note">조건 동일 비교</span></div>
</div>
<div id="btns">
<button id="modeMeshes">MODE 1: 일반 메시</button>
<button id="modeInstances">MODE 2: Instances</button>
<button id="modeThin">MODE 3: Thin Instances</button>
<button id="snapshot">스냅샷 기록</button>
<button id="resetCam">카메라 리셋</button>
</div>
</div>
<div id="snapWrap">
<h3>인스턴싱 전/후 비교 스냅샷</h3>
<table>
<thead>
<tr>
<th>No</th>
<th>Mode</th>
<th>Boxes</th>
<th>FPS</th>
<th>FrameTime(ms)</th>
<th>SceneMeshes</th>
<th>ThinCount</th>
<th>메모(관찰)</th>
</tr>
</thead>
<tbody id="snapBody"></tbody>
</table>
</div>
<canvas id="renderCanvas"></canvas>
<script>
const canvas = document.getElementById("renderCanvas");
const engine = new BABYLON.Engine(canvas, true);
const ui = {
mode: document.getElementById("mode"),
fps: document.getElementById("fps"),
ft: document.getElementById("ft"),
meshes: document.getElementById("meshes"),
thinCount: document.getElementById("thinCount"),
note: document.getElementById("note"),
snapBody: document.getElementById("snapBody")
};
const BOX_COUNT = 10000;
const AREA = 120; // 배치 범위(너무 좁으면 오버드로우가 늘 수 있음)
const SIZE = 0.9;
let scene, camera;
let baseBox = null; // 원본 메시(Instances/Thin Instances 기준)
let spawned = []; // 일반 메시/인스턴스 참조 보관
let currentMode = "-";
let snapNo = 0;
function rand(min, max) { return min + Math.random() * (max - min); }
function createScene() {
const s = new BABYLON.Scene(engine);
s.clearColor = new BABYLON.Color4(0.06, 0.07, 0.09, 1.0);
camera = new BABYLON.ArcRotateCamera(
"camera", Math.PI / 2, Math.PI / 2.5, 140,
new BABYLON.Vector3(0, 0, 0), s
);
camera.attachControl(canvas, true);
camera.wheelDeltaPercentage = 0.01;
const light = new BABYLON.HemisphericLight("hemi", new BABYLON.Vector3(0, 1, 0), s);
light.intensity = 1.0;
const ground = BABYLON.MeshBuilder.CreateGround("ground", { width: AREA, height: AREA }, s);
ground.position.y = 0;
ground.isPickable = false;
const gmat = new BABYLON.StandardMaterial("gmat", s);
gmat.alpha = 0.18;
ground.material = gmat;
// 공통 원본 박스(Instances/Thin Instances 기준)
baseBox = BABYLON.MeshBuilder.CreateBox("baseBox", { size: SIZE }, s);
baseBox.isVisible = false; // 원본은 숨기고, 인스턴스/씬 인스턴스만 보이게
const mat = new BABYLON.StandardMaterial("baseMat", s);
mat.diffuseColor = new BABYLON.Color3(0.75, 0.78, 0.90);
baseBox.material = mat;
return s;
}
function clearAll() {
// 기존 생성물 정리
spawned.forEach(m => { try { m.dispose(); } catch(e) {} });
spawned = [];
if (baseBox) {
// thin instances 버퍼 초기화
try {
baseBox.thinInstanceSetBuffer("matrix", null);
baseBox.thinInstanceSetBuffer("color", null);
} catch(e) {}
}
ui.thinCount.textContent = "0";
}
function placeMesh(m) {
m.position.x = rand(-AREA * 0.5, AREA * 0.5);
m.position.z = rand(-AREA * 0.5, AREA * 0.5);
m.position.y = SIZE * 0.5;
m.rotation.y = rand(0, Math.PI * 2);
const s = rand(0.6, 1.4);
m.scaling.set(s, s, s);
}
function modeMeshes() {
clearAll();
currentMode = "MODE 1: 일반 메시";
ui.mode.textContent = currentMode;
ui.note.textContent = "가장 단순하지만 가장 무거운 기준선";
// baseBox는 숨겨둔 상태 그대로, 새 메시를 BOX_COUNT개 생성
for (let i = 0; i < BOX_COUNT; i++) {
const b = BABYLON.MeshBuilder.CreateBox("box_" + i, { size: SIZE }, scene);
b.material = baseBox.material; // 동일 재질(재질 다양화는 더 악화될 수 있음)
placeMesh(b);
spawned.push(b);
}
ui.thinCount.textContent = "0";
}
function modeInstances() {
clearAll();
currentMode = "MODE 2: Instances";
ui.mode.textContent = currentMode;
ui.note.textContent = "원본 1개 + 인스턴스 10,000개";
for (let i = 0; i < BOX_COUNT; i++) {
const inst = baseBox.createInstance("inst_" + i);
inst.isVisible = true;
placeMesh(inst);
spawned.push(inst);
}
ui.thinCount.textContent = "0";
}
function modeThin() {
clearAll();
currentMode = "MODE 3: Thin Instances";
ui.mode.textContent = currentMode;
ui.note.textContent = "행렬 버퍼로 대량 렌더링(개별 오브젝트 취급 제한)";
// Thin Instances: transform matrix(4x4) 배열을 Float32Array로 준비
// BabylonJS는 Matrix를 16 float로 전개해 buffer로 넣습니다.
const matrices = new Float32Array(BOX_COUNT * 16);
// 선택: per-instance color(4 floats: r,g,b,a) 버퍼도 줄 수 있음
const colors = new Float32Array(BOX_COUNT * 4);
const tmp = new BABYLON.Matrix();
const pos = new BABYLON.Vector3();
const rot = new BABYLON.Vector3();
const scl = new BABYLON.Vector3();
for (let i = 0; i < BOX_COUNT; i++) {
pos.set(rand(-AREA * 0.5, AREA * 0.5), SIZE * 0.5, rand(-AREA * 0.5, AREA * 0.5));
rot.set(0, rand(0, Math.PI * 2), 0);
const s = rand(0.6, 1.4);
scl.set(s, s, s);
BABYLON.Matrix.ComposeToRef(scl, BABYLON.Quaternion.FromEulerVector(rot), pos, tmp);
tmp.copyToArray(matrices, i * 16);
// 색: 약간의 변화를 주되, 큰 차이를 만들지 않도록
colors[i * 4 + 0] = 0.65 + Math.random() * 0.25; // r
colors[i * 4 + 1] = 0.68 + Math.random() * 0.22; // g
colors[i * 4 + 2] = 0.78 + Math.random() * 0.18; // b
colors[i * 4 + 3] = 1.0; // a
}
baseBox.isVisible = true; // thin instances는 base mesh가 실제로 렌더링 대상이 됨
baseBox.thinInstanceSetBuffer("matrix", matrices, 16, true);
baseBox.thinInstanceSetBuffer("color", colors, 4, true);
ui.thinCount.textContent = String(BOX_COUNT);
}
function updateHUD() {
const fps = engine.getFps();
const ft = fps > 0 ? (1000 / fps) : 0;
ui.fps.textContent = fps.toFixed(1);
ui.ft.textContent = ft.toFixed(2);
ui.meshes.textContent = String(scene.meshes.length);
// thinCount는 모드에서 갱신
}
function addSnapshot() {
snapNo += 1;
const fps = parseFloat(ui.fps.textContent) || 0;
const ft = parseFloat(ui.ft.textContent) || 0;
const tr = document.createElement("tr");
tr.innerHTML = `
<td>${snapNo}</td>
<td>${currentMode}</td>
<td>${BOX_COUNT}</td>
<td>${fps.toFixed(1)}</td>
<td>${ft.toFixed(2)}</td>
<td>${scene.meshes.length}</td>
<td>${ui.thinCount.textContent}</td>
<td><span contenteditable="true" style="display:inline-block; min-width:180px; outline:none;">관찰 내용을 적으세요</span></td>
`;
ui.snapBody.appendChild(tr);
}
document.getElementById("modeMeshes").addEventListener("click", () => modeMeshes());
document.getElementById("modeInstances").addEventListener("click", () => modeInstances());
document.getElementById("modeThin").addEventListener("click", () => modeThin());
document.getElementById("snapshot").addEventListener("click", () => addSnapshot());
document.getElementById("resetCam").addEventListener("click", () => {
camera.alpha = Math.PI / 2;
camera.beta = Math.PI / 2.5;
camera.radius = 140;
camera.target.set(0, 0, 0);
});
scene = createScene();
// 시작은 비교가 쉬운 Thin Instances로 두되, 기기 성능에 맞게 바꾸셔도 됩니다.
modeThin();
engine.runRenderLoop(() => {
scene.render();
updateHUD();
});
window.addEventListener("resize", () => engine.resize());
</script>
</body>
</html>
6) 인스턴싱 전/후 비교 포인트
위 데모에서 “정답 숫자”는 환경마다 달라집니다. 대신 다음을 비교 관찰하면 원리를 확실히 이해할 수 있습니다.
| 관찰 항목 | MODE 1: 일반 메시 | MODE 2: Instances | MODE 3: Thin Instances |
|---|---|---|---|
| Scene Meshes 수 | 10,000개 수준으로 증가 | 인스턴스가 오브젝트로 존재(증가) | 실제 씬 메시 수는 크게 늘지 않음(기본 메시 중심) |
| CPU 체감(카메라 조작/프레임) | 빠르게 악화될 가능성이 큼 | 개선되지만 개수 증가 시 한계는 존재 | 대량에서 가장 안정적일 가능성이 큼 |
| 개별 제어/디버그 | 가장 쉬움 | 대체로 가능(인스턴스가 객체로 존재) | 제약이 큼(개별 피킹/충돌은 설계가 필요) |
실습 산출물은 “스냅샷 표”입니다. 같은 장면에서 모드를 바꿔가며 FPS/FrameTime을 기록하고, 메모 칸에 “어느 모드가 어느 구간에서 안정적이었는지”를 남기면 다음 최적화 단계에서 기준선이 됩니다.
7) 실행 단계 체크리스트
- 정적 서버에서 실행하고, 동일한 브라우저/해상도에서 비교합니다.
- MODE 1 → MODE 2 → MODE 3 순서로 전환하며 스냅샷을 기록합니다.
- 가능하면 카메라를 같은 위치로 맞추고(리셋 버튼), 비교 조건을 고정합니다.
- 인스턴싱 비교는 “재질/셰이더가 동일”해야 의미가 있습니다(데모는 동일 재질 기준).
- Thin Instances는 “대량 배치에 특화”되어 있으므로, 개별 상호작용이 필요하면 Instances로 설계를 바꿉니다.
8) 추가로 생각해볼 점
- 1만 개를 ‘움직이게’ 만드는 것은 다른 문제입니다: Thin Instances는 대량 렌더에 유리하지만, 매 프레임 행렬 버퍼를 크게 갱신하면 다시 CPU/업로드 비용이 커질 수 있습니다. 정적/저빈도 업데이트에 특히 적합합니다.
- Instancing만으로도 충분한 경우가 많습니다: 먼저 Instances로 구조를 단순하게 개선하고, 그래도 부족할 때 Thin Instances로 넘어가면 학습 부담이 줄어듭니다.
- 다음 최적화 후보: 컬링(카메라 밖 비활성), LOD, 머티리얼 공유, 텍스처 압축/다운스케일, 후처리 최소화 등이 “추가 단계”로 이어집니다.
- 측정과 함께 진행: 이전 회차의 FPS/드로우콜 측정 루틴과 연결하면, 인스턴싱이 실제로 어떤 지표를 개선하는지 더 명확해집니다.
이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.

0 댓글