BabylonJS 21회차 : 성능 측정 방법(프레임/드로우콜)



BabylonJS 21회차 : 성능 측정 방법(프레임/드로우콜)

목표: 무엇이 느린지 “측정”할 수 있습니다.
핵심 개념: Inspector, Instrumentation, FPS
실습: 오브젝트 수를 증가시키며 병목 찾기
산출물: “성능 측정 스냅샷”


요약

성능 최적화는 “감”으로 하면 실패하기 쉽습니다. 먼저 측정으로 현재 상태를 숫자로 확인하고, 그 숫자가 무엇을 의미하는지 이해한 뒤, 병목의 종류(드로우콜 과다인지, 폴리곤 과다인지, CPU 업데이트 비용인지)를 좁혀가야 합니다. 이번 회차에서는 BabylonJS에서 가장 빠르게 시작할 수 있는 Inspector와, 코드로 지표를 수집하는 Instrumentation을 함께 사용해 “느린 이유를 재현하고 기록하는 루틴”을 만드는 것이 목표입니다.


목차


1) 핵심 포인트(오늘의 결론)

  • FPS만 보지 말고 프레임타임(ms)도 같이 보세요: FPS는 직관적이지만, 원인 분석은 프레임타임이 더 유리합니다.
  • 드로우콜(Draw Calls)은 “CPU 제출 비용”과 직결됩니다: 오브젝트/머티리얼이 많아질수록 드로우콜이 늘고, CPU가 먼저 병목이 될 수 있습니다.
  • Inspector는 “현장 점검 도구”입니다: 지금 화면에서 무엇이 많은지(메시/머티리얼/텍스처/드로우콜)를 빠르게 확인합니다.
  • Instrumentation은 “기록 도구”입니다: 숫자를 코드로 수집해 스냅샷을 남기고, 변경 전/후 비교가 가능해집니다.
  • 실습은 재현 가능해야 합니다: 오브젝트 수를 단계적으로 늘리며 “어느 구간부터 무엇이 급증하는지”를 관찰합니다.


2) FPS·프레임타임·드로우콜: 무엇을 먼저 볼까?

2-1) FPS와 프레임타임의 관계

FPS는 “1초에 몇 프레임을 그렸는가”이고, 프레임타임은 “1프레임을 그리는 데 걸린 시간(ms)”입니다. 둘은 반비례 관계입니다. 예를 들어 60FPS는 대략 16.67ms/프레임 수준입니다.

  • 60 FPS ≈ 16.67 ms
  • 30 FPS ≈ 33.33 ms
  • 20 FPS ≈ 50.00 ms

실무적으로는 “목표 FPS”를 잡고, 목표 프레임타임을 기준으로 초과하는 요소를 찾는 방식이 많이 쓰입니다.

2-2) 드로우콜(Draw Calls)이 중요한 이유

드로우콜은 GPU가 화면에 그리기 위해 “그려라”라고 명령을 내리는 호출 단위로 이해하셔도 충분합니다. 드로우콜이 많아지면 GPU가 아니라 CPU(렌더 제출, 상태 변경)가 먼저 바빠질 수 있습니다. 특히 오브젝트가 많고 머티리얼이 다양할수록 드로우콜이 늘어나기 쉽습니다.

2-3) 초보자 기준 ‘우선순위’ 추천

우선순위 지표 의미 초보자용 해석
1 FPS / Frame Time 전체 속도 느린지 빠른지 ‘현상’ 확인
2 Draw Calls 렌더 제출량 오브젝트/머티리얼이 너무 많지 않은지
3 Active Meshes / Total Vertices 화면에 관여하는 지오메트리 양 폴리곤/메시가 과한지


3) Inspector로 빠르게 확인하는 방법

Inspector는 “지금 씬에서 무엇이 많은지”를 UI로 보여주는 도구입니다. 초보자에게 특히 좋은 이유는, 코드 수정 없이도 현재 상태를 빠르게 점검할 수 있기 때문입니다.

3-1) Inspector 포함(개발 중에만)

브라우저에서 Inspector를 열려면 Inspector 스크립트를 포함하고, 단축키 또는 버튼으로 호출할 수 있게 만드는 것이 편합니다.

  • 개발 중: Inspector 포함
  • 배포/릴리스: Inspector 제외(용량/보안/성능 이유)

