BabylonJS 26회차 : PostProcess(블룸/DOF 등)
요약
PostProcess(포스트 프로세스)는 “3D를 다 그린 뒤, 화면(프레임) 전체에 필터를 적용하는 단계”입니다. 모델/조명/재질이 어느 정도 갖춰졌는데도 화면이 밋밋하게 느껴질 때, Bloom(블룸), 색보정(Image Processing), Vignette(비네트), Depth of Field(심도) 같은 처리가 ‘완성도’를 올려주는 경우가 많습니다. 다만 포스트 프로세스는 잘못 쓰면 흐릿해지거나 과장되어 보일 수 있고, 성능 비용도 발생합니다. 이번 글에서는 BabylonJS의 DefaultRenderingPipeline로 Bloom을 가장 빠르게 적용하고, ON/OFF 비교를 통해 “언제 효과가 좋은지/언제 과한지”를 판단하는 기준을 만듭니다.
목차
- 1) 핵심 포인트(오늘의 결론)
- 2) PostProcess는 무엇이고, 왜 필요한가
- 3) DefaultRenderingPipeline 한 번에 이해하기
- 4) Bloom(블룸) 파라미터: 무엇을 건드려야 하나
- 5) DOF/색보정/비네트: ‘시네마틱’ 느낌 만들기
- 6) 실습: Bloom ON/OFF 비교 데모(시네마틱 씬)
- 7) 실행 단계 체크리스트
- 8) 추가로 생각해볼 점
- 9) 블로그 최적화 정보
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(노출/대비/톤매핑): 화면이 회색/밋밋하면
exposure와contrast를 조절해 톤을 잡습니다. 톤매핑(예: 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 성능 차이를 스냅샷으로 남기면, 이후 품질/성능 트레이드오프 판단이 쉬워집니다.
이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.

0 댓글