# AI 꾸러미 — VFX 그래프 · 효과 편집기 — Data-driven VFX (effect presets)
> 효과를 방출량 · 속도 · 크기 · 수명 · 색조 · 중력 · 퍼짐 · 층 켬/끔 값 묶음으로 만들고 JSON 으로 저장해서, 코드를 안 고치고 조절판으로 효과를 다듬는다.  
> 견본: https://ai-techstudio.web.app/#t/i196

이 문서를 코드 도우미(Claude Code · Cursor · ChatGPT 등)에 그대로 주면 돼요. 「## 주문서」 가 할 일, 나머지는 참고 자료예요.

## 주문서

### 만들어 줘: VFX 그래프 · 효과 편집기 — Data-driven VFX (effect presets)

#### 1. 목표
불덩이 · 얼음 · 독 · 비전 효과를 값 묶음으로 정의하는 효과 편집기를 만들어 줘 — 방출 · 수명 · 층을 조절판으로 바꾸면 바로 보이고, 오른쪽에 같은 효과의 JSON 이 나오고 복사할 수 있게. 분위기는 어두운 작업대 + 보라 패널.

#### 2. 핵심 기술 용어
- **Data-driven VFX (effect presets)** — 값으로 정의하는 효과 · 프리셋
- **VFX graph / effect editor** — 방출기 → 곡선 → 층 노드로 보는 효과 편집기
- **JSON serialization** — 효과를 JSON 글로 저장 · 불러오기
- **Parameter interpolation between presets** — 프리셋 사이 값을 부드럽게 옮기기

#### 3. 환경
- 플랫폼: three.js r186 (ES 모듈 · TypeScript, `import * as THREE from "three"`), WebGL2, 외부 라이브러리 추가 없이
- 화면: 3D · 브라우저 — PC 와 폰(가로 844×390 · 세로 390×844) 모두, 60fps 목표

#### 4. 조건
- 효과는 값 묶음(타입 하나)으로 정의하고, 방출 코드는 그 값만 읽는다 — 효과마다 다른 코드 금지
- JSON 으로 저장 · 복사 (클립보드), 같은 JSON 으로 다시 만들 수 있게
- 프리셋 바꿀 때 값을 부드럽게 옮기고, 색조는 짧은 쪽으로 돈다
- 판 그리기는 값이 바뀐 때만 + 최소 간격(0.12초) — 매 프레임 캔버스 업로드 금지
- 조절판: 방출량 · 크기 · 색조 · 중력 · 연기 층 · 불티 층 · 다음 프리셋 · JSON 복사

#### 5. 완성 기준 (이게 보이면 성공)
- 받침대 위 효과가 불덩이 → 얼음 → 독 → 비전으로 부드럽게 바뀌고, 오른쪽 JSON 이 함께 바뀐다
- 조절판 값을 바꾸면 이름이 「내 효과」가 되고 효과 · JSON 이 바로 바뀐다
- 「JSON 복사」로 복사한 글에 emitter · particle · curves · layers 가 들어 있다
- 폰에서도 끊김 없이

#### 6. 진행 방식
- 핵심 코드 위주로, 설명은 짧게. 내 프로젝트에 끼워 넣기 쉬운 함수 · 클래스로 나눠 줘.
- 처음 화면에 바로 결과가 보이게, 그리고 켬/끔(또는 전/후) 비교를 할 수 있게 만들어 줘.
- 그림 · 소리 · 모델 파일이 필요하면 코드로 만든 임시 대체물로 먼저 돌아가게 하고, 진짜 파일로 바꿀 자리를 표시해 줘.
- 마지막에 「확인 방법」(무엇을 보면 성공인지)과 「조절할 값」 목록을 짧게 정리해 줘.
- 답변과 코드 주석은 한국어로 해 줘.

