BabylonJS 25회차 : 파티클/이펙트(불/연기/스파크)



BabylonJS 25회차 : 파티클/이펙트(불/연기/스파크)

목표: 시각 효과의 기본을 익힙니다.
핵심 개념: ParticleSystem, emitter
실습: 클릭 위치에 스파크 생성
산출물: 이펙트 데모


요약

파티클은 “작은 이미지(텍스처)를 매우 많이 뿌려서” 불, 연기, 스파크 같은 시각 효과를 만드는 방식입니다. 3D 모델링으로 불꽃을 만들기보다, 짧은 수명(lifeTime)과 빠른 속도(direction/minEmitPower)를 가진 파티클을 순간적으로 방출(emitRate 또는 manualEmitCount)하면 훨씬 간단하고 효율적입니다. 이번 회차에서는 ParticleSystem의 핵심 파라미터를 초보자 기준으로 정리하고, 클릭한 위치에 스파크를 “한 번 터뜨리는” 데모를 구현해봅니다.


목차


1) 핵심 포인트

  • 파티클은 “작은 스프라이트 다수”입니다: 개별 파티클은 매우 가볍고, 전체로 불/연기/먼지 같은 덩어리 느낌을 만듭니다.
  • emitter는 “파티클이 시작되는 곳”입니다: 메시에 붙일 수도 있고, 좌표(Vector3)에 바로 뿌릴 수도 있습니다.
  • 스파크는 ‘짧게, 빠르게, 강하게’가 핵심: 수명은 짧게, 속도는 빠르게, 방출은 순간 폭발처럼 구성합니다.
  • 연기는 ‘길게, 느리게, 퍼지게’가 핵심: 수명은 길게, 속도는 느리게, 크기는 커지도록 설정합니다.
  • 성능은 “파티클 수”와 “업데이트 빈도”가 좌우: 화면에서 티 나지 않는 수준으로 적정량을 찾고, 필요 시 풀링(재사용)을 고려합니다.


2) ParticleSystem 구조 이해(텍스처, emitter, 수명)

2-1) 파티클이 그려지는 방식

  • particleTexture: 파티클 하나가 어떤 이미지로 보일지 결정합니다(보통 원형 글로우/연기 텍스처).
  • blendMode: 빛나게(가산합성) 보이게 할지, 일반 알파 블렌딩으로 섞을지 결정합니다.
    스파크/불꽃은 가산합성이 자연스러운 편이고, 연기는 일반 알파가 흔합니다.
  • minLifeTime/maxLifeTime: 파티클이 살아있는 시간(초)입니다. “스파크 느낌”은 보통 0.1~0.4초처럼 짧게 잡습니다.

2-2) emitter란 무엇인가

emitter는 파티클이 “어디서 시작할지”를 정합니다.

  • 메시 emitter: 칼끝, 엔진 배기구처럼 “오브젝트에 붙어서 움직이는” 이펙트에 적합합니다.
  • 좌표 emitter(Vector3): 폭발 지점, 클릭 지점처럼 “그 자리에서 한 번 터지는” 이펙트에 적합합니다.

2-3) 스파크는 “연속 방출”보다 “버스트(한 번에 터뜨리기)”가 이해하기 쉽습니다

이번 실습은 클릭할 때마다 파티클 시스템을 짧게 생성하고, 잠깐 방출한 뒤 종료/정리합니다. 초보자 단계에서는 이 방식이 “원리와 효과”를 확인하기 가장 편합니다. 이후에 성능이 필요해지면 “파티클 시스템을 풀링(재사용)”하는 방식으로 확장할 수 있습니다.


3) 주요 파라미터 빠른 정리(불/연기/스파크 관점)

파라미터 의미 스파크 추천 연기 추천
minLifeTime/maxLifeTime 수명(초) 0.12~0.35 1.5~4.0
emitRate 초당 방출량(연속) 낮게 또는 0(버스트 권장) 30~150
manualEmitCount 프레임당 수동 방출(버스트) 120~300(짧게) 보통 사용 안 함
minEmitPower/maxEmitPower 속도(발사 힘) 6~16 0.2~1.4
direction1/direction2 방출 방향 범위 위로+퍼짐(원뿔) 천천히 위로
gravity 파티클에 작용하는 중력 아래로 약간(-9~-2) 거의 0 또는 아주 약하게
minSize/maxSize 크기 0.06~0.18 0.3~1.2
color1/color2/colorDead 색/소멸 색 노랑/주황 → 투명 회색 → 투명