3-2) Inspector에서 초보자가 먼저 볼 것

  • Stats/Performance 영역: FPS, 드로우콜, 메모리/렌더 정보가 함께 보이는 경우가 많습니다.
  • Meshes/Materials 목록: 객체/재질이 과도하게 쪼개져 있는지 확인합니다.
  • Textures: 텍스처 개수가 많거나 해상도가 과도한지 점검합니다.

Inspector는 “원인 후보를 좁히는 도구”입니다. 이후에는 Instrumentation으로 숫자를 수집해 스냅샷을 남기면 비교가 쉬워집니다.


4) Instrumentation으로 숫자 수집하기

Instrumentation은 “프레임마다 어떤 비용이 발생했는지”를 숫자로 읽을 수 있게 해줍니다. 이번 회차에서는 초보자에게 가장 실용적인 범위로 다음을 다룹니다.

  • FPS: engine.getFps()
  • 드로우콜: EngineInstrumentation의 drawCallsCounter(가능할 때 사용)
  • 액티브 메시/버텍스: scene.getActiveMeshes().length, scene.getTotalVertices()

중요한 태도는 하나입니다. “한 번에 많은 지표를 다 보려고 하지 말고, 의심되는 병목을 한두 개로 좁혀서 확인”하는 방식이 효율적입니다.


5) 실습: 오브젝트 수 증가시키며 병목 찾기

이 실습의 핵심은 “재현 가능한 부하”를 만드는 것입니다. 박스(또는 구) 메시를 단계적으로 늘려가며 FPS와 드로우콜이 어떻게 변하는지 관찰합니다. 그리고 어느 순간부터 급격히 떨어지는 구간을 찾아 “무엇이 먼저 한계에 도달하는지”를 확인합니다.

5-1) 실습 시나리오

  • 버튼으로 박스를 100개씩 추가합니다.
  • 매 프레임 HUD에 FPS/오브젝트 수/드로우콜/버텍스를 표시합니다.
  • Inspector 토글 버튼을 추가해 현장 점검이 가능하게 만듭니다.
  • 스냅샷 버튼으로 현재 수치를 표에 기록해 “성능 측정 스냅샷”을 완성합니다.

5-2) 완성 코드: 측정 HUD + 오브젝트 증가 + 스냅샷 기록

아래 코드는 그대로 index.html로 실행할 수 있습니다(정적 서버 권장). Inspector는 개발 편의용으로 포함되어 있으며, 필요 시 제거해도 실습은 가능합니다.

