BabylonJS 17회차 : 3D 모델/에셋 파이프라인 - GLB 로딩(ImportMesh/SceneLoader)



BabylonJS 17회차 : 3D 모델/에셋 파이프라인 - GLB 로딩(ImportMesh/SceneLoader)

목표: 외부 3D 모델(GLB)을 안정적으로 불러옵니다.
핵심 개념: async 로딩, 에셋 경로(rootUrl/sceneFilename), 로딩 화면(진행률 표시)
실습: 로딩바 표시 → 로딩 완료 후 씬 진입
산출물: “로딩 UI 포함 모델 뷰어”


목차


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가 끝난 뒤에만 카메라 프레이밍: 모델이 아직 없는 상태에서 바운딩 계산을 하면 항상 실패하거나 의미 없는 값이 됩니다.
  • onProgresstotal을 신뢰하지 않는 방식: 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하는 루틴을 준비하는 것이 좋습니다.
  • 모델 표준화 파이프라인: 팀/프로젝트 규모가 커질수록 “원점/축/스케일 규칙”을 정해두면 뷰어와 게임 씬에서 모두 편해집니다.

이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.

Reactions

댓글 쓰기

0 댓글