BabylonJS 23회차 : LOD/Frustum Culling/텍스처 최적화
목표: 대형 씬의 기본 최적화 원칙을 습득합니다.
핵심 개념: LOD, Frustum Culling, 텍스처 해상도/압축
실습: 멀리서 저해상도 모델로 전환(LOD 데모)
산출물: LOD 데모
요약
대형 씬이 느려지는 이유는 단순히 “오브젝트가 많아서”가 아니라, 멀리 있는 것까지 고품질로 그리거나, 카메라에 안 보이는 것까지 계산/렌더링하거나, 텍스처 비용(메모리/대역폭)이 과도하기 때문인 경우가 많습니다. 이번 회차에서는 BabylonJS에서 바로 적용 가능한 3가지 축을 잡습니다. (1) LOD로 거리 기반 품질 전환, (2) Frustum Culling으로 화면 밖 렌더 비용 절감, (3) 텍스처 해상도/압축으로 메모리·샘플링 비용 관리입니다.
목차
- 1) 핵심 포인트
- 2) LOD 기본: “멀리서는 단순하게”
- 3) Frustum Culling: “화면 밖은 안 그린다”
- 4) 텍스처 최적화: 해상도와 압축의 기본
- 5) 실습: LOD 데모 만들기(거리 따라 모델 전환)
- 6) 실행 단계 체크리스트
- 7) 추가로 생각해볼 점
- 8) 블로그 최적화 정보
1) 핵심 포인트
- LOD는 “품질”이 아니라 “비용”을 다루는 기술: 멀리 있는 오브젝트는 시각적으로 구분이 어려우므로, 폴리곤/머티리얼/텍스처 비용을 내려도 체감 품질이 유지됩니다.
- Frustum Culling은 기본적으로 켜져 있고, 끄지 않는 것이 원칙: 화면에 보이지 않는 오브젝트는 렌더 파이프라인에 들어오지 않게 해야 합니다.
- 텍스처는 해상도만 낮춰도 큰 효과: 특히 모바일/저사양에서 텍스처 메모리와 대역폭이 병목이 되기 쉽습니다.
- 측정 → 적용 → 비교: 이전 회차의 FPS/드로우콜 측정 루틴과 연결해 “정말 개선됐는지”를 숫자로 확인합니다.
2) LOD 기본: “멀리서는 단순하게”
LOD(Level of Detail)는 “거리(또는 화면에서 차지하는 크기)”에 따라 모델의 디테일을 바꾸는 전략입니다. 실무에서 가장 흔한 형태는 2~3단계입니다.
| 단계 | 권장 용도 | 구성 예 | 기대 효과 |
|---|---|---|---|
| LOD0(근거리) | 카메라에 크게 보임 | 원본 고폴리곤 + 고해상도 텍스처 | 품질 유지 |
| LOD1(중거리) | 실루엣만 중요 | 폴리곤 감소 + 텍스처 다운스케일 | GPU 비용 감소 |
| LOD2(원거리) | 작게 보임/군집 | 아주 단순한 메시 또는 빌보드 | 대규모 씬에서 유지력 향상 |
BabylonJS에서는 보통 “메시를 여러 개 준비한 뒤” LOD 전환 거리를 등록하는 방식으로 시작합니다. 핵심은 LOD 모델들이 동일한 위치/피벗 기준을 공유해야 전환이 자연스럽다는 점입니다.
3) Frustum Culling: “화면 밖은 안 그린다”
Frustum(절두체)은 카메라가 실제로 보는 3D 공간입니다. 카메라 밖의 오브젝트는 그리지 않는 것이 정상입니다. BabylonJS는 기본적으로 “카메라 프러스텀에 들어온 메시만 Active Mesh로 선택”해 렌더 비용을 줄입니다.
3-1) 초보자가 자주 하는 실수
- 메시를 ‘항상 활성’로 강제: 디버깅을 위해 설정한 옵션을 그대로 두면, 화면 밖도 계속 처리되어 Active Mesh가 과도하게 유지될 수 있습니다.
- 바운딩이 이상한 모델: 스케일/피벗이 꼬인 모델은 바운딩 계산이 비정상일 수 있어 컬링이 기대대로 동작하지 않을 수 있습니다.
- 투명/특수 렌더: 일부 특수 머티리얼/렌더링은 정렬/패스 구성으로 예상과 다른 비용이 들 수 있으므로, “Active Mesh 수”와 함께 관찰하는 습관이 좋습니다.
3-2) 확인 방법(추천)
- Inspector에서 Active Meshes/Draw Calls를 함께 확인합니다.
- 카메라를 회전/이동해도 Active Meshes가 줄지 않는다면 “컬링이 제대로 안 되는 이유”를 의심합니다.
- 이전 회차처럼 HUD로
scene.getActiveMeshes().length를 표시하면 변화가 즉시 보입니다.
4) 텍스처 최적화: 해상도와 압축의 기본
텍스처는 “용량”뿐 아니라 GPU 메모리와 샘플링 대역폭을 사용합니다. 특히 대형 씬에서 텍스처가 많아지면, 폴리곤보다 먼저 텍스처가 병목이 되는 경우도 흔합니다.
4-1) 해상도 규칙(가장 쉬운 효과)
- 보이는 크기 기준으로 결정: 화면에서 작게 보이는 오브젝트에 4K 텍스처를 쓰면 대부분 낭비입니다.
- 공유 텍스처를 우선: 같은 재질/패턴은 가능한 한 공유해 텍스처 수 자체를 줄입니다.
- 기본 권장 범위: 소품은 512~1024, 중형은 1024~2048부터 시작해 측정으로 조정하는 방식이 안전합니다.
4-2) 압축(실무 확장 방향)
웹 환경에서는 기기/브라우저마다 지원하는 텍스처 압축 포맷이 다를 수 있습니다. 초보자 단계에서는 “압축 텍스처가 존재한다”는 것을 알고, 프로젝트가 커질 때 다음의 방향으로 확장하면 충분합니다.
- Basis/KTX2 계열: 런타임에서 적절한 GPU 포맷으로 변환/업로드하는 워크플로를 도입하면 대역폭/메모리 개선에 도움이 됩니다.
- 단계적 도입: 먼저 해상도/아틀라스/공유로 줄이고, 이후에 압축 파이프라인을 붙이는 흐름을 권장합니다.
5) 실습: LOD 데모 만들기(거리 따라 모델 전환)
이번 실습은 “고해상도(LOD0) 모델”과 “저해상도(LOD1) 모델”을 준비해, 카메라가 멀어질수록 자동으로 저해상도 모델이 선택되도록 만드는 데모입니다. 초보자도 바로 실행할 수 있도록, 여기서는 GLB 없이도 재현 가능한 방식(세그먼트가 다른 구/토러스 등)을 사용합니다. 이후에는 GLB 2개(고/저)로 교체해도 같은 원리로 동작합니다.
5-1) 완성 코드: LOD 전환 데모(Active Mesh 관찰 포함)
아래 코드는 index.html 하나로 실행할 수 있습니다(정적 서버 권장). 카메라를 멀리/가까이 이동하면서 LOD가 전환되는지, Active Mesh가 어떻게 잡히는지 관찰합니다.
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>BabylonJS LOD Demo (Frustum Culling + Texture Tips)</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(520px, 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); }
</style>
<script src="https://cdn.babylonjs.com/babylon.js"></script>
</head>
<body>
<div id="hud">
<p id="hudTitle">LOD 데모: 거리 따라 모델 전환(Active Mesh 관찰)</p>
<div id="stats">
<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">Camera Radius</span><span class="v" id="rad">-</span></div>
<div class="row"><span class="k">Current LOD(예상)</span><span class="v" id="lod">-</span></div>
<div class="row"><span class="k">Active Meshes</span><span class="v" id="active">-</span></div>
</div>
<div id="btns">
<button id="toggleAlwaysActive">alwaysSelectAsActiveMesh 토글(주의)</button>
<button id="resetCam">카메라 리셋</button>
</div>
</div>
<canvas id="renderCanvas"></canvas>
<script>
const canvas = document.getElementById("renderCanvas");
const engine = new BABYLON.Engine(canvas, true);
const ui = {
fps: document.getElementById("fps"),
ft: document.getElementById("ft"),
rad: document.getElementById("rad"),
lod: document.getElementById("lod"),
active: document.getElementById("active")
};
let scene, camera;
let hero; // LOD가 걸린 메인 메시(LOD0)
let lod1; // LOD1 메시(저해상도)
let forceAlwaysActive = false;
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, 18,
new BABYLON.Vector3(0, 1.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: 60, height: 60 }, s);
ground.position.y = 0;
ground.isPickable = false;
const gmat = new BABYLON.StandardMaterial("gmat", s);
gmat.alpha = 0.18;
ground.material = gmat;
// ---------------------------------------
// LOD 준비: 동일 위치에 “고/저” 모델 2개
// ---------------------------------------
// LOD0(근거리): 세그먼트(디테일)가 높은 토러스
hero = BABYLON.MeshBuilder.CreateTorus("heroLOD0", {
diameter: 2.2,
thickness: 0.55,
tessellation: 96
}, s);
hero.position.set(0, 1.2, 0);
// LOD1(원거리): 세그먼트가 낮은 토러스(더 단순)
lod1 = BABYLON.MeshBuilder.CreateTorus("heroLOD1", {
diameter: 2.2,
thickness: 0.55,
tessellation: 16
}, s);
lod1.position.copyFrom(hero.position);
// 보기 쉽게 재질은 동일하게(LOD끼리 스타일이 흔들리지 않게)
const mat = new BABYLON.StandardMaterial("mat", s);
mat.diffuseColor = new BABYLON.Color3(0.75, 0.78, 0.90);
hero.material = mat;
lod1.material = mat;
// LOD 등록: distance 기준으로 교체
// 가까울 때(LOD0) hero가 보이고, 멀어지면 lod1로 전환됩니다.
// 값은 프로젝트 규모에 맞게 조정합니다.
hero.addLODLevel(20, lod1); // 20 이상이면 LOD1
hero.addLODLevel(40, null); // 40 이상이면 렌더하지 않음(완전 원거리 제거)
// LOD 메시는 기본적으로 로직에 의해 렌더링되므로, 직접 visible을 만지기보다 LOD 규칙에 맡기는 편이 안전합니다.
// (여기서는 LOD 확인을 위해 lod1을 만든 뒤 LOD로 등록합니다.)
// 주변에 오브젝트를 조금 뿌려 frustum culling 변화도 느껴보기
const decoMat = new BABYLON.StandardMaterial("decoMat", s);
decoMat.diffuseColor = new BABYLON.Color3(0.25, 0.28, 0.30);
for (let i = 0; i < 250; i++) {
const b = BABYLON.MeshBuilder.CreateBox("deco_" + i, { size: 0.6 }, s);
b.position.x = (Math.random() - 0.5) * 55;
b.position.z = (Math.random() - 0.5) * 55;
b.position.y = 0.3;
b.material = decoMat;
}
return s;
}
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.rad.textContent = camera.radius.toFixed(2);
ui.active.textContent = String(scene.getActiveMeshes().length);
// 간단한 “예상 LOD” 표시(거리 기준 설명용)
const r = camera.radius;
let label = "LOD0(고)";
if (r >= 40) label = "Culled(렌더 안 함)";
else if (r >= 20) label = "LOD1(저)";
ui.lod.textContent = label;
}
document.getElementById("toggleAlwaysActive").addEventListener("click", () => {
forceAlwaysActive = !forceAlwaysActive;
// 주의: alwaysSelectAsActiveMesh=true는 frustum culling 효과를 약화시킬 수 있습니다.
// 디버깅 목적으로만 쓰고, 실무에서는 기본값(대개 false)을 유지하는 편이 안전합니다.
scene.meshes.forEach(m => {
// ground 등은 제외하고 싶다면 조건을 넣어도 됩니다.
m.alwaysSelectAsActiveMesh = forceAlwaysActive;
});
});
document.getElementById("resetCam").addEventListener("click", () => {
camera.alpha = Math.PI / 2;
camera.beta = Math.PI / 2.5;
camera.radius = 18;
camera.target.set(0, 1.0, 0);
});
scene = createScene();
engine.runRenderLoop(() => {
scene.render();
updateHUD();
// 보기 좋은 애니메이션(LOD 전환 관찰용)
hero.rotation.y += 0.01;
});
window.addEventListener("resize", () => engine.resize());
</script>
</body>
</html>
5-2) GLB로 확장하는 방법(실무 연결)
- 고해상도 GLB(LOD0)와 저해상도 GLB(LOD1)를 준비합니다. 두 모델은 스케일/피벗/정렬이 동일해야 전환이 자연스럽습니다.
- SceneLoader로 두 모델을 로딩한 뒤, LOD0 메시에
addLODLevel로 LOD1 메시를 연결합니다. - 원거리에서는
addLODLevel(거리, null)을 추가해 “아예 렌더에서 제외”하는 단계도 고려할 수 있습니다.
6) 실행 단계 체크리스트
- LOD 모델(고/저)은 피벗과 스케일을 일치시킵니다(Blender 내보내기 규칙과 연결).
- LOD 전환 거리는 “숫자 하나로 끝”이 아니라, 씬 스케일과 카메라 설계에 맞게 조정합니다.
- Frustum Culling을 약화시키는 옵션(예:
alwaysSelectAsActiveMesh강제)은 디버깅 이후 반드시 되돌립니다. - 텍스처는 먼저 “해상도 다운스케일 + 공유”부터 적용하고, 필요하면 압축 파이프라인을 확장합니다.
- 적용 전/후를 이전 회차의 측정 루틴(FPS/Active Meshes/드로우콜)로 비교해 기록합니다.
7) 추가로 생각해볼 점
- LOD는 ‘교체 시점’이 중요: 너무 이른 전환은 품질이 티 나고, 너무 늦은 전환은 성능 이득이 작습니다. 프로젝트마다 “허용 가능한 품질”을 기준으로 거리를 잡는 편이 좋습니다.
- 원거리 Culled 단계는 강력하지만 조심: 원거리에서 완전히 숨기면 성능이 좋아질 수 있지만, 갑자기 나타나는 팝인(pop-in)이 거슬릴 수 있습니다. 필요하면 단계 수를 늘리거나 전환 거리를 조정합니다.
- 텍스처 최적화는 ‘보이는 면적’이 기준: 디테일을 텍스처로만 표현하는 경우, 해상도 하향이 품질에 큰 영향을 줄 수 있으니 “테스트 샷(카메라 거리)”로 판단하는 습관이 좋습니다.
- 대형 씬은 결합 전략이 필요: 인스턴싱/LOD/컬링/텍스처 최적화가 각각 따로가 아니라, 함께 설계될 때 효과가 커집니다.
이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.

0 댓글