<!doctype html>
<html lang="ko">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <title>BabylonJS Performance Measure (FPS / Draw Calls)</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(420px, 92vw);
      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(720px, 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>
  <!-- Inspector는 개발 중에만 권장(배포 시 제거) -->
  <script src="https://cdn.babylonjs.com/inspector/babylon.inspector.bundle.js"></script>
</head>

<body>
  <div id="hud">
    <p id="hudTitle">성능 측정 HUD (FPS / Draw Calls)</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">Draw Calls</span><span class="v" id="dc">-</span></div>
      <div class="row"><span class="k">Meshes(전체)</span><span class="v" id="mesh">-</span></div>
      <div class="row"><span class="k">Active Meshes</span><span class="v" id="active">-</span></div>
      <div class="row"><span class="k">Total Vertices</span><span class="v" id="vert">-</span></div>
      <div class="row"><span class="k">Boxes(추가한 개수)</span><span class="v" id="count">0</span></div>
    </div>

    <div id="btns">
      <button id="add100">+100 박스</button>
      <button id="reset">초기화</button>
      <button id="snapshot">스냅샷 기록</button>
      <button id="toggleInspector">Inspector 토글</button>
    </div>
  </div>

  <div id="snapWrap">
    <h3>성능 측정 스냅샷(기록)</h3>
    <table>
      <thead>
        <tr>
          <th>No</th>
          <th>Boxes</th>
          <th>FPS</th>
          <th>FrameTime(ms)</th>
          <th>DrawCalls</th>
          <th>ActiveMeshes</th>
          <th>Vertices</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 = {
      fps: document.getElementById("fps"),
      ft: document.getElementById("ft"),
      dc: document.getElementById("dc"),
      mesh: document.getElementById("mesh"),
      active: document.getElementById("active"),
      vert: document.getElementById("vert"),
      count: document.getElementById("count"),
      snapBody: document.getElementById("snapBody")
    };

    let scene;
    let boxes = [];
    let boxCount = 0;
    let snapNo = 0;

    // Instrumentation(가능하면 사용)
    let engineInstr = null;
    function tryEnableInstrumentation() {
      try {
        engineInstr = new BABYLON.EngineInstrumentation(engine);
        engineInstr.captureGPUFrameTime = false; // 초보 단계에서는 off(환경 의존)
      } catch (e) {
        engineInstr = null;
      }
    }

    function createScene() {
      const s = new BABYLON.Scene(engine);
      s.clearColor = new BABYLON.Color4(0.06, 0.07, 0.09, 1.0);

      const camera = new BABYLON.ArcRotateCamera(
        "camera",
        Math.PI / 2,
        Math.PI / 2.4,
        18,
        new BABYLON.Vector3(0, 2, 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;

      return s;
    }

    function addBoxes(n) {
      // 박스가 늘어날수록 draw calls/active meshes가 늘어나는 것을 관찰하기 위한 단순 부하
      // (실무에서는 instancing/thin instances로 개선할 수 있지만, 지금은 측정이 목표)
      const s = scene;
      const baseMat = new BABYLON.StandardMaterial("baseMat", s);
      baseMat.diffuseColor = new BABYLON.Color3(0.75, 0.75, 0.85);

      for (let i = 0; i < n; i++) {
        const b = BABYLON.MeshBuilder.CreateBox("box_" + (boxCount + i), { size: 0.7 }, s);
        b.position.x = (Math.random() - 0.5) * 40;
        b.position.z = (Math.random() - 0.5) * 40;
        b.position.y = 0.35;

        // 일부러 재질을 다양하게 만들어 draw call이 늘어나는 케이스도 관찰할 수 있게 옵션을 남김
        // 초보자는 우선 동일 재질로 시작하는 편이 이해가 쉽습니다.
        b.material = baseMat;

        boxes.push(b);
      }
      boxCount += n;
      ui.count.textContent = String(boxCount);
    }

    function resetBoxes() {
      boxes.forEach(m => m.dispose());
      boxes = [];
      boxCount = 0;
      ui.count.textContent = "0";
    }

    function getDrawCallsSafe() {
      // BabylonJS 버전에 따라 접근 방식이 다를 수 있으므로 안전하게 처리
      if (engineInstr && engineInstr.drawCallsCounter && typeof engineInstr.drawCallsCounter.current === "number") {
        return engineInstr.drawCallsCounter.current;
      }
      // 폴백: 알 수 없음
      return null;
    }

    function updateHUD() {
      const fps = engine.getFps();
      const frameTime = fps > 0 ? (1000 / fps) : 0;

      ui.fps.textContent = fps.toFixed(1);
      ui.ft.textContent = frameTime.toFixed(2);

      const dc = getDrawCallsSafe();
      ui.dc.textContent = (dc === null) ? "(inspector로 확인)" : String(dc);

      ui.mesh.textContent = String(scene.meshes.length);
      ui.active.textContent = String(scene.getActiveMeshes().length);
      ui.vert.textContent = String(scene.getTotalVertices());
    }

    function addSnapshot() {
      snapNo += 1;

      const fps = parseFloat(ui.fps.textContent) || 0;
      const ft = parseFloat(ui.ft.textContent) || 0;
      const dcText = ui.dc.textContent;
      const dc = (dcText === "(inspector로 확인)") ? "N/A" : dcText;

      const tr = document.createElement("tr");
      tr.innerHTML = `
        <td>${snapNo}</td>
        <td>${boxCount}</td>
        <td>${fps.toFixed(1)}</td>
        <td>${ft.toFixed(2)}</td>
        <td>${dc}</td>
        <td>${scene.getActiveMeshes().length}</td>
        <td>${scene.getTotalVertices()}</td>
        <td><span contenteditable="true" style="display:inline-block; min-width:160px; outline:none;">관찰 내용을 적으세요</span></td>
      `;
      ui.snapBody.appendChild(tr);
    }

    // 버튼 바인딩
    document.getElementById("add100").addEventListener("click", () => addBoxes(100));
    document.getElementById("reset").addEventListener("click", () => resetBoxes());
    document.getElementById("snapshot").addEventListener("click", () => addSnapshot());
    document.getElementById("toggleInspector").addEventListener("click", async () => {
      if (!scene) return;
      if (scene.debugLayer.isVisible()) {
        scene.debugLayer.hide();
      } else {
        scene.debugLayer.show({ overlay: true });
      }
    });

    // 시작
    scene = createScene();
    tryEnableInstrumentation();

    // 초기 부하(원하면 0으로 시작해도 됨)
    addBoxes(200);

    engine.runRenderLoop(() => {
      scene.render();
      updateHUD();
    });

    window.addEventListener("resize", () => engine.resize());
  </script>
</body>
</html>

5-3) 실습 진행 방법(초보자용)

  • 1단계: 실행 직후 FPS와 Frame Time을 확인합니다. 이 값이 “기준선”입니다.
  • 2단계: +100 박스를 3~5번 눌러 오브젝트를 늘립니다. 어느 구간부터 FPS가 급격히 떨어지는지 관찰합니다.
  • 3단계: 스냅샷 기록을 각 구간(예: 200/400/600/800개)에서 눌러 표에 남깁니다.
  • 4단계: Inspector를 열어 Draw Calls(드로우콜)과 Mesh/Material 수를 확인합니다. 드로우콜이 함께 증가한다면 “제출량 병목” 가능성이 큽니다.
  • 5단계: 표의 메모 칸에 “언제부터 체감이 나빠졌는지”, “드로우콜이 얼마나 늘었는지”를 기록합니다.

5-4) 병목을 해석하는 간단한 기준

  • 오브젝트 수가 늘수록 FPS가 빠르게 하락하고, 드로우콜도 같이 증가: 드로우콜/CPU 제출 병목 가능성이 큽니다.
  • 드로우콜은 크게 늘지 않는데 FPS가 떨어짐: 폴리곤(Vertices)/셰이더/후처리/해상도(픽셀 비용) 문제일 수 있습니다.
  • Active Meshes가 비정상적으로 많음: 카메라 밖 오브젝트까지 계속 처리 중인지(컬링/레이어/활성화) 점검합니다.


6) 산출물: 성능 측정 스냅샷 템플릿

