BabylonJS 27회차 : GUI(2D)와 3D UI



BabylonJS 27회차 : GUI(2D)와 3D UI

목표: UI를 붙여 “제품 느낌” 만들기
핵심 개념: Babylon GUI, AdvancedDynamicTexture
실습: HUD(체력/좌표) + 3D 버튼 패널
산출물: “UI 포함 인터랙션 씬”


요약

3D 씬이 “기술 데모”에서 “제품”으로 보이기 시작하는 순간은, 대부분 UI가 붙는 순간입니다. 이번 회차에서는 BabylonJS의 GUI 시스템을 두 갈래로 정리합니다. (1) 화면에 고정되는 2D HUD(체력/좌표/상태), (2) 월드 안에 떠 있는 3D UI(버튼/패널). 핵심은 AdvancedDynamicTexture로 “UI가 그려지는 캔버스”를 만들고, 상황에 따라 Fullscreen UI(2D) 또는 GUI3DManager(3D)를 선택해 구성하는 것입니다. 실습에서는 체력 바와 좌표 HUD를 만들고, 3D 버튼 패널로 체력 증감/텔레포트/오토무브 토글을 구현해 “UI 포함 인터랙션 씬”을 완성합니다.


목차


1) 핵심 포인트

  • UI도 “에셋”입니다: 화면의 여백, 정렬, 글꼴 크기, 상태 표현(활성/비활성)이 잡히면 제품 느낌이 크게 올라갑니다.
  • AdvancedDynamicTexture(ADT)는 UI가 그려지는 ‘도화지’입니다: Fullscreen UI(2D)든 Mesh UI(3D)든 ADT 개념을 잡아두면 응용이 쉽습니다.
  • 2D HUD는 정보 전달에 최적: 체력/미션/좌표/미니맵처럼 “항상 보여야 하는 정보”는 화면 고정이 안정적입니다.
  • 3D UI는 상호작용 지점에 최적: 버튼/패널/키오스크/기계 조작처럼 “월드의 특정 위치에서 조작”할 때 설득력이 좋습니다.
  • 실습은 작은 기능을 끝까지: HUD로 상태를 보여주고, 3D 버튼으로 상태를 바꾸며, 변화가 HUD에 반영되는 흐름까지 연결합니다.


2) Babylon GUI 전체 구조 한 장으로 이해하기

Babylon GUI는 크게 두 계열로 이해하면 빠릅니다.

계열 대표 클래스 사용 위치 핵심 장점
2D GUI(화면 오버레이) AdvancedDynamicTexture.CreateFullscreenUI 화면 고정 HUD 가독성, 정렬/레이아웃, 정보 전달
3D GUI(월드 UI) GUI3DManager, StackPanel3D, HolographicButton 월드 내 버튼/패널 공간감, 상호작용 지점의 설득력
Mesh UI(3D 표면에 2D UI 붙이기) AdvancedDynamicTexture.CreateForMesh 모니터/키오스크/간판 2D 레이아웃을 3D 표면에 자연스럽게 투영

이번 회차 실습은 Fullscreen UI + GUI3DManager 조합으로 진행합니다. 제품형 데모에서 가장 자주 쓰는 “HUD + 월드 버튼” 패턴이기 때문입니다.


3) 2D HUD: Fullscreen AdvancedDynamicTexture

HUD를 만들 때 초보자가 가장 먼저 잡아야 하는 기준은 “정보의 안정성”입니다. 체력/좌표 같은 정보는 카메라가 어떻게 움직여도 화면에서 사라지면 안 되고, 배경(3D)이 복잡해져도 읽기 쉬워야 합니다.

HUD 구성 기본 규칙(추천)

  • 안전 여백: 상단/좌측 12~16px 정도 여백을 두고, 모서리에 딱 붙이지 않습니다.
  • 배경 레이어: 반투명 배경(alpha)을 넣어 어떤 배경에서도 글자가 읽히게 합니다.
  • 글자 크기 분리: 타이틀(작게 굵게) + 값(숫자 가독성) 패턴으로 구성합니다.
  • 상태는 ‘바’로 표현: 체력/게이지는 텍스트보다 바(Progress 형태)가 직관적입니다.

