BabylonJS 17회차 : 3D 모델/에셋 파이프라인 - GLB 로딩(ImportMesh/SceneLoader)
목표: 외부 3D 모델(GLB)을 안정적으로 불러옵니다.
핵심 개념: async 로딩, 에셋 경로(rootUrl/sceneFilename), 로딩 화면(진행률 표시)
실습: 로딩바 표시 → 로딩 완료 후 씬 진입
산출물: “로딩 UI 포함 모델 뷰어”
목차
- 1) 왜 GLB 로딩이 자주 흔들릴까요?
- 2) 핵심 개념 정리: async, 경로, 로더
- 3) ImportMesh vs SceneLoader: 무엇을 언제 쓰나
- 4) 로딩 UI 설계: 진행률, 완료 이벤트, 실패 처리
- 5) 실습: 로딩바 포함 GLB 모델 뷰어 만들기
- 6) 트러블슈팅 체크리스트
- 7) 추가로 생각해볼 점
- 8) 블로그 최적화 정보
1) 왜 GLB 로딩이 자주 흔들릴까요?
처음 3D 모델을 불러올 때, “코드는 맞는 것 같은데 화면이 안 뜨는” 상황을 자주 겪습니다. 대부분은 아래 범주에서 발생합니다.
- 경로 문제:
rootUrl과 파일명 조합이 실제 서버 경로와 다르거나,rootUrl끝의/누락으로 잘못 합쳐지는 경우가 있습니다. - 로더 누락: GLB(= glTF binary)는 BabylonJS 코어만으로는 처리되지 않고,
babylonjs.loaders플러그인이 필요합니다. - 비동기 흐름: 모델 로딩이 끝나기 전에 카메라/타겟/스케일 보정을 해버려서 결과가 어색하거나, 로딩 중 빈 화면이 오래 떠서 “멈춘 것처럼” 느껴집니다.
- CORS/로컬 실행:
file://로 바로 열면 브라우저 보안 정책 때문에 텍스처/바이너리 로딩이 실패할 수 있습니다. 정적 서버(로컬 서버 포함)로 제공하는 방식이 안전합니다.
이번 회차에서는 이 문제를 “구조적으로” 예방하기 위해, 로딩 UI를 갖춘 안정적인 로딩 파이프라인을 목표로 구성합니다.
2) 핵심 개념 정리: async, 경로, 로더
2-1) async 로딩(기다리는 코드가 기본)
GLB 로딩은 네트워크/디스크 I/O가 포함되므로 비동기입니다. 따라서 await로 “로딩 완료 시점”을 명확히 잡아야, 카메라 타겟/바운딩 기반 자동 프레이밍 같은 후처리를 정확히 적용할 수 있습니다.
2-2) 에셋 경로(rootUrl + sceneFilename) 감각
BabylonJS 로더 API는 대체로 rootUrl(폴더 경로)과 sceneFilename(파일명)을 분리합니다. 이 패턴은 텍스처/바이너리 등 연관 리소스 상대경로 해석을 안정적으로 처리하기 위한 구조입니다.
rootUrl예:./assets/sceneFilename예:robot.glb
중요한 습관은 rootUrl 끝에 /를 붙이는 것입니다. 그리고 로딩 실패 시에는 “경로 문자열”을 가장 먼저 점검합니다.
2-3) GLB 로더 플러그인 포함
CDN으로 BabylonJS를 사용하는 경우, GLB 로딩을 위해 보통 아래 파일을 추가로 포함합니다.
babylon.js(엔진 코어)babylonjs.loaders.min.js(glTF/GLB 로더)
번들 환경(Webpack/Vite 등)에서도 마찬가지로 “로더 패키지”가 포함되어야 GLB가 정상 파싱됩니다.
3) ImportMesh vs SceneLoader: 무엇을 언제 쓰나
BabylonJS에는 여러 로딩 방식이 있습니다. 같은 GLB를 불러오더라도 “어디에 붙일지”, “무엇을 받고 싶은지”에 따라 선택이 달라집니다.
| API | 주요 목적 | 반환/효과 | 추천 상황 |
|---|---|---|---|
SceneLoader.ImportMeshAsync |
특정 메시(또는 전체)를 현재 씬으로 가져오기 | 로드된 meshes, particleSystems, skeletons, animationGroups 등 |
“모델만” 로딩하고 조명/카메라는 내 씬 구성대로 유지하고 싶을 때 |
SceneLoader.AppendAsync |
파일에 포함된 씬 요소를 현재 씬에 “추가” | 현재 씬에 내용이 누적됨(반환은 보통 void) | 프리셋 씬/환경까지 통째로 추가하거나, “추가 로드” 시나리오에 적합 |
SceneLoader.LoadAssetContainerAsync |
바로 씬에 붙이지 않고 컨테이너로 받아 제어 | AssetContainer(필요 시 addAllToScene()) |
로딩 후 조건부로 씬에 추가/제거, 프리로딩/캐싱, 전환이 필요할 때 |
이번 실습에서는 초보자에게 직관적인 흐름을 위해 ImportMeshAsync를 기준으로 진행하되, 동일한 로딩 UI가 AppendAsync에도 거의 그대로 적용된다는 점까지 함께 연결합니다.
4) 로딩 UI 설계: 진행률, 완료 이벤트, 실패 처리
4-1) 로딩 UI가 필요한 이유
- 모델 용량이 크면 첫 화면이 오래 비어 보일 수 있습니다.
- 사용자는 “멈춤”과 “로딩 중”을 구분하기 어렵습니다.
- 진행률이 보이면 체감 대기시간이 크게 줄어듭니다.
4-2) 진행률(onProgress)에서 주의할 점
onProgress 이벤트는 네트워크 상황/서버 설정/브라우저에 따라 total이 항상 채워지지 않을 수 있습니다. 따라서 로딩바는 아래처럼 “안전하게” 처리하는 패턴이 좋습니다.
lengthComputable인 경우:loaded / total로 정확한 퍼센트 계산- 그 외: 텍스트를 “로딩 중…”으로 유지하거나, 보조적인 애니메이션(스트라이프/스피너)을 사용
4-3) 실패 처리(try/catch + 메시지)
실무에서는 “파일명 오타/경로 오류/서버 미구성/CORS”가 가장 흔한 실패 원인입니다. 따라서 로딩 코드는 반드시 try/catch로 감싸고, 화면에 오류 메시지를 노출하는 편이 디버깅에 유리합니다.
5) 실습: 로딩바 포함 GLB 모델 뷰어 만들기
아래 예시는 정적 서버에서 실행하는 것을 전제로 합니다. (예: VSCode Live Server, Vite dev server 등) 폴더 구조는 다음처럼 단순하게 두는 것이 이해에 좋습니다.
| 경로 | 내용 |
|---|---|
/index.html |
모델 뷰어 메인 |
/assets/robot.glb |
불러올 GLB 파일(예시) |
5-1) 완성 코드: “로딩 UI 포함 모델 뷰어”
아래 코드는 그대로 복사해 index.html로 저장한 뒤, assets 폴더에 GLB를 넣고 실행하시면 됩니다.
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>BabylonJS GLB Model Viewer (with Loading UI)</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; }
/* 로딩 오버레이 */
#loadingOverlay {
position: fixed; inset: 0;
display: flex; align-items: center; justify-content: center;
background: rgba(10, 12, 16, 0.88);
color: #fff;
z-index: 9999;
}
#loadingCard {
width: min(560px, 92vw);
border: 1px solid rgba(255,255,255,0.18);
border-radius: 14px;
padding: 18px 18px 16px;
background: rgba(255,255,255,0.06);
backdrop-filter: blur(6px);
}
#loadingTitle { font-size: 16px; margin: 0 0 10px 0; font-weight: 600; }
#loadingDesc { font-size: 13px; margin: 0 0 12px 0; color: rgba(255,255,255,0.82); line-height: 1.5; }
#barWrap {
width: 100%;
height: 10px;
border-radius: 999px;
background: rgba(255,255,255,0.14);
overflow: hidden;
}
#bar {
width: 0%;
height: 100%;
background: rgba(255,255,255,0.86);
transform-origin: left;
transition: width 120ms linear;
}
#percentRow {
display: flex; align-items: center; justify-content: space-between;
margin-top: 10px;
font-size: 12px;
color: rgba(255,255,255,0.85);
}
#errorBox {
margin-top: 12px;
padding: 10px 12px;
border-radius: 10px;
border: 1px solid rgba(255,120,120,0.35);
background: rgba(255, 80, 80, 0.08);
color: rgba(255,235,235,0.95);
display: none;
white-space: pre-wrap;
font-size: 12px;
line-height: 1.45;
}
</style>
<!-- BabylonJS CDN (코어 + 로더) -->
<script src="https://cdn.babylonjs.com/babylon.js"></script>
<script src="https://cdn.babylonjs.com/loaders/babylonjs.loaders.min.js"></script>
</head>
<body>
<div id="loadingOverlay">
<div id="loadingCard">
<p id="loadingTitle">모델 로딩 중</p>
<p id="loadingDesc">GLB 파일을 불러오고 있습니다. 네트워크/PC 성능에 따라 시간이 걸릴 수 있습니다.</p>
<div id="barWrap"><div id="bar"></div></div>
<div id="percentRow">
<span id="percentText">0%</span>
<span id="detailText">준비 중...</span>
</div>
<div id="errorBox"></div>
</div>
</div>
<canvas id="renderCanvas"></canvas>
<script>
const canvas = document.getElementById("renderCanvas");
const engine = new BABYLON.Engine(canvas, true);
const ui = {
overlay: document.getElementById("loadingOverlay"),
bar: document.getElementById("bar"),
percent: document.getElementById("percentText"),
detail: document.getElementById("detailText"),
error: document.getElementById("errorBox")
};
function setProgress(p, detail) {
const clamped = Math.max(0, Math.min(100, p));
ui.bar.style.width = clamped + "%";
ui.percent.textContent = Math.round(clamped) + "%";
if (detail) ui.detail.textContent = detail;
}
function showError(message) {
ui.error.style.display = "block";
ui.error.textContent = message;
ui.detail.textContent = "로딩 실패";
}
function hideOverlay() {
ui.overlay.style.display = "none";
}
async function createScene() {
const scene = new BABYLON.Scene(engine);
scene.clearColor = new BABYLON.Color4(0.06, 0.07, 0.09, 1.0);
// 카메라/라이트는 “내 뷰어” 기준으로 고정 구성
const camera = new BABYLON.ArcRotateCamera(
"camera",
Math.PI / 2,
Math.PI / 2.2,
3.5,
new BABYLON.Vector3(0, 1, 0),
scene
);
camera.attachControl(canvas, true);
camera.wheelDeltaPercentage = 0.01;
const light = new BABYLON.HemisphericLight("hemi", new BABYLON.Vector3(0, 1, 0), scene);
light.intensity = 1.0;
// 기본 그리드 바닥(선택): 스케일 감각 확인용
const ground = BABYLON.MeshBuilder.CreateGround("ground", { width: 6, height: 6 }, scene);
ground.position.y = 0;
ground.isPickable = false;
const gmat = new BABYLON.StandardMaterial("gmat", scene);
gmat.alpha = 0.15;
ground.material = gmat;
// 로딩 대상: /assets/robot.glb
// rootUrl은 끝에 / 를 붙이는 습관이 안전합니다.
const rootUrl = "./assets/";
const fileName = "robot.glb";
setProgress(0, "요청 준비...");
try {
// onProgress: total이 없을 수도 있으니 안전 처리
const onProgress = (evt) => {
if (!evt) return;
if (evt.lengthComputable && evt.total > 0) {
const p = (evt.loaded / evt.total) * 100;
setProgress(p, `다운로드 ${Math.round(p)}%`);
} else {
// total을 모르는 경우: 퍼센트를 고정하지 말고 상태 텍스트만 갱신
ui.detail.textContent = `다운로드 ${Math.round(evt.loaded / 1024)}KB...`;
}
};
// ImportMeshAsync: 모델만 가져오고, 내가 만든 카메라/라이트는 유지
setProgress(5, "GLB 파싱 준비...");
const result = await BABYLON.SceneLoader.ImportMeshAsync(
null, // meshNames: null이면 전체
rootUrl, // rootUrl
fileName, // sceneFilename
scene, // scene
onProgress // onProgress
);
// 로딩 완료 후 후처리(카메라 프레이밍/위치 보정 등)
setProgress(95, "후처리 적용 중...");
// 모델 전체 바운딩 기반으로 카메라 자동 프레이밍
const meshes = result.meshes.filter(m => m && m.getTotalVertices && m.getTotalVertices() > 0);
if (meshes.length > 0) {
const merged = BABYLON.Mesh.MergeMeshes(meshes, true, true, undefined, false, true);
if (merged) {
merged.name = "__mergedForBounds";
merged.isVisible = false; // 바운딩 계산용
const bi = merged.getBoundingInfo();
const center = bi.boundingBox.centerWorld;
const radius = bi.boundingSphere.radiusWorld;
camera.setTarget(center);
camera.radius = Math.max(2.0, radius * 2.2);
// 바닥 기준 정렬(선택): 모델이 공중에 떠 있으면 내려주기
const minY = bi.boundingBox.minimumWorld.y;
const deltaY = 0 - minY;
// 원본 메시들을 함께 이동(merged는 dispose 되었을 수 있으므로 result.meshes 기준)
result.meshes.forEach(m => {
if (m && m.position) m.position.y += deltaY;
});
}
}
setProgress(100, "완료");
hideOverlay();
return scene;
} catch (e) {
const msg = (e && e.message) ? e.message : String(e);
showError(
"GLB 로딩에 실패했습니다.\n" +
"1) /assets/robot.glb 경로가 맞는지\n" +
"2) 정적 서버로 실행 중인지(file:// 금지)\n" +
"3) babylonjs.loaders 포함 여부를 확인하세요.\n\n" +
"에러: " + msg
);
setProgress(0, "실패");
return scene;
}
}
(async () => {
const scene = await createScene();
engine.runRenderLoop(() => {
if (scene) scene.render();
});
})();
window.addEventListener("resize", () => {
engine.resize();
});
</script>
</body>
</html>
5-2) 코드 읽는 포인트(초보자 기준)
- 로딩 오버레이는 캔버스 위에 “고정 레이어”로 구성: 로딩 중에도 WebGL 캔버스는 준비될 수 있지만, 사용자는 오버레이를 통해 진행 상황을 확인합니다.
ImportMeshAsync가 끝난 뒤에만 카메라 프레이밍: 모델이 아직 없는 상태에서 바운딩 계산을 하면 항상 실패하거나 의미 없는 값이 됩니다.onProgress의total을 신뢰하지 않는 방식:lengthComputable일 때만 퍼센트를 계산하고, 아니면 “KB 단위 로딩”처럼 텍스트만 갱신합니다.- 실패 메시지를 UI에 표시: 콘솔만 보고 찾기 어려운 경우가 많아, 화면에 원인 가이드를 함께 노출하면 디버깅 속도가 빨라집니다.
5-3) SceneLoader.AppendAsync로 바꾸면?
만약 “모델 파일 안의 씬 요소를 통째로 추가”하고 싶다면, 로딩 부분을 아래처럼 교체할 수 있습니다. (로딩 UI 로직은 그대로 유지됩니다.)
// ImportMeshAsync 대신 AppendAsync를 사용하는 예시(핵심만)
const onProgress = (evt) => {
if (evt && evt.lengthComputable && evt.total > 0) {
const p = (evt.loaded / evt.total) * 100;
setProgress(p, `다운로드 ${Math.round(p)}%`);
}
};
await BABYLON.SceneLoader.AppendAsync(rootUrl, fileName, scene, onProgress);
// AppendAsync는 현재 scene에 내용이 추가됩니다.
6) 트러블슈팅 체크리스트
로딩이 실패하거나 화면에 아무것도 안 보일 때, 아래 순서대로 점검하면 대부분 해결됩니다.
| 증상 | 가능 원인 | 해결 팁 |
|---|---|---|
| 로딩바 0%에서 멈춤 | 서버 미실행(file://), 경로 오타 | 정적 서버로 실행하고, ./assets/robot.glb 직접 접근이 되는지 확인 |
| 콘솔에 “No plugin found” 류 에러 | GLB 로더 미포함 | babylonjs.loaders.min.js 포함 여부 확인 |
| 로드는 되는데 화면에 아무것도 안 보임 | 카메라가 모델을 못 보고 있음(스케일/위치) | 바운딩 기반 프레이밍 적용, 모델을 원점으로 이동, 카메라 radius 조정 |
| 텍스처가 안 보이거나 깨짐 | 텍스처 경로/압축 포맷/서버 MIME | GLB 내장 텍스처 권장, 서버에서 올바른 정적 파일 제공 확인 |
7) 추가로 생각해볼 점
- 로딩 화면 고도화: 퍼센트가 불확실한 경우를 대비해 “스피너 + 상태 텍스트” 조합을 넣으면 UX가 더 안정적입니다.
- AssetContainer로 프리로딩: 화면 전환이 필요한 프로젝트라면
LoadAssetContainerAsync로 미리 받아두고, 사용자 입력 시addAllToScene()로 붙이는 구조가 효율적입니다. - 메모리 관리: 여러 모델을 연속으로 로딩한다면, 이전 모델의 메시/머티리얼/텍스처를 명확히 dispose하는 루틴을 준비하는 것이 좋습니다.
- 모델 표준화 파이프라인: 팀/프로젝트 규모가 커질수록 “원점/축/스케일 규칙”을 정해두면 뷰어와 게임 씬에서 모두 편해집니다.
이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.

0 댓글