4) 불/연기/스파크 프리셋 비교 표

아래 표는 “느낌을 만드는 핵심 요소”만 추려서 정리한 것입니다. 실무에서는 여기에 텍스처(불꽃용/연기용)와 블렌드 모드를 함께 조합합니다.

이펙트 대표 설정 방향 블렌드 추천 초보자 팁
스파크 짧은 수명, 높은 속도, 순간 버스트 가산합성(빛나는 느낌) manualEmitCount로 “한 번에 터지게” 만들면 이해가 빠릅니다.
중간 수명, 위로 상승, 색 그라데이션 가산합성 또는 알파 색 변화(colorDead)와 크기 변화가 핵심입니다.
연기 긴 수명, 느린 속도, 점점 커짐 알파 블렌딩(부드럽게) 해상도 큰 텍스처보다 “적당한 텍스처 + 적당한 개수”가 안정적입니다.


5) 실습: 클릭 위치에 스파크 생성(이펙트 데모)

실습 흐름은 다음과 같습니다.

  • 바닥(ground)을 만들고 카메라를 붙입니다.
  • 포인터(마우스) 클릭 시, 화면 좌표로 픽킹(scene.pick)해서 3D 위치(pickedPoint)를 얻습니다.
  • 그 위치를 emitter로 하는 파티클 시스템을 생성합니다.
  • 아주 짧은 시간만 방출한 뒤(manualEmitCount), 자동으로 정리(dispose)합니다.

5-1) 완성 코드: 이펙트 데모(스파크)

아래 코드는 그대로 index.html로 실행할 수 있습니다(정적 서버 권장). 클릭한 바닥 위치에서 스파크가 터집니다. 성능을 위해 “한 번 터뜨리고 정리하는” 형태로 구성했습니다.

<!doctype html>
<html lang="ko">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <title>BabylonJS Particle Effect Demo (Click Spark)</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(520px, 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; }
    #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; }
    #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); }
  </style>

  <script src="https://cdn.babylonjs.com/babylon.js"></script>