“성능 측정 스냅샷”은 최적화를 시작하기 전에 반드시 남겨두면 좋은 기록입니다. 다음 회차에서 최적화를 하더라도, 스냅샷이 있으면 “정말 좋아졌는지”를 숫자로 증명할 수 있습니다.

6-1) 스냅샷에 꼭 포함할 항목

항목 의미 추천 기록 방식 비고
장면 조건 비교의 기준 오브젝트 수/해상도/카메라 위치 조건이 바뀌면 비교가 무의미해집니다
FPS / Frame Time 체감 속도 HUD 숫자, Inspector 수치 가능하면 평균값(몇 초 관찰 후) 권장
Draw Calls 렌더 제출량 Inspector/Instrumentation 버전/환경에 따라 표기 위치가 다를 수 있음
Active Meshes / Vertices 지오메트리 규모 HUD/콘솔 기록 폴리곤 비용 추정에 도움
관찰 메모 정성 기록 “어느 버튼부터 버벅임” 등 최적화 우선순위 판단에 도움

6-2) 팀/개인 레포에 남길 최소 문서 형태(예시)

# Performance Snapshot

* Device: (예: Desktop / Laptop / Mobile)
* Browser: (예: Chrome 버전)
* Resolution: (예: 1920x1080)
* Scene: (예: box-stress-test)
* Notes: (예: Inspector on/off 여부)

| Boxes | FPS | FrameTime(ms) | DrawCalls | ActiveMeshes | Vertices | Memo            |         |
| ----: | --: | ------------: | --------: | -----------: | -------: | --------------- | ------- |
|   200 |  60 |          16.7 |       N/A |          210 |   123456 | 기준선             |         |
|   400 |  48 |          20.8 |       N/A |          410 |   246912 | 마우스 회전 시 약간 둔해짐 |  |


7) 추가로 생각해볼 점

  • 측정 후에야 최적화가 의미가 있습니다: “드로우콜이 문제인지”도 모르고 머지를 하거나 텍스처를 줄이면, 노력 대비 효과가 작을 수 있습니다.
  • Inspector는 개발 중 상시 켜두기보다 필요할 때 켜는 편이 좋습니다: 디버그 레이어는 자체 비용이 있을 수 있으므로, 측정 시에는 Inspector on/off를 구분해 기록하는 습관이 좋습니다.
  • 동일 조건 비교가 중요합니다: 카메라 위치, 화면 해상도, 오브젝트 수가 바뀌면 수치가 크게 바뀝니다. 스냅샷에는 조건을 반드시 적어두세요.
  • 다음 단계의 최적화 후보: 인스턴싱(Instances/Thin Instances), 메시 머지, 머티리얼 공유, 컬링/LOD, 해상도 스케일링(엔진 하드웨어 스케일) 등이 있습니다. 다만 그 전에 “병목의 종류”부터 확정하는 것이 우선입니다.

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

Reactions

댓글 쓰기

0 댓글