BabylonJS 26회차 : PostProcess(블룸/DOF 등)



BabylonJS 26회차 : PostProcess(블룸/DOF 등)


요약

PostProcess(포스트 프로세스)는 “3D를 다 그린 뒤, 화면(프레임) 전체에 필터를 적용하는 단계”입니다. 모델/조명/재질이 어느 정도 갖춰졌는데도 화면이 밋밋하게 느껴질 때, Bloom(블룸), 색보정(Image Processing), Vignette(비네트), Depth of Field(심도) 같은 처리가 ‘완성도’를 올려주는 경우가 많습니다. 다만 포스트 프로세스는 잘못 쓰면 흐릿해지거나 과장되어 보일 수 있고, 성능 비용도 발생합니다. 이번 글에서는 BabylonJS의 DefaultRenderingPipeline로 Bloom을 가장 빠르게 적용하고, ON/OFF 비교를 통해 “언제 효과가 좋은지/언제 과한지”를 판단하는 기준을 만듭니다.


목차


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

  • PostProcess는 “마지막 화면 보정”입니다: 조명/재질이 완벽하지 않아도, 화면의 ‘톤’과 ‘완성도’를 끌어올릴 수 있습니다.
  • DefaultRenderingPipeline로 시작하면 가장 안전합니다: Bloom, 색보정, FXAA 같은 기능을 한 파이프라인에서 제어할 수 있습니다.
  • Bloom은 ‘밝은 부분이 번지는 효과’입니다: 과하면 흐릿하고 싸구려처럼 보일 수 있으므로 ON/OFF 비교가 매우 중요합니다.
  • 튜닝 순서 추천: (1) BloomThreshold로 과다 번짐 방지 → (2) BloomWeight/Kernel로 강도 조절 → (3) Exposure/Contrast로 톤 정리
  • 성능은 파이프라인 비용입니다: BloomKernel(블러 크기), 해상도 스케일, MSAA/FXAA 조합을 기준으로 “내 프로젝트의 예산”을 잡아야 합니다.


2) PostProcess는 무엇이고, 왜 필요한가

3D 렌더링은 보통 다음 순서로 진행됩니다.

  • 메시/카메라/라이트/머티리얼로 3D 씬을 렌더링합니다.
  • 렌더 결과(한 장의 화면)에 후처리 필터를 적용합니다.
  • 최종 화면을 사용자에게 보여줍니다.

Bloom은 이 “후처리 단계”의 대표 사례입니다. 현실의 카메라/눈은 아주 밝은 빛을 보면 주변으로 번짐이 생기는데, 3D는 계산상 선명하게 그려지기 때문에 오히려 ‘차갑고 플라스틱 같은’ 느낌이 날 때가 있습니다. Bloom을 적절히 넣으면 네온/조명/하이라이트가 부드럽게 살아나면서 시네마틱한 인상이 강해집니다.


3) DefaultRenderingPipeline 한 번에 이해하기

BabylonJS의 DefaultRenderingPipeline은 “자주 쓰는 후처리를 묶어둔 패키지”로 이해하시면 됩니다. 초보자 입장에서는 아래처럼 생각하는 것이 가장 실용적입니다.

  • 한 번 생성: 파이프라인을 만들고 카메라에 연결합니다.
  • 옵션 on/off: bloomEnabled, imageProcessingEnabled, depthOfFieldEnabled 등으로 기능을 켜고 끕니다.
  • 파라미터 튜닝: bloomThreshold, bloomWeight, exposure, contrast 같은 값으로 “느낌”을 조절합니다.
기능 역할 켜야 하는 상황 주의점
Bloom 밝은 부분 번짐 네온/조명/하이라이트 강조 과하면 흐릿해짐, 텍스트/라인이 뭉개질 수 있음
Image Processing 노출/대비/톤매핑 전체 색감/명암을 정리하고 싶을 때 노출을 올리면 Bloom이 같이 과해질 수 있음
DOF(Depth of Field) 심도(초점 밖 흐림) 시네마틱/제품샷 느낌 성능 비용, 초점 거리 튜닝 필요
FXAA 후처리 AA(계단 완화) 라인 계단이 거슬릴 때 살짝 흐려 보일 수 있음(선명함 우선이면 주의)


4) Bloom(블룸) 파라미터: 무엇을 건드려야 하나

Bloom은 “밝은 픽셀을 뽑아서 블러 처리 후 다시 합성”하는 방식으로 동작합니다. 그래서 핵심은 두 가지입니다. (1) 무엇을 ‘밝다’고 판단할지(Threshold), (2) 얼마나 크게/강하게 번지게 할지(Kernel/Weight)입니다.