</head>
<body>
  <div id="hud">
    <p id="hudTitle">클릭 스파크 이펙트 데모 (ParticleSystem)</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">Last Click</span><span class="v" id="pos">-</span></div>
      <div class="row"><span class="k">Sparks Fired</span><span class="v" id="count">0</span></div>
    </div>

    <div id="btns">
      <button id="toggleAuto">자동 연사 토글(데모용)</button>
      <button id="clearMarks">마커 삭제</button>
    </div>

    <div id="hint">
      <div>사용법: 바닥을 <code>클릭</code>하면 해당 위치에서 스파크가 터집니다.</div>
      <div>관찰 포인트: <code>lifeTime</code>와 <code>emitPower</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"),
      pos: document.getElementById("pos"),
      count: document.getElementById("count")
    };

    let scene, camera, ground;
    let fired = 0;
    let auto = false;
    let autoHandle = null;
    const markers = [];

    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.55,
        18,
        new BABYLON.Vector3(0, 1.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;

      ground = BABYLON.MeshBuilder.CreateGround("ground", { width: 40, height: 40 }, s);
      ground.position.y = 0;
      const gmat = new BABYLON.StandardMaterial("gmat", s);
      gmat.diffuseColor = new BABYLON.Color3(0.14, 0.15, 0.17);
      gmat.alpha = 0.95;
      ground.material = gmat;
      ground.isPickable = true;

      // 씬에 간단한 장애물(스파크 위치 감 잡기용)
      const wallMat = new BABYLON.StandardMaterial("wmat", s);
      wallMat.diffuseColor = new BABYLON.Color3(0.25, 0.28, 0.32);

      for (let i = 0; i < 10; i++) {
        const b = BABYLON.MeshBuilder.CreateBox("box" + i, { size: 1.8 }, s);
        b.position.x = (Math.random() - 0.5) * 28;
        b.position.z = (Math.random() - 0.5) * 28;
        b.position.y = 0.9;
        b.material = wallMat;
        b.isPickable = false;
      }

      // 클릭 처리: 바닥 픽킹으로 3D 위치를 얻어 스파크 생성
      s.onPointerObservable.add((pointerInfo) => {
        if (pointerInfo.type !== BABYLON.PointerEventTypes.POINTERDOWN) return;

        const pick = s.pick(s.pointerX, s.pointerY, (mesh) => mesh === ground);
        if (!pick || !pick.hit || !pick.pickedPoint) return;

        const p = pick.pickedPoint;
        ui.pos.textContent = `${p.x.toFixed(2)}, ${p.y.toFixed(2)}, ${p.z.toFixed(2)}`;

        createSparkAt(p);
        createMarkerAt(p);
      });

      return s;
    }

    function createMarkerAt(point) {
      // 클릭 위치를 눈으로 확인하기 위한 작은 마커(선택)
      const m = BABYLON.MeshBuilder.CreateSphere("mark" + markers.length, { diameter: 0.18, segments: 12 }, scene);
      m.position.copyFrom(point);
      m.position.y += 0.09;
      const mat = new BABYLON.StandardMaterial("markMat" + markers.length, scene);
      mat.emissiveColor = new BABYLON.Color3(0.25, 0.45, 0.85);
      m.material = mat;
      m.isPickable = false;
      markers.push(m);

      // 마커가 너무 쌓이면 오래된 것부터 정리
      if (markers.length > 40) {
        const old = markers.shift();
        if (old) old.dispose();
      }
    }

    function createSparkAt(point) {
      fired += 1;
      ui.count.textContent = String(fired);

      // 스파크는 짧은 버스트: 파티클 시스템을 만들고 잠깐만 방출 후 정리
      const ps = new BABYLON.ParticleSystem("spark" + fired, 700, scene);

      // 파티클 텍스처(글로우 계열이 스파크에 잘 맞습니다)
      ps.particleTexture = new BABYLON.Texture("https://playground.babylonjs.com/textures/flare.png", scene);

      // emitter를 좌표로 설정(클릭 지점에서 바로 터뜨리기)
      ps.emitter = new BABYLON.Vector3(point.x, point.y + 0.05, point.z);

      // 스파크 느낌: 짧은 수명
      ps.minLifeTime = 0.12;
      ps.maxLifeTime = 0.35;

      // 크기(작게) + 약간 랜덤
      ps.minSize = 0.06;
      ps.maxSize = 0.16;

      // 색: 노랑/주황 계열 → 투명(죽을 때)
      ps.color1 = new BABYLON.Color4(1.0, 0.85, 0.35, 1.0);
      ps.color2 = new BABYLON.Color4(1.0, 0.45, 0.12, 1.0);
      ps.colorDead = new BABYLON.Color4(0.2, 0.1, 0.05, 0.0);

      // 방출 방향 범위(원뿔처럼 위로 튀는 느낌)
      ps.direction1 = new BABYLON.Vector3(-1.2, 2.6, -1.2);
      ps.direction2 = new BABYLON.Vector3( 1.2, 3.4,  1.2);

      // 속도(강한 느낌)
      ps.minEmitPower = 7;
      ps.maxEmitPower = 16;

      // 중력(스파크는 아래로 떨어지는 느낌을 주면 자연스럽습니다)
      ps.gravity = new BABYLON.Vector3(0, -9.0, 0);

      // 방출 영역(클릭 지점 주변 아주 작은 범위)
      ps.minEmitBox = new BABYLON.Vector3(-0.05, 0.0, -0.05);
      ps.maxEmitBox = new BABYLON.Vector3( 0.05, 0.0,  0.05);

      // 블렌드(빛나는 느낌)
      ps.blendMode = BABYLON.ParticleSystem.BLENDMODE_ADD;

      // 버스트 구성: 프레임당 수동 방출량(짧게 크게)
      ps.emitRate = 0;
      ps.manualEmitCount = 220;

      // 시작
      ps.start();

      // 아주 짧은 시간 후 방출을 멈추고 정리
      // (lifeTime이 짧아서, stop 후 잠깐 기다리면 화면에서 사라집니다)
      setTimeout(() => {
        ps.stop();
        ps.manualEmitCount = 0;
      }, 120);

      setTimeout(() => {
        ps.dispose();
      }, 900);
    }

    // 데모용: 자동 연사(성능 관찰용)
    document.getElementById("toggleAuto").addEventListener("click", () => {
      auto = !auto;
      if (auto) {
        autoHandle = setInterval(() => {
          const x = (Math.random() - 0.5) * 18;
          const z = (Math.random() - 0.5) * 18;
          createSparkAt(new BABYLON.Vector3(x, 0, z));
          createMarkerAt(new BABYLON.Vector3(x, 0, z));
        }, 250);
      } else {
        if (autoHandle) clearInterval(autoHandle);
        autoHandle = null;
      }
    });

    document.getElementById("clearMarks").addEventListener("click", () => {
      while (markers.length) {
        const m = markers.pop();
        if (m) m.dispose();
      }
    });

    scene = createScene();

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

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