## 원리
- 효과 하나 = 값 묶음: rate(초당 개수) · speed · size · life · hue · gravity(− 는 위로) · spread + 층 켬/끔(핵 · 불꽃 · 연기 · 불티 · 바닥).
- 같은 방출 코드가 이 값만 읽어 입자를 뿜는다 — 불꽃은 rate, 연기는 rate × 0.2, 불티는 rate × 0.35. 색은 hue 하나에서 hot · mid · dark 세 단계를 만든다.
- 프리셋 4개(불덩이 · 얼음 결정 · 독 샘 · 비전 소용돌이)를 3.6초마다 바꾸며, 숫자 값은 1 − e^(−4dt) 로 부드럽게 옮긴다. 색조는 짧은 쪽으로 돌아간다(0.9 → 0.1 은 0.2 차이).
- 오른쪽 판은 노드 셋(방출기 · 수명 곡선 · 층) 그림 + JSON.stringify 결과. 값이 바뀌었을 때만, 0.12초에 한 번까지 다시 그린다.

## 핵심 코드 — 효과 값 묶음 · 프리셋 사이 옮기기 · 값으로 방출 · JSON
(발췌: demos/demosSkillB.ts i196 make() 를 정리)
```ts
interface FX { rate: number; speed: number; size: number; life: number; hue: number; gravity: number; spread: number;
  core: boolean; flame: boolean; smoke: boolean; sparks: boolean; ground: boolean }
const PRESETS: { name: string; fx: FX }[] = [
  { name: '불덩이', fx: { rate: 70, speed: 1.6, size: 0.9, life: 0.9, hue: 0.06, gravity: -1.4, spread: 0.35, core: true, flame: true, smoke: true, sparks: true, ground: true } },
  { name: '얼음 결정', fx: { rate: 55, speed: 2.4, size: 0.5, life: 1.1, hue: 0.55, gravity: 1.8, spread: 0.9, core: true, flame: true, smoke: false, sparks: true, ground: true } },
];
const cur: FX = { ...PRESETS[0].fx };

function blendTo(tg: FX, dt: number): void { // 프리셋 사이 부드럽게
  const k = 1 - Math.exp(-dt * 4);
  for (const key of ['rate', 'speed', 'size', 'life', 'hue', 'gravity', 'spread'] as const) {
    const a = cur[key];
    let b = tg[key];
    if (key === 'hue' && Math.abs(b - a) > 0.5) b += b < a ? 1 : -1; // 색조는 짧은 쪽으로
    cur[key] = key === 'hue' ? (((a + (b - a) * k) % 1) + 1) % 1 : a + (b - a) * k;
  }
  for (const key of ['core', 'flame', 'smoke', 'sparks', 'ground'] as const) cur[key] = tg[key];
}

function emitFlame(dt: number, E: V3): void { // 값만 읽는 같은 코드
  for (let i = rate(fAcc, cur.rate, dt); i > 0; i--) {
    const d = norm3([rr(-1, 1) * cur.spread, 1, rr(-1, 1) * cur.spread]);
    fx.emit({ p: add3(E, sc3(randDir(), 0.12)), v: sc3(d, cur.speed * rr(0.6, 1.2)), life: cur.life * rr(0.7, 1.2),
      size: cur.size * 0.5, size2: cur.size * 1.1, c: HSL(cur.hue, 1, 0.6, 2.4), c2: HSL(cur.hue + 0.02, 1, 0.3, 0.5),
      a: 0.5 / Math.max(1, cur.size * 1.3), grav: cur.gravity, drag: 0.8 });
  }
}

const json = (name: string): string => JSON.stringify({
  effect: name,
  emitter: { shape: 'cone', rate: Math.round(cur.rate), spread: +cur.spread.toFixed(2), speed: +cur.speed.toFixed(2) },
  particle: { life: +cur.life.toFixed(2), size: +cur.size.toFixed(2), gravity: +cur.gravity.toFixed(2), hue: +cur.hue.toFixed(2) },
  layers: { core: cur.core, flame: cur.flame, smoke: cur.smoke, sparks: cur.sparks, ground: cur.ground },
}, null, 1);
```

