BabylonJS 23회차 : LOD/Frustum Culling/텍스처 최적화



BabylonJS 23회차 : LOD/Frustum Culling/텍스처 최적화

목표: 대형 씬의 기본 최적화 원칙을 습득합니다.
핵심 개념: LOD, Frustum Culling, 텍스처 해상도/압축
실습: 멀리서 저해상도 모델로 전환(LOD 데모)
산출물: LOD 데모


요약

대형 씬이 느려지는 이유는 단순히 “오브젝트가 많아서”가 아니라, 멀리 있는 것까지 고품질로 그리거나, 카메라에 안 보이는 것까지 계산/렌더링하거나, 텍스처 비용(메모리/대역폭)이 과도하기 때문인 경우가 많습니다. 이번 회차에서는 BabylonJS에서 바로 적용 가능한 3가지 축을 잡습니다. (1) LOD로 거리 기반 품질 전환, (2) Frustum Culling으로 화면 밖 렌더 비용 절감, (3) 텍스처 해상도/압축으로 메모리·샘플링 비용 관리입니다.


목차


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/컬링/텍스처 최적화가 각각 따로가 아니라, 함께 설계될 때 효과가 커집니다.

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

Reactions

댓글 쓰기

0 댓글