5-2) “스파크가 그럴듯해지는” 튜닝 포인트

  • 더 강한 폭발: manualEmitCount를 올리고, minEmitPower/maxEmitPower를 조금 올립니다.
  • 더 짧고 날카롭게: minLifeTime/maxLifeTime를 더 줄이고(예: 0.08~0.25), minSize/maxSize도 줄입니다.
  • 더 무겁게 떨어지게: gravity의 Y를 더 낮게(예: -12) 조정합니다.
  • 빛나는 느낌 강화: blendMode는 스파크에 보통 BLENDMODE_ADD가 잘 맞습니다.

5-3) 불/연기로 확장하는 방법(방향만)

  • 불: 수명을 조금 늘리고(0.4~1.2), 위로 상승 방향을 더 일관되게 만들고, 색을 주황/붉은 계열로 조정합니다.
  • 연기: 수명을 길게(2~5), 속도를 낮게(0.2~1.0), 크기를 크게(0.5~1.5), 알파 블렌딩으로 부드럽게 섞습니다.
  • 공통: 텍스처가 분위기를 크게 좌우하므로, 스파크/연기 전용 텍스처를 분리하면 품질이 빠르게 올라갑니다.


6) 실행 단계(체크리스트)

  • 정적 서버(VSCode Live Server 등)에서 실행합니다.
  • 바닥을 클릭했을 때 콘솔 오류가 없는지 확인합니다(픽킹/텍스처 로딩 실패 여부).
  • manualEmitCount, minLifeTime/maxLifeTime, minEmitPower/maxEmitPower를 하나씩 바꿔가며 “어떤 값이 어떤 느낌을 만드는지”를 기록합니다.
  • 자동 연사(데모용)를 켜고 FPS 변화를 보면서 “내 환경에서 안전한 파티클 예산”을 대략 잡아봅니다.
  • 프로젝트에 적용할 때는 “필요한 순간에만 생성/재생”하도록 설계합니다(항상 켜두기 금지).


7) 자주 막히는 지점(트러블슈팅)

증상 가능 원인 해결 방법
클릭해도 아무 것도 안 나옴 픽킹 대상이 없거나 pick이 hit 실패 바닥 mesh에 isPickable=true 확인, pick 조건 함수가 올바른지 확인
파티클이 검은 네모로 보임 텍스처 로딩 실패 또는 알파 채널 문제 텍스처 URL 확인, 네트워크 탭에서 200 응답 확인, 알파가 있는 텍스처 사용
너무 티가 안 남 수명/크기/방출량이 너무 낮음 manualEmitCount↑, maxSize↑, maxEmitPower↑로 단계적으로 조정
FPS가 급격히 떨어짐 동시에 너무 많은 파티클/시스템 생성 방출량을 줄이고, 파티클 시스템을 재사용(풀링)하는 구조로 개선
전환/정리가 늦어 잔상이 남음 수명이 길거나 dispose 타이밍이 늦음 maxLifeTime↓ 또는 dispose 타이밍을 조금 앞당김(단, 너무 빠르면 갑자기 사라짐)


8) 추가로 생각해볼 점

  • 성능을 위해 “파티클 시스템 풀링”을 고려하세요: 클릭마다 새로 만들고 버리는 방식은 이해가 쉽지만, 대량 사용 시에는 재사용 구조가 더 안정적입니다.
  • 이펙트 품질은 ‘텍스처’ 비중이 큽니다: 파라미터를 잘 잡아도 텍스처가 어울리지 않으면 결과가 밋밋해질 수 있습니다.
  • 씬 최적화와 연결: 파티클도 결국 렌더 비용이 있으므로, 이전 회차의 측정 루틴(FPS/병목)과 함께 운영하는 습관이 좋습니다.
  • 실무 패턴: “히트 지점(총알 충돌)”, “발자국 먼지”, “폭발”처럼 트리거 이벤트에 연결하면 가장 활용도가 높습니다.

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

Reactions

댓글 쓰기

0 댓글