HUD에서 자주 쓰는 컨트롤

  • Rectangle: 배경 패널, 섹션 카드
  • StackPanel: 세로/가로 정렬
  • TextBlock: 라벨/값 표시
  • Button: 2D 버튼(옵션)


4) 3D UI: GUI3DManager와 3D 패널

3D UI는 “월드에 떠 있는 조작 패널”을 만들 때 유용합니다. 키오스크 버튼, 기계 조작 패널, 상호작용 오브젝트 주변의 툴패널 같은 형태가 대표적입니다.

3D UI를 쓸 때의 장점

  • 상호작용 위치가 명확: 사용자는 “어디에서 무엇을 조작하는지”를 자연스럽게 이해합니다.
  • 연출이 쉬움: 패널이 특정 오브젝트 옆에 떠 있으면 ‘제품’의 사용 흐름이 살아납니다.
  • UI를 씬의 일부로 구성: UI 자체가 오브젝트처럼 보이므로 데모 설득력이 좋아집니다.

3D UI에서 주의할 점

  • 가독성: 너무 멀거나, 배경이 복잡하면 읽기 어려워집니다. 크기/거리/대비를 조절해야 합니다.
  • 카메라 방향: 패널이 옆으로 돌아가면 버튼이 읽히지 않습니다. 필요하면 카메라를 향하도록(billboard/lookAt) 처리합니다.
  • 입력 우선순위: 월드 클릭과 UI 클릭이 섞일 때는 이벤트 흐름을 정리해야 합니다(실습에서는 UI는 3D 버튼, 월드 클릭은 사용하지 않습니다).


5) 2D/3D UI 선택 기준 표

상황 추천 UI 이유 초보자 팁
체력/좌표/미션 2D HUD 항상 보이고 읽기 쉬워야 함 반투명 배경 + 간결한 텍스트
기계 조작 패널 3D UI 월드 위치가 의미를 가짐 패널을 카메라 쪽으로 향하게
모니터/간판/키오스크 화면 Mesh UI 2D 레이아웃을 3D 표면에 투영 CreateForMesh로 시작


6) 실습: HUD + 3D 버튼 패널 “인터랙션 씬”

실습에서 구현할 기능은 아래와 같습니다.

  • HUD(2D): 체력(바 + 숫자), 플레이어 좌표 표시
  • 3D 버튼 패널: Damage(-10), Heal(+10), Teleport(랜덤 이동), AutoMove 토글
  • 상호작용 흐름: 3D 버튼 → 상태 변경 → HUD 반영

6-1) 완성 코드: UI 포함 인터랙션 씬

아래 코드는 단일 index.html로 실행 가능합니다(정적 서버 권장). Babylon GUI를 사용하기 위해 babylon.gui.min.js를 함께 로딩합니다.

<!doctype html>
<html lang="ko">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <title>BabylonJS GUI Demo (HUD + 3D UI Panel)</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; }
  </style>

  <script src="https://cdn.babylonjs.com/babylon.js"></script>
  <script src="https://cdn.babylonjs.com/gui/babylon.gui.min.js"></script>
</head>