4-1) 추천 튜닝 순서

  • BloomThreshold(임계값)부터: 번짐 대상이 너무 많으면 화면 전체가 뿌옇게 됩니다. 먼저 “번질 픽셀을 제한”하세요.
  • BloomWeight(가중치)로 강도 조절: 번짐의 ‘존재감’을 조절합니다.
  • BloomKernel(블러 크기)로 부드러움 조절: 크면 더 넓게 번지지만 비용도 올라갑니다.

4-2) 실무 감각용 값 가이드

항목 역할 시작값(권장) 증상/조치
bloomThreshold 얼마나 밝아야 번질지 0.75~0.90 화면이 뿌옇다 → threshold를 올려 번짐 대상을 줄입니다.
bloomWeight 번짐 강도 0.25~0.60 효과가 약하다 → weight를 올립니다(올리기 전 threshold부터 확인).
bloomKernel 번짐 퍼짐(블러 크기) 32~96 너무 ‘번져 보임’ → kernel을 낮춥니다. 성능이 나쁘면 kernel부터 줄입니다.
bloomScale Bloom 처리 해상도 스케일 0.5~1.0 성능이 부담 → scale을 낮추고, 과한 흐림은 threshold/weight로 보정합니다.


5) DOF/색보정/비네트: ‘시네마틱’ 느낌 만들기

이번 회차의 핵심은 Bloom이지만, “시네마틱 씬”을 만들기 위해 자주 같이 쓰는 요소들을 간단히 정리해두면 이후 확장이 쉬워집니다.

  • Image Processing(노출/대비/톤매핑): 화면이 회색/밋밋하면 exposurecontrast를 조절해 톤을 잡습니다. 톤매핑(예: ACES)이 가능하면 하이라이트가 더 자연스럽게 정리되는 경우가 많습니다.
  • Vignette(비네트): 화면 가장자리를 살짝 어둡게 해서 시선을 중앙으로 모읍니다. 과하면 답답해 보이므로 약하게 쓰는 편이 안전합니다.
  • DOF(심도): 특정 대상에만 초점을 주고 주변을 흐리게 해서 “카메라 렌즈 느낌”을 냅니다. 초보자 단계에서는 DOF를 과감히 키기보다, 씬이 완성된 뒤 필요한 컷에서만 적용하는 전략이 안정적입니다.


6) 실습: Bloom ON/OFF 비교 데모(시네마틱 씬)

이번 데모는 다음 조건을 만족합니다.

  • DefaultRenderingPipeline을 생성하고 카메라에 연결합니다.
  • Bloom ON/OFF 버튼으로 즉시 비교합니다.
  • 시네마틱 분위기를 위해 emissive(발광) 오브젝트와 톤(노출/대비)을 함께 구성합니다.
  • 초보자도 바로 실행 가능한 단일 index.html 형태로 제공합니다.

6-1) 완성 코드: Bloom ON/OFF 비교 + 시네마틱 씬

아래 코드는 그대로 index.html로 저장해 실행할 수 있습니다(정적 서버 권장). Bloom을 켰을 때 네온/발광 느낌이 어떻게 달라지는지 확인하고, threshold/weight/kernel을 조절해 “과하지 않은 선”을 찾아보세요.

<!doctype html>
<html lang="ko">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <title>BabylonJS PostProcess Demo (Default Pipeline + Bloom ON/OFF)</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(560px, 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, input[type="range"] { cursor:pointer; }
    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;
    }
    button:hover { background: rgba(255,255,255,0.16); }

    .sliderRow { display:flex; align-items:center; gap:10px; margin-top:8px; }
    .sliderRow label { width:130px; color: rgba(255,255,255,0.75); font-size:12px; }
    .sliderRow input { flex:1; }
    .sliderRow span { width:72px; text-align:right; font-variant-numeric: tabular-nums; font-size:12px; }

    #hint { margin-top:8px; color: rgba(255,255,255,0.72); font-size:12px; line-height:1.45; }
    #hint code { background: rgba(255,255,255,0.12); padding: 1px 6px; border-radius: 6px; }
  </style>

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

