WebGL·WebGPU 기반의 오픈소스 3D 엔진. 기초 씬 구성부터 GLSL Node Material, 수만 개의 Thin Instance 렌더링, Inspector 디버깅 루틴까지 — 초·중·고급 + 이론 + 도구로 정리한 실무 가이드.
Microsoft가 후원하는 오픈소스 JavaScript/TypeScript 3D 엔진. WebGL과 WebGPU 위에서 동작하며, 게임·시각화·XR 경험을 웹 브라우저에서 구현할 수 있습니다.
playground.babylonjs.com에서 별도 설치 없이 바로 실험 가능합니다.npm 설치 후 Engine → Scene → Camera → Light → Mesh 순으로 구성하는 것이 기본 흐름입니다.
# 코어 + 로더 + GUI npm install @babylonjs/core @babylonjs/loaders @babylonjs/gui
import { Engine, Scene, ArcRotateCamera, HemisphericLight, MeshBuilder, Vector3 } from "@babylonjs/core"; const canvas = document.getElementById("c") as HTMLCanvasElement; const engine = new Engine(canvas, true); const scene = new Scene(engine); const camera = new ArcRotateCamera( "cam", -Math.PI/2, Math.PI/2.5, 5, Vector3.Zero(), scene ); camera.attachControl(canvas, true); new HemisphericLight("h", new Vector3(0,1,0), scene); MeshBuilder.CreateBox("box", { size: 1 }, scene); engine.runRenderLoop(() => scene.render()); window.addEventListener("resize", () => engine.resize());
Babylon.js의 모든 씬은 아래 5가지 요소의 조합으로 구성됩니다.
ArcRotateCamera(궤도), FreeCamera(FPS), UniversalCamera 등.Hemispheric(전역), Directional(태양광), Point(전구), Spot(스포트) 네 종류.Babylon.js는 왼손 좌표계(LH)가 기본이지만 scene.useRightHandedSystem = true로 glTF 호환을 위해 오른손으로 전환할 수 있습니다. 단위는 통상 1 unit = 1 meter로 다룹니다.
MeshBuilder로 표준 도형을 만들고, ActionManager나 pointer 이벤트로 클릭·호버를 처리합니다.
MeshBuilder.CreateBox ("b", { size: 1 }, scene); MeshBuilder.CreateSphere ("s", { diameter: 1, segments: 32 }, scene); MeshBuilder.CreateGround ("g", { width: 10, height: 10 }, scene); MeshBuilder.CreateCylinder("c", { diameter: 1, height: 2 }, scene);
import { ActionManager, ExecuteCodeAction } from "@babylonjs/core"; box.actionManager = new ActionManager(scene); box.actionManager.registerAction( new ExecuteCodeAction( ActionManager.OnPickTrigger, () => { box.scaling.scaleInPlace(1.1); } ) );
scene.onBeforeRenderObservable.add(cb)에 등록합니다. engine.getDeltaTime()로 프레임 간격(ms)을 얻어 시간 기반 애니메이션을 만드세요.
Composition API + <script setup> 패턴으로 생명주기에 맞춰 엔진을 안전하게 생성/폐기합니다.
<script setup lang="ts"> import { ref, onMounted, onBeforeUnmount } from "vue"; import { Engine, Scene, ArcRotateCamera, HemisphericLight, MeshBuilder, Vector3 } from "@babylonjs/core"; const canvasRef = ref<HTMLCanvasElement>(); let engine: Engine | null = null; onMounted(() => { engine = new Engine(canvasRef.value!, true); const scene = new Scene(engine); const cam = new ArcRotateCamera( "c",-Math.PI/2, Math.PI/2.5,5, Vector3.Zero(), scene ); cam.attachControl(canvasRef.value!, true); new HemisphericLight("h", Vector3.Up(), scene); MeshBuilder.CreateBox("b", {}, scene); engine.runRenderLoop(() => scene.render()); }); onBeforeUnmount(() => engine?.dispose()); </script> <template> <canvas ref="canvasRef" style="width:100%;height:100%" /> </template>
engine.dispose()를 반드시 호출하세요. 안 그러면 WebGL 컨텍스트 누수로 메모리가 계속 증가합니다.
여기까지 편하게 이해됐다면 중급으로 넘어갈 준비가 완료된 겁니다.
ActionManager로 구현props로 박스 크기를 받아 반영메쉬가 어떻게 빛에 반응하고 어떤 색·질감으로 보일지 결정하는 것이 Material입니다. Babylon.js는 4가지 접근을 제공합니다.
import { StandardMaterial, Color3, Texture } from "@babylonjs/core"; const mat = new StandardMaterial("m", scene); mat.diffuseColor = new Color3(0.75, 0.29, 1); // 본체 색 mat.specularColor = Color3.Black(); // 하이라이트 제거 mat.emissiveColor = new Color3(0.05, 0, 0.1); // 자체 발광 mat.diffuseTexture = new Texture("/tex/wood.jpg", scene); box.material = mat;
금속성(metallic)과 거칠기(roughness)라는 두 축으로 거의 모든 실물 재질을 표현할 수 있습니다.
import { PBRMaterial, Color3, Texture } from "@babylonjs/core"; const pbr = new PBRMaterial("pbr", scene); pbr.albedoColor = Color3.FromHexString("#bf4aff"); pbr.metallic = 0.9; // 0 = 비금속, 1 = 금속 pbr.roughness = 0.15; // 0 = 거울, 1 = 완전 무광 pbr.environmentIntensity = 1.2; // 환경 IBL (반사를 위해 권장) scene.environmentTexture = CubeTexture.CreateFromPrefilteredData( "/env/studio.env", scene ); sphere.material = pbr;
forceIrradianceInFragment를 비활성화하는 등의 최적화를 고려하세요.
셰이더를 코드가 아니라 노드 그래프로 설계하는 도구. 결과는 그대로 Babylon 씬에 저장·로드됩니다.
nme.babylonjs.com에서 브라우저로 바로 실행. 상단의 Save를 눌러 .json으로 저장하면 그대로 Babylon에서 불러올 수 있습니다.
import { NodeMaterial } from "@babylonjs/core"; const nodeMat = await NodeMaterial.ParseFromFileAsync( "myGlow", "/materials/glow.json", scene ); mesh.material = nodeMat; // 런타임에 파라미터 바꾸기 const timeBlock = nodeMat.getBlockByName("Time"); // 또는 Input block 값 변경 const colorInput = nodeMat.getInputBlockByPredicate( b => b.name === "tint" ); colorInput!.value = new Color3(1, 0.3, 0.7);
자주 쓰이는 3가지 패턴 — 프로시저럴 그라디언트, 프레넬 림 라이트, UV 스크롤.
완전한 제어가 필요할 때는 정점 셰이더와 프래그먼트 셰이더를 직접 작성합니다.
// vertex.glsl precision highp float; attribute vec3 position; attribute vec3 normal; attribute vec2 uv; uniform mat4 worldViewProjection; varying vec2 vUV; varying vec3 vN; void main() { vUV = uv; vN = normal; gl_Position = worldViewProjection * vec4(position, 1.0); }
// fragment.glsl precision highp float; uniform float time; varying vec2 vUV; void main() { vec3 c = 0.5 + 0.5 * cos(time + vUV.xyx + vec3(0,2,4)); gl_FragColor = vec4(c, 1.0); }
import { ShaderMaterial, Effect } from "@babylonjs/core"; Effect.ShadersStore["myVertexShader"] = vertexSrc; Effect.ShadersStore["myFragmentShader"] = fragSrc; const mat = new ShaderMaterial("my", scene, { vertex: "my", fragment: "my" }, { attributes: ["position", "normal", "uv"], uniforms: ["worldViewProjection", "time"] } ); let t = 0; scene.onBeforeRenderObservable.add(() => { t += engine.getDeltaTime() / 1000; mat.setFloat("time", t); });
같은 메쉬를 수천·수만 개 그려야 할 때(풀잎, 별, 파티클, 복셀, 건물 타일…) 각 객체를 일반 Mesh로 만들면 드로우콜과 메모리가 폭발합니다.
같은 박스 50,000개 렌더 (데스크톱 기준, 참고용): Mesh 50,000개 → 프레임 1~3 FPS (드로우콜 5만) Instance 50,000개 → 프레임 30~45 FPS (드로우콜 1, JS 비용 있음) Thin Instance 5만 → 프레임 60 FPS (드로우콜 1, JS 비용 거의 0)
원본 메쉬 하나에 4×4 변환 행렬 버퍼를 넘기면 끝입니다.
import { MeshBuilder, Matrix } from "@babylonjs/core"; const base = MeshBuilder.CreateBox("b", { size: 0.2 }, scene); const count = 50000; const matrices = new Float32Array(count * 16); for (let i = 0; i < count; i++) { const m = Matrix.Translation( (Math.random()-0.5) * 50, (Math.random()-0.5) * 50, (Math.random()-0.5) * 50 ); m.copyToArray(matrices, i * 16); } // 두 번째 인자 true → GPU staticBuffer로 최적화 base.thinInstanceSetBuffer("matrix", matrices, 16, true);
// 정적이 아니라 매 프레임 바뀐다면 staticBuffer=false base.thinInstanceSetBuffer("matrix", matrices, 16, false); scene.onBeforeRenderObservable.add(() => { // 100번째 인스턴스만 회전 const m = Matrix.RotationY(performance.now() * 0.001); m.copyToArray(matrices, 100 * 16); base.thinInstanceBufferUpdated("matrix"); });
true. 매 프레임 변동이 있으면 false. 잘못 선택하면 업데이트가 안 되거나 성능이 하락합니다.
직접 Float32Array를 다루지 않아도 되는 헬퍼가 있고, 인스턴스마다 다른 색도 줄 수 있습니다.
// 개수만 지정해 한 번에 할당 (행렬은 Identity로 초기화) base.thinInstanceCount = 10000; // 한 개씩 추가하는 패턴 for (let i = 0; i < 10; i++) { const m = Matrix.Translation(i, 0, 0); base.thinInstanceAdd(m); // refresh=true가 기본 } // 특정 인덱스 덮어쓰기 base.thinInstanceSetMatrixAt(3, Matrix.Scaling(2,2,2));
const colors = new Float32Array(count * 4); for (let i = 0; i < count; i++) { colors[i*4+0] = Math.random(); colors[i*4+1] = Math.random(); colors[i*4+2] = Math.random(); colors[i*4+3] = 1; } base.thinInstanceSetBuffer("color", colors, 4, true); // StandardMaterial은 vertex color를 쓰도록 알려야 함 (mat as StandardMaterial).diffuseColor = Color3.White(); (mat as any).useVertexColors = true;
thinInstanceSetBuffer("myAttr", data, stride)로 임의 속성도 넘길 수 있습니다. Node Material에서 InstanceColor 혹은 InstancesBlock으로 받아 셰이더 안에서 자유롭게 활용하세요.
Thin Instance는 Node가 아니기 때문에 일반 기능 몇 가지를 명시적으로 켜줘야 합니다.
// 기본적으로 thin instance는 하나의 메쉬로 피킹됨 base.thinInstanceEnablePicking = true; scene.onPointerDown = () => { const pick = scene.pick(scene.pointerX, scene.pointerY); if (pick?.pickedMesh === base) { console.log("클릭한 인스턴스 인덱스:", pick.thinInstanceIndex); } };
기본값은 원본 메쉬의 바운딩 박스로 전체가 같이 컬링됩니다. 인스턴스가 넓게 퍼져 있다면 다음 중 하나를 쓰세요.
// 1) 수동으로 큰 바운딩 박스 설정 base.setBoundingInfo(new BoundingInfo( new Vector3(-100,-100,-100), new Vector3(100, 100, 100) )); // 2) 인스턴스별 개별 컬링 활성화 (오버헤드 있음) base.thinInstanceEnablePicking = true; base.thinInstanceRefreshBoundingInfo(true);
ShadowGenerator.getShadowMap().renderList.push(base)로 섀도우 캐스터에 등록됩니다. 단, 인스턴스가 많을수록 섀도우 맵 렌더 비용도 비례해서 증가하니 그림자 해상도를 낮추거나 CSM을 고려하세요.
지금까지 배운 것을 한 번에: 40×40×40 그리드에 색상 그라디언트를 입힌 큐브를 단 1회 드로우콜로 렌더링합니다. Babylon.js Playground의 대표 Thin Instance 데모를 TypeScript + Vue 3로 옮긴 버전입니다.
import { Engine, Scene, ArcRotateCamera, Vector3, MeshBuilder, Matrix, StandardMaterial, Color3 } from "@babylonjs/core"; export const createScene = ( engine: Engine, canvas: HTMLCanvasElement ): Scene => { const scene = new Scene(engine); const camera = new ArcRotateCamera( "Camera", -Math.PI / 5, Math.PI / 3, 200, Vector3.Zero(), scene ); camera.attachControl(canvas, true); const box = MeshBuilder.CreateBox("root", { size: 1 }, scene); const numPerSide = 40; const size = 100; const ofst = size / (numPerSide - 1); const m = Matrix.Identity(); let col = 0, index = 0; const instanceCount = numPerSide ** 3; // 64,000 const matricesData = new Float32Array(16 * instanceCount); const colorData = new Float32Array(4 * instanceCount); for (let x = 0; x < numPerSide; x++) { m.m[12] = -size / 2 + ofst * x; for (let y = 0; y < numPerSide; y++) { m.m[13] = -size / 2 + ofst * y; for (let z = 0; z < numPerSide; z++) { m.m[14] = -size / 2 + ofst * z; m.copyToArray(matricesData, index * 16); const coli = Math.floor(col); colorData[index*4] = ((coli & 0xff0000) >> 16) / 255; colorData[index*4+1] = ((coli & 0x00ff00) >> 8) / 255; colorData[index*4+2] = ((coli & 0x0000ff) ) / 255; colorData[index*4+3] = 1.0; index++; col += 0xffffff / instanceCount; } } } box.thinInstanceSetBuffer("matrix", matricesData, 16); box.thinInstanceSetBuffer("color", colorData, 4); const mat = new StandardMaterial("material", scene); mat.disableLighting = true; mat.emissiveColor = Color3.White(); box.material = mat; return scene; }; export default createScene;
<script setup lang="ts"> import { ref, onMounted, onBeforeUnmount } from "vue"; import { Engine } from "@babylonjs/core"; import createScene from "./createScene"; const canvasRef = ref<HTMLCanvasElement>(); let engine: Engine | null = null; onMounted(() => { const canvas = canvasRef.value!; engine = new Engine(canvas, true); const scene = createScene(engine, canvas); engine.runRenderLoop(() => scene.render()); window.addEventListener("resize", () => engine?.resize()); }); onBeforeUnmount(() => engine?.dispose()); </script> <template><canvas ref="canvasRef" style="width:100%;height:100vh" /></template>
let, 나머지는 const.@babylonjs/core에서 이름 임포트 — 트리쉐이킹에 유리.MeshBuilder.CreateBox로 통합. BoxBuilder는 레거시.engine/canvas 대신 파라미터로 받아 테스트·재사용이 쉬워짐.staticBuffer 기본값 true → 한 번 업로드 후 GPU 고정. 64,000개 큐브가 드로우콜 1회로 60fps 렌더링. disableLighting + emissiveColor = White 조합으로 인스턴스별 color 버퍼가 그대로 최종 색이 됩니다.
staticBuffer = true, 동적이면 false로 명확히 분리thinInstanceEnablePicking, 넓게 퍼지면 바운딩 수동 설정3D 공간에서 모든 물체의 위치·방향·크기는 결국 하나의 4×4 행렬로 표현됩니다. Position, Rotation, Scale이 어떻게 행렬로 합쳐지는지 살펴봅니다.
mesh.position (Vector3).mesh.rotation(오일러) 또는 mesh.rotationQuaternion.mesh.scaling (Vector3).3D 공간은 3개의 축(x, y, z)이 있지만 이동(Translation)을 곱셈으로 표현하려면 4×4 행렬이 필요합니다. 이를 동차 좌표계(Homogeneous Coordinates)라 합니다. 4번째 성분(w)을 1로 설정하면 이동·회전·크기 변환을 모두 하나의 행렬 곱셈으로 처리할 수 있습니다.
[ Sx·Rxx Rxy Rxz Tx ] [ Ryx Sy·Ryy Ryz Ty ] ← 4×4 변환 행렬 [ Rzx Rzy Sz·Rzz Tz ] [ 0 0 0 1 ]
import { Matrix } from "@babylonjs/core"; // 메쉬의 월드 변환 행렬 (TRS 합성) const worldMatrix: Matrix = mesh.getWorldMatrix(); // 직접 TRS 행렬 조합 const t = Matrix.Translation(1, 2, 3); const r = Matrix.RotationYawPitchRoll(yaw, pitch, roll); const s = Matrix.Scaling(2, 2, 2); const m = s.multiply(r).multiply(t); // S→R→T
회전을 표현하는 두 가지 방식의 차이와, 실무에서 쿼터니언을 써야 하는 이유를 짐벌락(Gimbal Lock)을 중심으로 설명합니다.
세 축(X·Y·Z)을 순서대로 회전시키는 방식. 직관적이지만 순서에 따라 결과가 달라지며, 특정 상황에서 자유도를 잃는 문제가 있습니다.
예를 들어 Y축을 90° 회전시키면 X축과 Z축이 겹쳐버려 한 축의 자유도가 사라지는 현상입니다. 항공기·카메라 등 연속 회전이 많은 경우에 특히 문제가 됩니다.
4개의 숫자 (x, y, z, w)로 회전을 표현하는 방식. 임의 축을 중심으로 한 번에 회전을 정의하므로 짐벌락이 수학적으로 불가능합니다.
import { Quaternion, Vector3 } from "@babylonjs/core"; // 오일러 → 쿼터니언 변환 const q = Quaternion.FromEulerAngles(pitch, yaw, roll); // SLERP: 두 회전 사이를 0~1 t로 보간 const blended = Quaternion.Slerp(qStart, qEnd, 0.5); // 메쉬에 적용 (rotationQuaternion 사용 시 rotation 무시됨) mesh.rotationQuaternion = q;
Translation·Rotation·Scale 세 요소를 하나로 합친 행렬이 변환 행렬입니다. Thin Instance에서 인스턴스마다 행렬 하나를 GPU에 전달하는 이유가 바로 여기에 있습니다.
import { Matrix, Quaternion, Vector3 } from "@babylonjs/core"; const position = new Vector3(3, 0, 1); const rotation = Quaternion.FromEulerAngles(0, Math.PI / 4, 0); // Y 45° const scale = new Vector3(2, 2, 2); // 세 가지를 하나의 4×4 행렬로 합성 const matrix = Matrix.Compose(scale, rotation, position);
Thin Instance는 인스턴스별로 개별 mesh.position·rotation이 없습니다. 대신 각 인스턴스의 변환 행렬을 Float32Array(16개 값 × 인스턴스 수)로 묶어 GPU에 한꺼번에 전달합니다.
const count = 1000; const buffer = new Float32Array(16 * count); for (let i = 0; i < count; i++) { const m = Matrix.Compose( new Vector3(1, 1, 1), // scale Quaternion.Identity(), // rotation new Vector3(i * 2, 0, 0) // position ); m.copyToArray(buffer, i * 16); } mesh.thinInstanceSetBuffer("matrix", buffer, 16);
Matrix.Compose로 만든 행렬은 보통 로컬 변환.mesh.getWorldMatrix()는 부모 계층까지 포함한 최종 행렬.Matrix.Compose로 세 요소를 합친 뒤, GPU에 Float32Array로 전달하는 흐름을 이해하면 Thin Instance의 핵심을 파악한 것입니다.
Inspector는 씬 구조·머티리얼·라이트·성능 상태를 실시간으로 확인하는 디버깅 도구입니다. 개발 단계에서 가장 먼저 켜야 하는 도구입니다.
import { Inspector } from "@babylonjs/inspector"; scene.debugLayer.show({ embedMode: true, handleResize: true });
처음 Inspector를 열면 패널이 많아 보이지만, 실제로는 “무엇이 그려졌는지 / 왜 느린지 / 어디가 잘못됐는지”를 순서대로 보는 도구입니다.
metallic, roughness, 알베도 텍스처 연결 상태.문제가 생겼을 때 “어디서 비용이 터졌는지”를 단계적으로 좁히는 방식입니다. 아래 순서대로 하면 초보자도 재현 가능한 디버깅 루틴을 만들 수 있습니다.
바인딩(Binding)은 GPU에 파이프라인·텍스처·버퍼를 연결하는 과정입니다. 오브젝트마다 머티리얼이 조금씩 다르면 바인딩 변경이 잦아지고 Draw Call이 증가하기 쉽습니다.
기능 토글 전후 수치를 팀 문서에 남기면, “체감”이 아닌 수치 중심으로 회귀를 잡을 수 있습니다.
[성능 기록 예시] 기능: 그림자 품질 High FPS: 58 -> 41 Draw Calls: 145 -> 312 Active Meshes: 90 -> 91 판단: 메시 수 증가는 거의 없음, Draw Call 급증 -> 머티리얼/쉐도우 패스 분기 확인