## 흔한 실수 · 확인 목록
- [ ] **효과마다 방출 코드를 따로 쓰면 편집기가 못 따라간다** — 방출 코드는 하나, 다른 건 값뿐이어야 JSON 하나로 효과가 다시 만들어진다.
- [ ] **색조를 그냥 lerp 하면 빨강 → 보라 사이에 무지개가 다 지나간다** — 차이가 0.5 넘으면 1 을 더하거나 빼서 짧은 쪽으로 돈다.
- [ ] **판 캔버스를 매 프레임 다시 그리면 텍스처 업로드로 버벅인다** — 값이 바뀐 때(dirty)만, 그것도 0.12초 간격 이상으로.

## 완성 기준 체크리스트
- [ ] 받침대 위 효과가 불덩이 → 얼음 → 독 → 비전으로 부드럽게 바뀌고, 오른쪽 JSON 이 함께 바뀐다
- [ ] 조절판 값을 바꾸면 이름이 「내 효과」가 되고 효과 · JSON 이 바로 바뀐다
- [ ] 「JSON 복사」로 복사한 글에 emitter · particle · curves · layers 가 들어 있다
- [ ] 폰에서도 끊김 없이

## 이 기술 정보
- id: `i196` · 분류: 이펙트 · 연출 › 스킬 VFX (마법 · 미사일) · 3D · 난이도 어려움 · 폰 부담 가벼움 (폰 OK) — 효과 자체는 입자 방출(최대 900 + 연기 220 + 불티 300). 판 캔버스는 값이 바뀔 때만 다시 그린다.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i196
- 쓰면 좋을 때: 비슷한 효과를 여러 개 만들어야 할 때 — 코드 하나 + 값 여럿 / 디자이너 · 다른 사람이 효과를 다듬어야 할 때
- 쓰지 말 때: 효과가 한두 개뿐 — 편집기 만드는 품이 더 든다 / 모양 자체가 다른 효과(번개 · 보호막) — 값 묶음으론 안 된다. 그런 건 따로 코드

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### i196 견본 항목 — `src/demos/demosSkillB.ts:2575`
```ts
  i196: {
    kind: '3d',
    caption: '방출기 · 수명 곡선 · 층(핵 · 불꽃 · 연기 · 불티 · 바닥빛)을 값으로 조합 — 오른쪽은 같은 효과의 JSON',
    make() {
      const A = arena({ cam: [1.7, 5.4, 6.6], look: [1.7, 0.9, 0], fx: 900, st: 300 });
      const smoke = A.addPool(new Pool(220, { normal: true, map: A.tx.puff() }));
      smoke.obj.renderOrder = 4;
      // 받침대
      const ped = new THREE.Mesh(new THREE.CylinderGeometry(0.45, 0.6, 0.5, 8), new THREE.MeshStandardMaterial({ color: 0x3a3640, roughness: 0.7, metalness: 0.2 }));
      ped.position.y = 0.25;
      const cap = new THREE.Mesh(new THREE.TorusGeometry(0.42, 0.05, 6, 24), new THREE.MeshStandardMaterial({ color: 0xc8a050, roughness: 0.4, metalness: 0.7 }));
      cap.rotation.x = Math.PI / 2;
      cap.position.y = 0.5;
      A.scene.add(ped, cap);
      const groundG = decal(A.tx.glow(), 3.6, [1, 1, 1], 0.8);
      A.scene.add(groundG);
      const L = new THREE.PointLight(0xffffff, 20, 7, 2);
      L.position.set(0, 1.3, 0.3);
      A.scene.add(L);
      interface FX { rate: number; speed: number; size: number; life: number; hue: number; gravity: number; spread: number; core: boolean; flame: boolean; smoke: boolean; sparks: boolean; ground: boolean }
      const PRESETS: { name: string; fx: FX }[] = [
        { name: '불덩이', fx: { rate: 70, speed: 1.6, size: 0.9, life: 0.9, hue: 0.06, gravity: -1.4, spread: 0.35, core: true, flame: true, smoke: true, sparks: true, ground: true } },
        { name: '얼음 결정', fx: { rate: 55, speed: 2.4, size: 0.5, life: 1.1, hue: 0.55, gravity: 1.8, spread: 0.9, core: true, flame: true, smoke: false, sparks: true, ground: true } },
        { name: '독 샘', fx: { rate: 40, speed: 0.9, size: 1.2, life: 1.6, hue: 0.3, gravity: -0.5, spread: 0.6, core: false, flame: true, smoke: true, sparks: false, ground: true } },
        { name: '비전 소용돌이', fx: { rate: 80, speed: 2.8, size: 0.6, life: 0.8, hue: 0.78, gravity: 0, spread: 1, core: true, flame: true, smoke: false, sparks: true, ground: true } },
      ];
      const cur: FX = { ...PRESETS[0]!.fx };
      let auto = true, pi = 0, presetName = PRESETS[0]!.name, lastSwitch = 0, dirty = true, lastDraw = -1;
      // 오른쪽 판 (JSON · 노드)
      const hud = new THREE.Scene();
      const hudCam = new THREE.OrthographicCamera(0, 1, 1, 0, -1, 1);
      const pc = document.createElement('canvas');
      pc.width = 512;
      pc.height = 640;
      const ptex = new THREE.CanvasTexture(pc);
      ptex.colorSpace = THREE.SRGBColorSpace;
      ptex.minFilter = THREE.LinearFilter;
      const panel = new THREE.Mesh(new THREE.PlaneGeometry(1, 1), new THREE.MeshBasicMaterial({ map: ptex, transparent: true, toneMapped: false, depthTest: false }));
      hud.add(panel);
      let PW = 0, PH = 0;
      const FONT = '"Pretendard Variable", Pretendard, system-ui, sans-serif';
      const MONO = 'Consolas, "D2Coding", monospace';
      const json = (): string =>
        JSON.stringify(
          {
            effect: presetName,
            emitter: { shape: 'cone', rate: Math.round(cur.rate), spread: +cur.spread.toFixed(2), speed: +cur.speed.toFixed(2) },
            particle: { life: +cur.life.toFixed(2), size: +cur.size.toFixed(2), gravity: +cur.gravity.toFixed(2), hue: +cur.hue.toFixed(2) },
            curves: { size: [0, 1, 0.3], alpha: [0, 1, 0], color: 'white→hue→dark' },
            layers: { core: cur.core, flame: cur.flame, smoke: cur.smoke, sparks: cur.sparks, ground: cur.ground },
          },
          null,
          1,
        ).replace(/\[\s+([^\]]*?)\s+\]/g, (_m, inner: string) => '[' + inner.replace(/\s+/g, ' ') + ']');
      const drawPanel = (): void => {
        const W = pc.width, H = pc.height;
        const g = pc.getContext('2d')!;
        const s = W / 360;
        g.clearRect(0, 0, W, H);
        g.fillStyle = 'rgba(14,12,24,0.88)';
        g.strokeStyle = 'rgba(160,140,255,0.45)';
        g.lineWidth = 2 * s;
        g.beginPath();
        g.roundRect(s, s, W - 2 * s, H - 2 * s, 12 * s);
        g.fill();
        g.stroke();
        const col = (h: number, l = 60): string => `hsl(${Math.round(h * 360)},90%,${l}%)`;
        g.fillStyle = '#fff';
        g.font = `800 ${16 * s}px ${FONT}`;
        g.textBaseline = 'middle';
        g.fillText('효과 편집기', 14 * s, 20 * s);
        g.fillStyle = col(cur.hue, 70);
        g.font = `700 ${12 * s}px ${FONT}`;
        g.fillText('· ' + presetName, 108 * s, 21 * s);
        // 노드 셋
        const nodes = ['방출기', '수명 곡선', '층'];
        const nw = 98 * s, nh = 56 * s, ny = 38 * s;
        nodes.forEach((nm, i) => {
          const x = 12 * s + i * (nw + 18 * s);
          g.fillStyle = 'rgba(255,255,255,0.06)';
          g.strokeStyle = i === 0 ? col(cur.hue) : 'rgba(255,255,255,0.25)';
          g.lineWidth = 1.5 * s;
          g.beginPath();
          g.roundRect(x, ny, nw, nh, 7 * s);
          g.fill();
          g.stroke();
          g.fillStyle = '#e8e4ff';
          g.font = `700 ${10.5 * s}px ${FONT}`;
          g.fillText(nm, x + 7 * s, ny + 10 * s);
          if (i < 2) {
            g.strokeStyle = 'rgba(200,190,255,0.6)';
            g.beginPath();
            g.moveTo(x + nw, ny + nh / 2);
            g.lineTo(x + nw + 18 * s, ny + nh / 2);
            g.stroke();
          }
          if (i === 0) {
            // 원뿔 모양 + 방출량 막대
            g.strokeStyle = col(cur.hue);
            g.beginPath();
            g.moveTo(x + 22 * s, ny + 48 * s);
            g.lineTo(x + 22 * s - 18 * s * cur.spread, ny + 22 * s);
            g.moveTo(x + 22 * s, ny + 48 * s);
            g.lineTo(x + 22 * s + 18 * s * cur.spread, ny + 22 * s);
            g.stroke();
            g.fillStyle = col(cur.hue);
            g.fillRect(x + 48 * s, ny + 40 * s, 42 * s * clamp(cur.rate / 120, 0, 1), 5 * s);
            g.fillStyle = 'rgba(255,255,255,0.15)';
            g.fillRect(x + 48 * s, ny + 30 * s, 42 * s * clamp(cur.speed / 3, 0, 1), 5 * s);
          } else if (i === 1) {
            g.lineWidth = 1.8 * s;
            g.strokeStyle = col(cur.hue);
            g.beginPath();
            for (let k = 0; k <= 20; k++) {
              const u = k / 20;
              const y = Math.sin(Math.PI * u) * (1 - u * 0.3);
              const px = x + 8 * s + u * (nw - 16 * s), py = ny + 50 * s - y * 28 * s;
              if (k) g.lineTo(px, py);
              else g.moveTo(px, py);
            }
            g.stroke();
            g.strokeStyle = 'rgba(255,255,255,0.55)';
            g.beginPath();
            for (let k = 0; k <= 20; k++) {
              const u = k / 20;
              const y = lerp(0.3, 1, Math.min(1, u * 3)) * (1 - u * 0.5);
              const px = x + 8 * s + u * (nw - 16 * s), py = ny + 50 * s - y * 28 * s;
              if (k) g.lineTo(px, py);
              else g.moveTo(px, py);
            }
            g.stroke();
          } else {
            const ls: [string, boolean][] = [['핵', cur.core], ['불꽃', cur.flame], ['연기', cur.smoke], ['불티', cur.sparks], ['바닥', cur.ground]];
            ls.forEach(([n, on], j) => {
              const cx = x + 7 * s + (j % 3) * 30 * s, cy = ny + 22 * s + Math.floor(j / 3) * 17 * s;
              g.fillStyle = on ? col(cur.hue, 45) : 'rgba(255,255,255,0.08)';
              g.beginPath();
              g.roundRect(cx, cy, 27 * s, 13 * s, 6 * s);
              g.fill();
              g.fillStyle = on ? '#fff' : 'rgba(255,255,255,0.35)';
              g.font = `700 ${8.5 * s}px ${FONT}`;
              g.textAlign = 'center';
              g.fillText(n, cx + 13.5 * s, cy + 7 * s);
              g.textAlign = 'left';
            });
          }
        });
        // JSON
        const lines = json().split('\n');
        g.font = `500 ${10.5 * s}px ${MONO}`;
        let y = 112 * s;
        g.fillStyle = 'rgba(255,255,255,0.05)';
        g.fillRect(10 * s, y - 10 * s, W - 20 * s, H - y);
        for (const ln of lines) {
          if (y > H - 8 * s) break;
          const m = /^(\s*)"([^"]+)":\s?(.*)$/.exec(ln);
          let x = 16 * s;
          if (m) {
            const ind = m[1]!.length * 10 * s;
            x += ind;
            g.fillStyle = '#9ad0ff';
            const key = `"${m[2]}": `;
            g.fillText(key, x, y);
            x += g.measureText(key).width;
            const val = m[3]!;
            g.fillStyle = /true|false/.test(val) ? '#ff9ad0' : /^"/.test(val) ? '#b8ff9a' : /^[{[]/.test(val) ? '#d8d4ff' : '#ffd48a';
            g.fillText(val, x, y);
          } else {
            g.fillStyle = '#d8d4ff';
            g.fillText(ln, x, y);
          }
          y += 14.5 * s;
        }
        ptex.needsUpdate = true;
      };
      const fAcc = { acc: 0 }, sAcc = { acc: 0 }, kAcc = { acc: 0 };
      const E: V3 = [0, 1.05, 0];
      let clock = 0;
      const setPreset = (i: number): void => {
        pi = i;
        presetName = PRESETS[i]!.name;
        lastSwitch = clock;
        dirty = true;
      };
      return base3d(A, (t, dt) => {
        clock = t;
        if (auto) {
          if (t - lastSwitch > 3.6) setPreset((pi + 1) % PRESETS.length);
          const tg = PRESETS[pi]!.fx;
          const k = 1 - Math.exp(-dt * 4);
          for (const key of ['rate', 'speed', 'size', 'life', 'hue', 'gravity', 'spread'] as const) {
            const a = cur[key];
            let b = tg[key];
            if (key === 'hue' && Math.abs(b - a) > 0.5) b += b < a ? 1 : -1;
            cur[key] = key === 'hue' ? (((lerp(a, b, k)) % 1) + 1) % 1 : lerp(a, b, k);
          }
          for (const key of ['core', 'flame', 'smoke', 'sparks', 'ground'] as const) cur[key] = tg[key];
          if (t - lastSwitch < 1.2) dirty = true;
        }
        const hot = HSL(cur.hue, 1, 0.6, 2.4);
        const mid = HSL(cur.hue, 1, 0.5, 1.4);
        const dark = HSL(cur.hue + 0.02, 1, 0.3, 0.5);
        if (cur.flame) for (let i = rate(fAcc, cur.rate, dt); i > 0; i--) {
          const d = norm3([rr(-1, 1) * cur.spread, 1, rr(-1, 1) * cur.spread]);
          const sp = cur.speed * rr(0.6, 1.2);
          const sw: V3 = cur.hue > 0.7 ? [Math.cos(t * 3) * 0.8, 0, Math.sin(t * 3) * 0.8] : [0, 0, 0];
          A.fx.emit({ p: add3(E, sc3(randDir(), 0.12)), v: add3(sc3(d, sp), sw), life: cur.life * rr(0.7, 1.2), size: cur.size * 0.5, size2: cur.size * 1.1, c: hot, c2: dark, a: 0.5 / Math.max(1, cur.size * 1.3), grav: cur.gravity, drag: 0.8 });
        }
        if (cur.smoke) for (let i = rate(sAcc, cur.rate * 0.2, dt); i > 0; i--) {
          smoke.emit({ p: add3(E, [rr(-0.2, 0.2), 0.5, rr(-0.2, 0.2)]), v: [rr(-0.2, 0.2), cur.speed * 0.6, rr(-0.2, 0.2)], life: cur.life * 2.2, size: cur.size * 0.8, size2: cur.size * 2.4, c: sc3(HSL(cur.hue, 0.4, 0.12), 1), a: 0.45, fadeIn: 0.3, spin: rr(-0.6, 0.6) });
        }
        if (cur.sparks) for (let i = rate(kAcc, cur.rate * 0.35, dt); i > 0; i--) {
          const d = norm3([rr(-1, 1) * (cur.spread + 0.4), 1, rr(-1, 1) * (cur.spread + 0.4)]);
          A.st.spark({ p: E, v: sc3(d, cur.speed * rr(1.6, 2.6)), life: cur.life * rr(0.6, 1.1), c: C(0xffffff, 2.5), c2: mid, len: 0.04, w: 0.02, grav: Math.max(cur.gravity, 0) + 3, drag: 0.6, fadeIn: 0 });
        }
        if (cur.core) orbGlow(A.fx, E, mid, 0.18 + cur.size * 0.18, 1);
        groundG.visible = cur.ground;
        groundG.material.color.setRGB(mid[0], mid[1], mid[2]);
        groundG.material.opacity = 0.6 + Math.sin(t * 9) * 0.05;
        L.color.setRGB(mid[0] / 1.4, mid[1] / 1.4, mid[2] / 1.4);
        L.intensity = cur.ground ? 22 : 8;
        if (dirty && t - lastDraw > 0.12 && PW > 0) {
          drawPanel();
          lastDraw = t;
          dirty = false;
        }
        A.fx.step(dt);
        smoke.step(dt);
        A.st.step(dt);
        A.tickCam(dt);
      }, [
        { type: 'toggle', label: '자동 프리셋 돌리기', value: auto, on: (v) => (auto = v) },
        { type: 'button', label: '다음 프리셋', on: () => { setPreset((pi + 1) % PRESETS.length); Object.assign(cur, PRESETS[pi]!.fx); } },
        { type: 'range', label: '방출량 (개/초)', min: 5, max: 150, step: 1, value: cur.rate, on: (v) => { auto = false; cur.rate = v; presetName = '내 효과'; dirty = true; } },
        { type: 'range', label: '크기', min: 0.2, max: 2, step: 0.05, value: cur.size, on: (v) => { auto = false; cur.size = v; presetName = '내 효과'; dirty = true; } },
        { type: 'range', label: '색조', min: 0, max: 1, step: 0.01, value: cur.hue, on: (v) => { auto = false; cur.hue = v; presetName = '내 효과'; dirty = true; } },
        { type: 'range', label: '중력 (− 는 위로)', min: -3, max: 4, step: 0.1, value: cur.gravity, on: (v) => { auto = false; cur.gravity = v; presetName = '내 효과'; dirty = true; } },
        { type: 'toggle', label: '연기 층', value: cur.smoke, on: (v) => { auto = false; cur.smoke = v; dirty = true; } },
        { type: 'toggle', label: '불티 층', value: cur.sparks, on: (v) => { auto = false; cur.sparks = v; dirty = true; } },
        { type: 'button', label: 'JSON 복사', on: () => { void navigator.clipboard?.writeText(json()).catch(() => undefined); } },
      ], {
        resize(w, h) {
          A.resize(w, h);
          const pw = Math.round(w * 0.4), ph = Math.round(h * 0.92);
          if (Math.abs(pw - PW) > 3 || Math.abs(ph - PH) > 3) {
            PW = pw;
            PH = ph;
            const sc = Math.max(1, 720 / Math.max(pw, 1));
            pc.width = Math.round(pw * Math.min(sc, 2));
            pc.height = Math.round(ph * Math.min(sc, 2));
            ptex.dispose();
            dirty = true;
            drawPanel();
          }
          panel.scale.set(0.4, 0.92, 1);
          panel.position.set(1 - 0.2 - 0.012, 0.5, 0);
        },
        render(r) {
          r.render(A.scene, A.cam);
          const ac = r.autoClear;
          r.autoClear = false;
          r.clearDepth();
          r.render(hud, hudCam);
          r.autoClear = ac;
        },
        dispose() {
          A.dispose();
          disposeAll(hud);
        },
      });
    },
  }
```

## 관련 기술
- 먼저 알면 좋은 기술: [입자 방출기 (수명 곡선)](https://ai-techstudio.web.app/ai/t/i170.md) `i170` · [층 쌓기 (핵 · 빛무리 · 불티 · 연기)](https://ai-techstudio.web.app/ai/t/i171.md) `i171`