<body>
  <div id="hud">
    <p id="hudTitle">PostProcess: DefaultRenderingPipeline + Bloom ON/OFF 비교</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">Bloom</span><span class="v" id="bloomState">ON</span></div>
      <div class="row"><span class="k">Tip</span><span class="v">threshold → weight → kernel 순으로 조정</span></div>
    </div>

    <div id="btns">
      <button id="toggleBloom">Bloom ON/OFF</button>
      <button id="toggleVignette">Vignette ON/OFF</button>
      <button id="reset">추천값으로 리셋</button>
    </div>

    <div class="sliderRow">
      <label for="thr">Bloom Threshold</label>
      <input id="thr" type="range" min="0" max="1" step="0.01">
      <span id="thrVal">-</span>
    </div>

    <div class="sliderRow">
      <label for="wgt">Bloom Weight</label>
      <input id="wgt" type="range" min="0" max="1" step="0.01">
      <span id="wgtVal">-</span>
    </div>

    <div class="sliderRow">
      <label for="ker">Bloom Kernel</label>
      <input id="ker" type="range" min="1" max="256" step="1">
      <span id="kerVal">-</span>
    </div>

    <div class="sliderRow">
      <label for="exp">Exposure</label>
      <input id="exp" type="range" min="0.4" max="2.0" step="0.01">
      <span id="expVal">-</span>
    </div>

    <div class="sliderRow">
      <label for="con">Contrast</label>
      <input id="con" type="range" min="0.6" max="2.0" step="0.01">
      <span id="conVal">-</span>
    </div>

    <div id="hint">
      <div>관찰 포인트: Bloom ON일 때 네온이 살아나지만, <code>threshold</code>가 낮으면 바닥/벽까지 뿌옇게 번질 수 있습니다.</div>
      <div>성능이 부담이면 <code>kernel</code>을 먼저 낮추고, 이후 <code>weight</code>로 느낌을 맞추세요.</div>
    </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"),
      bloomState: document.getElementById("bloomState"),
      thr: document.getElementById("thr"),
      wgt: document.getElementById("wgt"),
      ker: document.getElementById("ker"),
      exp: document.getElementById("exp"),
      con: document.getElementById("con"),
      thrVal: document.getElementById("thrVal"),
      wgtVal: document.getElementById("wgtVal"),
      kerVal: document.getElementById("kerVal"),
      expVal: document.getElementById("expVal"),
      conVal: document.getElementById("conVal")
    };

    let scene, camera, pipeline;

    const PRESET = {
      bloom: { threshold: 0.82, weight: 0.45, kernel: 64, scale: 0.6 },
      tone: { exposure: 1.15, contrast: 1.20 },
      vignette: { enabled: true, weight: 1.6 }
    };

    function createScene() {
      const s = new BABYLON.Scene(engine);
      s.clearColor = new BABYLON.Color4(0.02, 0.02, 0.03, 1.0);

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

      const dir = new BABYLON.DirectionalLight("dir", new BABYLON.Vector3(-0.5, -1, -0.3), s);
      dir.intensity = 0.6;

      // 바닥/벽: 어두운 톤으로 시네마틱 분위기 만들기
      const ground = BABYLON.MeshBuilder.CreateGround("ground", { width: 40, height: 40 }, s);
      const gmat = new BABYLON.StandardMaterial("gmat", s);
      gmat.diffuseColor = new BABYLON.Color3(0.08, 0.085, 0.10);
      gmat.specularColor = new BABYLON.Color3(0.05, 0.05, 0.05);
      ground.material = gmat;

      // 간단한 벽(배경 레이어)
      const wall = BABYLON.MeshBuilder.CreateBox("wall", { width: 40, height: 10, depth: 0.8 }, s);
      wall.position.set(0, 5, -18);
      const wmat = new BABYLON.StandardMaterial("wmat", s);
      wmat.diffuseColor = new BABYLON.Color3(0.06, 0.065, 0.075);
      wmat.specularColor = new BABYLON.Color3(0.02, 0.02, 0.02);
      wall.material = wmat;

      // 네온/발광 오브젝트: Bloom이 잘 보이도록 emissive를 적극 사용
      const neonMatA = new BABYLON.StandardMaterial("neonA", s);
      neonMatA.diffuseColor = new BABYLON.Color3(0.02, 0.02, 0.02);
      neonMatA.emissiveColor = new BABYLON.Color3(0.15, 0.55, 1.2); // 푸른 네온
      neonMatA.specularColor = new BABYLON.Color3(0, 0, 0);

      const neonMatB = new BABYLON.StandardMaterial("neonB", s);
      neonMatB.diffuseColor = new BABYLON.Color3(0.02, 0.02, 0.02);
      neonMatB.emissiveColor = new BABYLON.Color3(1.2, 0.35, 0.12); // 주황 네온
      neonMatB.specularColor = new BABYLON.Color3(0, 0, 0);

      // 네온 링
      const ring = BABYLON.MeshBuilder.CreateTorus("ring", { diameter: 4.2, thickness: 0.22, tessellation: 96 }, s);
      ring.position.set(-4.5, 2.6, -6);
      ring.material = neonMatA;

      // 네온 바(간단한 간판 느낌)
      for (let i = 0; i < 7; i++) {
        const bar = BABYLON.MeshBuilder.CreateBox("bar" + i, { width: 2.2, height: 0.18, depth: 0.22 }, s);
        bar.position.set(6.5, 1.8 + i * 0.55, -6.2);
        bar.material = (i % 2 === 0) ? neonMatB : neonMatA;
      }

      // 반사 느낌을 위해 살짝 밝은 오브젝트(금속 느낌은 단순화)
      const propMat = new BABYLON.StandardMaterial("propMat", s);
      propMat.diffuseColor = new BABYLON.Color3(0.16, 0.17, 0.19);
      propMat.specularColor = new BABYLON.Color3(0.25, 0.25, 0.25);

      const pillar = BABYLON.MeshBuilder.CreateCylinder("pillar", { height: 6, diameter: 1.2, tessellation: 48 }, s);
      pillar.position.set(0, 3, -8);
      pillar.material = propMat;

      // 약간의 안개로 깊이감(과하면 뿌옇습니다)
      s.fogMode = BABYLON.Scene.FOGMODE_EXP2;
      s.fogDensity = 0.02;
      s.fogColor = new BABYLON.Color3(0.02, 0.02, 0.03);

      return s;
    }

    function createPipeline() {
      // DefaultRenderingPipeline 생성
      // 버전에 따라 camera 전달 방식이 다를 수 있어 배열로 전달합니다.
      pipeline = new BABYLON.DefaultRenderingPipeline("default", true, scene, [camera]);

      // Bloom 기본 ON
      pipeline.bloomEnabled = true;
      pipeline.bloomThreshold = PRESET.bloom.threshold;
      pipeline.bloomWeight = PRESET.bloom.weight;
      pipeline.bloomKernel = PRESET.bloom.kernel;
      pipeline.bloomScale = PRESET.bloom.scale;

      // Image Processing(노출/대비/톤매핑)
      pipeline.imageProcessingEnabled = true;
      pipeline.imageProcessing.exposure = PRESET.tone.exposure;
      pipeline.imageProcessing.contrast = PRESET.tone.contrast;

      // 톤매핑(가능한 경우에만 적용)
      try {
        pipeline.imageProcessing.toneMappingEnabled = true;
        pipeline.imageProcessing.toneMappingType = BABYLON.ImageProcessingConfiguration.TONEMAPPING_ACES;
      } catch (e) {}

      // Vignette(비네트)
      pipeline.imageProcessing.vignetteEnabled = PRESET.vignette.enabled;
      pipeline.imageProcessing.vignetteWeight = PRESET.vignette.weight;
      pipeline.imageProcessing.vignetteColor = new BABYLON.Color4(0, 0, 0, 1);

      // FXAA(선택): 계단 완화가 필요하면 true
      pipeline.fxaaEnabled = false;

      syncUIFromPipeline();
      refreshBloomLabel();
    }

    function syncUIFromPipeline() {
      ui.thr.value = String(pipeline.bloomThreshold);
      ui.wgt.value = String(pipeline.bloomWeight);
      ui.ker.value = String(pipeline.bloomKernel);
      ui.exp.value = String(pipeline.imageProcessing.exposure);
      ui.con.value = String(pipeline.imageProcessing.contrast);

      ui.thrVal.textContent = Number(pipeline.bloomThreshold).toFixed(2);
      ui.wgtVal.textContent = Number(pipeline.bloomWeight).toFixed(2);
      ui.kerVal.textContent = String(Math.round(pipeline.bloomKernel));
      ui.expVal.textContent = Number(pipeline.imageProcessing.exposure).toFixed(2);
      ui.conVal.textContent = Number(pipeline.imageProcessing.contrast).toFixed(2);
    }

    function refreshBloomLabel() {
      ui.bloomState.textContent = pipeline.bloomEnabled ? "ON" : "OFF";
    }

    function bindUI() {
      document.getElementById("toggleBloom").addEventListener("click", () => {
        pipeline.bloomEnabled = !pipeline.bloomEnabled;
        refreshBloomLabel();
      });

      document.getElementById("toggleVignette").addEventListener("click", () => {
        pipeline.imageProcessing.vignetteEnabled = !pipeline.imageProcessing.vignetteEnabled;
      });

      document.getElementById("reset").addEventListener("click", () => {
        pipeline.bloomThreshold = PRESET.bloom.threshold;
        pipeline.bloomWeight = PRESET.bloom.weight;
        pipeline.bloomKernel = PRESET.bloom.kernel;
        pipeline.bloomScale = PRESET.bloom.scale;
        pipeline.imageProcessing.exposure = PRESET.tone.exposure;
        pipeline.imageProcessing.contrast = PRESET.tone.contrast;
        pipeline.imageProcessing.vignetteEnabled = PRESET.vignette.enabled;
        pipeline.imageProcessing.vignetteWeight = PRESET.vignette.weight;
        syncUIFromPipeline();
        refreshBloomLabel();
      });

      ui.thr.addEventListener("input", () => {
        pipeline.bloomThreshold = Number(ui.thr.value);
        ui.thrVal.textContent = Number(pipeline.bloomThreshold).toFixed(2);
      });

      ui.wgt.addEventListener("input", () => {
        pipeline.bloomWeight = Number(ui.wgt.value);
        ui.wgtVal.textContent = Number(pipeline.bloomWeight).toFixed(2);
      });

      ui.ker.addEventListener("input", () => {
        pipeline.bloomKernel = Number(ui.ker.value);
        ui.kerVal.textContent = String(Math.round(pipeline.bloomKernel));
      });

      ui.exp.addEventListener("input", () => {
        pipeline.imageProcessing.exposure = Number(ui.exp.value);
        ui.expVal.textContent = Number(pipeline.imageProcessing.exposure).toFixed(2);
      });

      ui.con.addEventListener("input", () => {
        pipeline.imageProcessing.contrast = Number(ui.con.value);
        ui.conVal.textContent = Number(pipeline.imageProcessing.contrast).toFixed(2);
      });
    }

    // 시작
    scene = createScene();
    createPipeline();
    bindUI();

    engine.runRenderLoop(() => {
      scene.render();
      ui.fps.textContent = engine.getFps().toFixed(1);

      // 약간의 애니메이션(시네마틱 느낌)
      const ring = scene.getMeshByName("ring");
      if (ring) ring.rotation.y += 0.006;
    });

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