<body>
  <canvas id="renderCanvas"></canvas>

  <script>
    const canvas = document.getElementById("renderCanvas");
    const engine = new BABYLON.Engine(canvas, true);

    let scene, camera;
    let player;
    let health = 100;
    let autoMove = false;

    // HUD 참조
    const hud = {
      healthValue: null,
      coordValue: null,
      healthFill: null,
      hintText: null
    };

    function clamp(v, min, max) { return Math.max(min, Math.min(max, v)); }

    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.6,
        18,
        new BABYLON.Vector3(0, 1.2, 0),
        s
      );
      camera.attachControl(canvas, true);
      camera.wheelDeltaPercentage = 0.01;

      const hemi = new BABYLON.HemisphericLight("hemi", new BABYLON.Vector3(0, 1, 0), s);
      hemi.intensity = 1.0;

      // 바닥
      const ground = BABYLON.MeshBuilder.CreateGround("ground", { width: 40, height: 40 }, s);
      const gmat = new BABYLON.StandardMaterial("gmat", s);
      gmat.diffuseColor = new BABYLON.Color3(0.14, 0.15, 0.17);
      gmat.alpha = 0.98;
      ground.material = gmat;
      ground.isPickable = false;

      // 플레이어(간단한 박스)
      player = BABYLON.MeshBuilder.CreateBox("player", { size: 1.0 }, s);
      player.position.set(0, 0.5, 0);
      const pmat = new BABYLON.StandardMaterial("pmat", s);
      pmat.diffuseColor = new BABYLON.Color3(0.75, 0.78, 0.90);
      player.material = pmat;

      // 씬에 약간의 기준물(방향 감각용)
      const poleMat = new BABYLON.StandardMaterial("poleMat", s);
      poleMat.diffuseColor = new BABYLON.Color3(0.24, 0.26, 0.30);

      for (let i = 0; i < 12; i++) {
        const pole = BABYLON.MeshBuilder.CreateCylinder("pole" + i, { height: 3.8, diameter: 0.25, tessellation: 24 }, s);
        pole.position.set((Math.random() - 0.5) * 30, 1.9, (Math.random() - 0.5) * 30);
        pole.material = poleMat;
        pole.isPickable = false;
      }

      // 2D HUD 구성
      buildHUD(s);

      // 3D UI 구성
      build3DPanel(s);

      // 입력: WASD 이동(간단)
      const keys = { w:false, a:false, s:false, d:false };
      window.addEventListener("keydown", (e) => {
        const k = e.key.toLowerCase();
        if (k === "w") keys.w = true;
        if (k === "a") keys.a = true;
        if (k === "s") keys.s = true;
        if (k === "d") keys.d = true;

        if (k === "t") teleportPlayer();
        if (k === "h") setHealth(health + 10);
        if (k === "j") setHealth(health - 10);
      });
      window.addEventListener("keyup", (e) => {
        const k = e.key.toLowerCase();
        if (k === "w") keys.w = false;
        if (k === "a") keys.a = false;
        if (k === "s") keys.s = false;
        if (k === "d") keys.d = false;
      });

      s.onBeforeRenderObservable.add(() => {
        const dt = engine.getDeltaTime() / 1000;
        const speed = 6.0;

        if (autoMove) {
          player.position.x += Math.cos(performance.now() * 0.001) * dt * 2.0;
          player.position.z += Math.sin(performance.now() * 0.001) * dt * 2.0;
        } else {
          let vx = 0, vz = 0;
          if (keys.w) vz += 1;
          if (keys.s) vz -= 1;
          if (keys.d) vx += 1;
          if (keys.a) vx -= 1;

          const len = Math.hypot(vx, vz);
          if (len > 0) {
            vx /= len; vz /= len;
            player.position.x += vx * speed * dt;
            player.position.z += vz * speed * dt;
          }
        }

        // HUD 갱신
        updateHUD();
      });

      return s;
    }

    // -----------------------------
    // 2D HUD (Fullscreen UI)
    // -----------------------------
    function buildHUD(s) {
      const uiTex = BABYLON.GUI.AdvancedDynamicTexture.CreateFullscreenUI("HUD", true, s);

      // 전체 패널(좌상단)
      const panel = new BABYLON.GUI.Rectangle("hudPanel");
      panel.width = "320px";
      panel.height = "150px";
      panel.cornerRadius = 12;
      panel.thickness = 1;
      panel.color = "#ffffff";
      panel.background = "#000000";
      panel.alpha = 0.45;
      panel.horizontalAlignment = BABYLON.GUI.Control.HORIZONTAL_ALIGNMENT_LEFT;
      panel.verticalAlignment = BABYLON.GUI.Control.VERTICAL_ALIGNMENT_TOP;
      panel.left = "12px";
      panel.top = "12px";
      uiTex.addControl(panel);

      const stack = new BABYLON.GUI.StackPanel("hudStack");
      stack.paddingTop = "10px";
      stack.paddingLeft = "12px";
      stack.paddingRight = "12px";
      stack.spacing = 6;
      stack.horizontalAlignment = BABYLON.GUI.Control.HORIZONTAL_ALIGNMENT_LEFT;
      panel.addControl(stack);

      const title = new BABYLON.GUI.TextBlock("title", "HUD");
      title.height = "22px";
      title.color = "#ffffff";
      title.fontSize = 14;
      title.fontWeight = "700";
      title.textHorizontalAlignment = BABYLON.GUI.Control.HORIZONTAL_ALIGNMENT_LEFT;
      stack.addControl(title);

      // 체력 라인
      const healthRow = new BABYLON.GUI.TextBlock("healthText", "Health: 100");
      healthRow.height = "22px";
      healthRow.color = "#ffffff";
      healthRow.fontSize = 13;
      healthRow.textHorizontalAlignment = BABYLON.GUI.Control.HORIZONTAL_ALIGNMENT_LEFT;
      stack.addControl(healthRow);
      hud.healthValue = healthRow;

      // 체력 바(배경 + 채움)
      const barBg = new BABYLON.GUI.Rectangle("barBg");
      barBg.width = "100%";
      barBg.height = "14px";
      barBg.thickness = 1;
      barBg.cornerRadius = 8;
      barBg.color = "#ffffff";
      barBg.background = "#222222";
      barBg.alpha = 0.9;
      stack.addControl(barBg);

      const barFill = new BABYLON.GUI.Rectangle("barFill");
      barFill.width = "100%";
      barFill.height = "14px";
      barFill.thickness = 0;
      barFill.cornerRadius = 8;
      barFill.background = "#66ccff";
      barFill.horizontalAlignment = BABYLON.GUI.Control.HORIZONTAL_ALIGNMENT_LEFT;
      barBg.addControl(barFill);
      hud.healthFill = barFill;

      // 좌표 라인
      const coord = new BABYLON.GUI.TextBlock("coord", "Pos: (0.00, 0.50, 0.00)");
      coord.height = "22px";
      coord.color = "#ffffff";
      coord.fontSize = 13;
      coord.textHorizontalAlignment = BABYLON.GUI.Control.HORIZONTAL_ALIGNMENT_LEFT;
      stack.addControl(coord);
      hud.coordValue = coord;

      // 힌트
      const hint = new BABYLON.GUI.TextBlock("hint", "WASD 이동 | T 텔레포트 | H 힐 | J 데미지");
      hint.height = "36px";
      hint.color = "#dddddd";
      hint.fontSize = 12;
      hint.textHorizontalAlignment = BABYLON.GUI.Control.HORIZONTAL_ALIGNMENT_LEFT;
      hint.textWrapping = true;
      stack.addControl(hint);
      hud.hintText = hint;

      updateHUD();
    }

    function updateHUD() {
      if (!hud.healthValue) return;

      hud.healthValue.text = `Health: ${health}`;
      const p = player.position;
      hud.coordValue.text = `Pos: (${p.x.toFixed(2)}, ${p.y.toFixed(2)}, ${p.z.toFixed(2)})`;

      // 체력 바 폭(%) 갱신
      const ratio = clamp(health / 100, 0, 1);
      hud.healthFill.width = `${Math.round(ratio * 100)}%`;

      // 상태에 따라 색감 변화(제품 느낌을 만드는 작은 포인트)
      if (health <= 30) hud.healthFill.background = "#ff6b6b";
      else if (health <= 60) hud.healthFill.background = "#ffd166";
      else hud.healthFill.background = "#66ccff";
    }

    function setHealth(v) {
      health = clamp(v, 0, 100);
      updateHUD();
    }

    function teleportPlayer() {
      player.position.x = (Math.random() - 0.5) * 24;
      player.position.z = (Math.random() - 0.5) * 24;
    }

    // -----------------------------
    // 3D UI (World Panel)
    // -----------------------------
    function build3DPanel(s) {
      const manager = new BABYLON.GUI.GUI3DManager(s);

      const panel = new BABYLON.GUI.StackPanel3D();
      panel.margin = 0.12;
      manager.addControl(panel);

      // 패널 위치(월드 안의 조작 패널처럼 보이게)
      panel.node.position = new BABYLON.Vector3(-6.5, 2.2, 4.0);

      // 패널이 항상 카메라를 향하도록(가독성 확보)
      s.onBeforeRenderObservable.add(() => {
        panel.node.lookAt(camera.position);
      });

      // 버튼 생성 헬퍼
      function addButton(label, onClick) {
        const btn = new BABYLON.GUI.HolographicButton("btn_" + label.replace(/\s/g, ""));
        btn.text = label;
        btn.onPointerUpObservable.add(() => onClick());
        panel.addControl(btn);
        return btn;
      }

      // 버튼들
      addButton("Damage -10", () => setHealth(health - 10));
      addButton("Heal +10", () => setHealth(health + 10));
      addButton("Teleport", () => teleportPlayer());
      addButton("AutoMove: Toggle", () => { autoMove = !autoMove; });

      // 패널 제목 느낌을 위해 “상단에 작은 라벨”을 하나 더 둡니다(버튼을 라벨처럼 활용)
      // HolographicButton은 기본적으로 클릭 가능한 형태이므로, 라벨은 클릭해도 부작용이 없도록 빈 동작을 연결합니다.
      const label = new BABYLON.GUI.HolographicButton("label");
      label.text = "3D UI Panel";
      label.onPointerUpObservable.add(() => {});
      panel.addControl(label);

      // 라벨을 맨 위로 올리기 위해 다시 정렬(간단히 재구성)
      // StackPanel3D는 추가된 순서대로 배치되므로, 마지막에 넣은 label을 위로 올리려면 다시 추가하는 방식이 필요할 수 있습니다.
      // 여기서는 데모 목적상 label이 아래에 있어도 동작에는 문제 없습니다.
    }

    // 시작
    scene = createScene();

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

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