6-2) ON/OFF 비교 시 체크 포인트

  • ON에서 좋아져야 하는 것: 네온/발광 오브젝트의 ‘광량’이 살아나고, 분위기가 부드러워져야 합니다.
  • ON에서 나빠질 수 있는 것: 바닥/벽이 뿌옇게 번지거나, 화면 전체가 흐릿해질 수 있습니다(대개 threshold가 낮거나 weight/kernel이 과한 경우).
  • OFF에서도 괜찮아야 합니다: Bloom이 없어도 씬이 성립해야 “후처리 의존”을 피할 수 있습니다. Bloom은 ‘보너스’로 쓰는 편이 결과가 안정적입니다.


7) 실행 단계 체크리스트

  • DefaultRenderingPipeline을 생성하고 카메라에 연결했는지 확인합니다.
  • Bloom 튜닝은 threshold → weight → kernel 순서로 진행합니다.
  • 화면이 뿌옇게 되면 threshold를 올려 “번질 대상”부터 줄입니다.
  • 성능이 부담이면 kernel을 먼저 낮추고, bloomScale도 낮춰봅니다.
  • 노출(exposure)을 올리면 Bloom이 같이 과해질 수 있으므로, 톤 조정은 Bloom이 어느 정도 잡힌 뒤 진행합니다.


8) 추가로 생각해볼 점

  • Bloom은 ‘발광 재질’과 같이 설계할 때 가장 예쁩니다: emissiveColor를 적절히 사용하고, 씬 전체를 너무 밝게 만들지 않는 편이 결과가 안정적입니다.
  • DOF는 컷/연출 단위로 쓰는 것이 안전합니다: 상시 DOF는 비용과 튜닝 난이도가 올라갑니다. 제품샷/클로즈업 같은 장면에서만 제한적으로 적용하는 전략을 권장합니다.
  • 포스트 프로세스는 최적화 대상입니다: 시네마틱이 목적이더라도, 타깃 기기(모바일/저사양)가 있다면 bloomKernel, bloomScale, FXAA 사용 여부를 기준으로 예산을 정해야 합니다.
  • 측정과 연결: 이전 회차의 성능 측정(FPS/병목) 루틴으로 Bloom ON/OFF 성능 차이를 스냅샷으로 남기면, 이후 품질/성능 트레이드오프 판단이 쉬워집니다.

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

Reactions

댓글 쓰기

0 댓글