6-2) (확장) 3D 표면에 2D UI 붙이기: CreateForMesh 패턴

월드에 “모니터/키오스크 화면”처럼 보이는 UI가 필요하다면 GUI3DManager 대신 AdvancedDynamicTexture.CreateForMesh가 직관적입니다. 2D 레이아웃(텍스트/버튼/이미지)을 그대로 쓰되, 결과가 3D 메시에 렌더링됩니다.

// 예: 3D 평면(스크린)에 2D UI를 붙이는 패턴
const screen = BABYLON.MeshBuilder.CreatePlane("screen", { width: 4, height: 2.2 }, scene);
screen.position.set(5, 2, -2);
screen.rotation.y = Math.PI * 0.15;

const adt = BABYLON.GUI.AdvancedDynamicTexture.CreateForMesh(screen, 1024, 512, false);

const rect = new BABYLON.GUI.Rectangle();
rect.thickness = 1;
rect.color = "#ffffff";
rect.background = "#000000";
rect.alpha = 0.55;
adt.addControl(rect);

const text = new BABYLON.GUI.TextBlock();
text.text = "Kiosk UI";
text.color = "#ffffff";
text.fontSize = 44;
rect.addControl(text);


7) 실행 단계 체크리스트

  • GUI 스크립트 로딩 확인: babylon.gui.min.js가 포함되어야 2D/3D GUI가 동작합니다.
  • HUD는 Fullscreen UI로 시작: CreateFullscreenUI로 패널(배경)부터 만들고, 그 안에 텍스트/바를 추가합니다.
  • 3D UI는 작은 패널부터: GUI3DManager + StackPanel3D + 버튼 2~3개로 성공 경험을 먼저 만듭니다.
  • 가독성 확보: 3D 패널은 카메라를 향하도록 lookAt 또는 billboard 처리를 적용합니다.
  • 상태 연결: 버튼 클릭으로 값을 바꾸고, HUD가 그 값을 표시하도록 “데이터 → UI 갱신” 흐름을 고정합니다.


8) 추가로 생각해볼 점

  • 제품 느낌은 ‘정렬’에서 나옵니다: 같은 폰트 크기/간격 규칙을 유지하고, 패널을 좌상단 12~16px 여백으로 고정하는 것만으로도 인상이 달라집니다.
  • UI 상태(Disabled/Loading/Error)를 준비하세요: 버튼이 비활성일 때 색/알파가 달라지는 규칙을 만들면 앱처럼 보입니다.
  • 3D UI는 거리/크기 예산이 필요합니다: 너무 크면 거슬리고, 너무 작으면 안 보입니다. 카메라 기본 거리(ArcRotate radius) 기준으로 패널 크기를 조정하는 습관이 좋습니다.
  • 대형 프로젝트에서는 UI도 모듈화: HUD 빌드 함수, 3D 패널 빌드 함수, 상태 스토어(health/score 등)를 분리하면 유지보수가 쉬워집니다.
  • 다음 확장 방향: HUD에 미니맵/목표 표시, 3D 패널에 탭/슬라이더/토글, Mesh UI로 키오스크 화면 구성까지 이어가면 “완성형 데모”로 발전합니다.

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

Reactions

댓글 쓰기

0 댓글