# AI 꾸러미 — 그라인더 불똥 소나기 (튀며 쪼개지는 불똥 · 뜨거운 자국)
> 닿는 곳에서 속도 방향으로 늘어난 불똥 수백 개 — 하얗게 뜨겁다가 빨갛게 식고, 바닥에 튀며 둘로 쪼개짐 + 쇠판에 지나간 자리가 노랗게 달아올랐다 식는 열 지도  
> 견본: https://ai-techstudio.web.app/#t/i509

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

## 주문서

### 만들어 줘: 그라인더 불똥 소나기 (튀며 쪼개지는 불똥 · 뜨거운 자국)

#### 1. 목표
작업장에 「그라인더 불똥 소나기 (튀며 쪼개지는 불똥 · 뜨거운 자국)」 기술을 깔끔한 기본 느낌으로 넣어 줘. — 닿는 곳에서 속도 방향으로 늘어난 불똥 수백 개 — 하얗게 뜨겁다가 빨갛게 식고, 바닥에 튀며 둘로 쪼개짐 + 쇠판에 지나간 자리가 노랗게 달아올랐다 식는 열 지도

#### 2. 핵심 기술 용어
- 그라인더 불똥 소나기 (튀며 쪼개지는 불똥 · 뜨거운 자국) — 닿는 곳에서 속도 방향으로 늘어난 불똥 수백 개 — 하얗게 뜨겁다가 빨갛게 식고, 바닥에 튀며 둘로 쪼개짐 + 쇠판에 지나간 자리가 노랗게 달아올랐다 식는 열 지도

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

#### 4. 조건
- 한 프레임 16ms 안 (폰 포함) — 무거운 계산은 처음에 한 번 또는 여러 프레임에 나눠서
- 새 재질 · 셰이더는 첫 화면 전에 미리 컴파일 (renderer.compileAsync)
- 화면 가장자리 흐림 · 비네트로 얼버무리지 않기
- 외부 라이브러리 추가 없이 three.js 와 three/examples 만

#### 5. 완성 기준 (이게 보이면 성공)
- 견본처럼 「닿는 곳에서 속도 방향으로 늘어난 불똥 수백 개 — 하얗게 뜨겁다가 빨갛게 식고, 바닥에 튀며 둘로 쪼개짐 + 쇠판에 지나간 자리가 노랗게 달아올랐다 식는 열 지도」 이(가) 첫 화면에서 바로 보인다
- 켬/끔(또는 전/후) 비교 단추로 이 기술이 만드는 차이를 확인할 수 있다
- 중요한 값 1~3개를 슬라이더로 바꿔 볼 수 있다
- 콘솔 오류 0, 폰 폭(390px)에서도 동작한다

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

## 원리
- 닿는 곳에서 속도 방향으로 늘어난 불똥 수백 개 — 하얗게 뜨겁다가 빨갛게 식고, 바닥에 튀며 둘로 쪼개짐 + 쇠판에 지나간 자리가 노랗게 달아올랐다 식는 열 지도


## 흔한 실수 · 확인 목록
- (아직 정리된 것이 없어요)

## 완성 기준 체크리스트
- [ ] 견본처럼 「닿는 곳에서 속도 방향으로 늘어난 불똥 수백 개 — 하얗게 뜨겁다가 빨갛게 식고, 바닥에 튀며 둘로 쪼개짐 + 쇠판에 지나간 자리가 노랗게 달아올랐다 식는 열 지도」 이(가) 첫 화면에서 바로 보인다
- [ ] 켬/끔(또는 전/후) 비교 단추로 이 기술이 만드는 차이를 확인할 수 있다
- [ ] 중요한 값 1~3개를 슬라이더로 바꿔 볼 수 있다
- [ ] 콘솔 오류 0, 폰 폭(390px)에서도 동작한다

## 이 기술 정보
- id: `i509` · 분류: 이펙트 · 연출 › 입자 · 연출 · 3D · 난이도 보통 · 폰 부담 보통 (폰 주의)
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i509
- 쓰면 좋을 때: 쓰면 좋을 곳 — 작업장 · 로봇 · 금속 자르기

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### i509 견본 항목 — `src/demos/demosVfx3.ts:2502`
```ts
  i509: {
    kind: '3d',
    caption: '닿는 곳에서 늘어난 불똥 수백 개 — 하얗게 뜨겁다 빨갛게 식고, 바닥에 튀며 쪼개짐 · 쇠판에 지나간 자리가 달아올랐다 식음',
    make(): Scene3D {
      const S = makeStage({ bg: 0x0a0b0e, cam: [0.9, 1.5, 3.3], look: [-0.55, 0.78, 0], fov: 44, fog: [7, 18] });
      const lab = S.hud.label('tl');
      S.scene.add(new THREE.HemisphereLight(0x8090a8, 0x101010, 0.6));
      const key = new THREE.DirectionalLight(0xa0b4d0, 0.9);
      key.position.set(-2, 4, 3);
      S.scene.add(key);
      const con = groundCanvas('v3con', '#5a544e', [['rgba(0,0,0,0.25)', 5000, 1.5], ['rgba(255,255,255,0.06)', 3000, 1.2]], 41);
      const floor = new THREE.Mesh(new THREE.PlaneGeometry(30, 30), new THREE.MeshStandardMaterial({ map: texFrom(con, true, 8), roughness: 0.75, color: 0x6a6a6a }));
      floor.rotation.x = -Math.PI / 2;
      S.scene.add(floor);
      const steel = new THREE.MeshStandardMaterial({ color: 0x5a5e66, roughness: 0.45, metalness: 0.8 });
      for (const x of [-0.95, 0.95]) {
        const leg = new THREE.Mesh(new THREE.BoxGeometry(0.08, 0.78, 0.5), steel);
        leg.position.set(x, 0.39, 0);
        S.scene.add(leg);
      }
      const HW = 128;
      const heat = new Float32Array(HW);
      const heatData = new Uint8Array(HW * 4);
      const heatTex = new THREE.DataTexture(heatData, HW, 1, THREE.RGBAFormat);
      heatTex.magFilter = THREE.LinearFilter;
      heatTex.needsUpdate = true;
      const plateMat = new THREE.ShaderMaterial({
        uniforms: { uHeat: { value: heatTex } },
        vertexShader: WORLD_VS,
        fragmentShader: /* glsl */ `uniform sampler2D uHeat; varying vec3 vW; varying vec3 vN;
          ${SHADE_GLSL}
          void main(){ vec3 N = normalize(vN); vec3 V = normalize(cameraPosition - vW);
            float u = (vW.x + 1.2) / 2.4;
            float h = texture2D(uHeat, vec2(u, 0.5)).r * 1.3;
            float groove = exp(-pow(vW.z / 0.035, 2.0));
            float brushed = 0.92 + 0.08 * sin(vW.x * 300.0 + sin(vW.z * 40.0) * 2.0);
            vec3 base = vec3(0.42, 0.44, 0.48) * brushed;
            base = mix(base, vec3(0.12, 0.1, 0.1), groove * 0.6);
            vec3 c = shade(base, N, V, normalize(vec3(0.3, 0.9, 0.4)), vec3(0.7, 0.72, 0.8));
            vec3 Rf = reflect(-V, N); c += vec3(0.05, 0.06, 0.08) * smoothstep(0.0, 1.0, Rf.y);
            float hh = h * (groove * 1.0 + exp(-pow(vW.z / 0.12, 2.0)) * 0.35);
            vec3 hc = mix(vec3(0.6, 0.04, 0.0), vec3(1.0, 0.35, 0.02), smoothstep(0.25, 0.6, hh));
            hc = mix(hc, vec3(1.0, 0.85, 0.5) * 2.5, smoothstep(0.65, 1.0, hh));
            c += hc * smoothstep(0.05, 0.4, hh) * 2.0;
            gl_FragColor = vec4(c, 1.0); ${TONE} }`,
      });
      const plate = new THREE.Mesh(new THREE.BoxGeometry(2.4, 0.05, 0.55), plateMat);
      plate.position.set(0, 0.8, 0);
      S.scene.add(plate);
      // 그라인더
      const disk = cachedCanvas('v3disk', 256, 256, (g, s) => {
        const r = rng(3);
        g.fillStyle = '#3a3a40';
        g.beginPath();
        g.arc(s / 2, s / 2, s / 2, 0, TAU);
        g.fill();
        for (let i = 0; i < 400; i++) {
          g.fillStyle = r() < 0.5 ? 'rgba(0,0,0,0.3)' : 'rgba(200,200,210,0.2)';
          const a = r() * TAU;
          const d = r() * s * 0.48;
          g.fillRect(s / 2 + Math.cos(a) * d, s / 2 + Math.sin(a) * d, 2, 2);
        }
        g.strokeStyle = 'rgba(220,220,230,0.35)';
        g.lineWidth = 6;
        g.beginPath();
        g.arc(s / 2, s / 2, s * 0.2, 0, TAU);
        g.stroke();
        g.fillStyle = '#c0a040';
        g.font = 'bold 22px sans-serif';
        g.fillText('A60', s * 0.58, s * 0.5);
      });
      const grinder = new THREE.Group();
      const diskM = new THREE.Mesh(new THREE.CylinderGeometry(0.22, 0.22, 0.02, 48), [new THREE.MeshStandardMaterial({ color: 0x2a2a2e, roughness: 0.8 }), new THREE.MeshStandardMaterial({ map: texFrom(disk, true), roughness: 0.9, color: 0x9a9aa0 }), new THREE.MeshStandardMaterial({ map: texFrom(disk, true), roughness: 0.9, color: 0x9a9aa0 })]);
      diskM.rotation.x = Math.PI / 2;
      const spin = new THREE.Group();
      spin.add(diskM);
      const guard = new THREE.Mesh(new THREE.CylinderGeometry(0.25, 0.25, 0.06, 32, 1, true, -0.2, Math.PI + 0.4), new THREE.MeshStandardMaterial({ color: 0x9aa0a8, roughness: 0.4, metalness: 0.8, side: THREE.DoubleSide }));
      guard.rotation.x = Math.PI / 2;
      guard.rotation.y = Math.PI;
      guard.position.z = -0.03;
      const head = new THREE.Mesh(new THREE.CylinderGeometry(0.07, 0.08, 0.14, 20), steel);
      head.rotation.x = Math.PI / 2;
      head.position.z = -0.1;
      const body = new THREE.Mesh(new THREE.CapsuleGeometry(0.075, 0.42, 6, 16), new THREE.MeshStandardMaterial({ color: 0x1a7ac8, roughness: 0.45 }));
      body.rotation.z = Math.PI / 2;
      body.position.set(0.32, 0, -0.14);
      const grip = new THREE.Mesh(new THREE.CapsuleGeometry(0.035, 0.16, 4, 10), new THREE.MeshStandardMaterial({ color: 0x1a1a1c, roughness: 0.8 }));
      grip.position.set(0.05, 0.12, -0.14);
      grinder.add(spin, guard, head, body, grip);
      grinder.rotation.z = -0.25;
      grinder.scale.setScalar(1.25);
      S.scene.add(grinder);
      const light = new THREE.PointLight(0xffa040, 0, 7, 1.5);
      S.scene.add(light);
      const floorGlow = glowDisc(0xff7020, 0, 3, 3);
      S.scene.add(floorGlow.mesh);
      const sparks = S.pool(3600, 4);
      const orb = S.pool(16, 5);
      const smoke = S.pool(200, 2);
      sparks.floorY = -10;
      const SPARK = ramp([[0, 0xffffff, 9, 1, 1], [0.15, 0xfff0b0, 6, 1, 1], [0.45, 0xffa030, 3.5, 1, 1], [0.8, 0xe03a08, 1.6, 0.8, 1], [1, 0x601000, 0.6, 0, 1]]);
      const CORE = flat(0xfff4d8, 8, 1);
      const HALO = flat(0xff8a30, 1.4, 0.5);
      const SMK = ramp([[0, 0x6a6460, 1, 0, 0], [0.2, 0x5a5654, 1, 0.25, 0], [1, 0x3a3836, 1, 0, 0]]);
      let amt = 1;
      let split = true;
      let rpm = 1;
      sparks.onStep = (p, i) => {
        if (p.y[i]! < 0.015 && p.vy[i]! < 0) {
          p.y[i] = 0.015;
          p.vy[i] = -p.vy[i]! * 0.42;
          p.vx[i] = p.vx[i]! * 0.62;
          p.vz[i] = p.vz[i]! * 0.62;
          if (split && p.u[i]! < 1 && sparks.n < sparks.cap - 2) {
            p.u[i] = 1;
            const k = sparks.spawn({ x: p.x[i]!, y: 0.02, z: p.z[i]!, vx: p.vx[i]! * R(0.6, 1.1) + R(-0.8, 0.8), vy: p.vy[i]! * R(0.6, 1.2), vz: p.vz[i]! + R(-1, 1), life: (p.life[i]! - p.age[i]!) * 0.8, s0: p.s0[i]! * 0.7, ramp: SPARK, st: 0.04, shape: 2, drag: 0.8, grav: 9.8, tag: 1, u: 1 });
            if (k >= 0) sparks.age[k] = p.age[i]!;
          }
        }
      };
      const controls: Control[] = [
        ctrlRange('불똥 양', 0.2, 2, 0.05, 1, (v) => (amt = v)),
        ctrlTog('바닥에 튀며 쪼개짐', true, (v) => (split = v)),
        ctrlRange('회전 세기', 0.5, 1.6, 0.05, 1, (v) => (rpm = v)),
      ];
      let cyc = 0;
      let acc = 0;
      const CP = new THREE.Vector3();
      return {
        scene: S.scene,
        camera: S.cam,
        controls,
        update(t, dt0) {
          const dt = Math.min(dt0, 0.05);
          cyc = (cyc + dt) % 5;
          let gx: number;
          let lift = 0;
          if (cyc < 3.4) gx = lerp(-0.75, 0.75, smooth(cyc / 3.4));
          else {
            const k = (cyc - 3.4) / 1.6;
            gx = lerp(0.75, -0.75, smooth(k));
            lift = Math.sin(Math.PI * k) * 0.35 + 0.02;
          }
          const on = lift < 0.025;
          CP.set(gx, 0.826, 0);
          grinder.position.set(gx + Math.sin(0.25) * 0.275, 0.826 + Math.cos(0.25) * 0.275 + lift + (on ? Math.sin(t * 80) * 0.002 : 0), 0);
          spin.rotation.z -= dt * 60 * rpm;
          orb.clear();
          if (on) {
            acc += dt * 280 * amt;
            while (acc >= 1) {
              acc -= 1;
              const sp = R(5, 10) * rpm;
              const a = R(-0.15, 0.55);
              sparks.spawn({ x: CP.x, y: CP.y + 0.005, z: CP.z + R(-0.01, 0.01), vx: -Math.cos(a) * sp, vy: Math.sin(a) * sp, vz: R(-1.6, 1.6), life: R(0.6, 1.5), s0: R(0.022, 0.04), ramp: SPARK, st: 0.06, shape: 2, drag: 0.6, grav: 9.8, tag: 1 });
            }
            if (Math.random() < dt * 8) smoke.spawn({ x: CP.x, y: CP.y + 0.05, z: CP.z, vx: R(-0.1, 0.1), vy: R(0.3, 0.6), life: R(1.2, 2), s0: 0.08, s1: 0.5, ramp: SMK, shape: 1, rot: R(-0.4, 0.4), drag: 0.5 });
            orb.spawn({ x: CP.x, y: CP.y + 0.01, z: CP.z + 0.02, life: 1, s0: 0.09, ramp: CORE, shape: 0, br: 0.8 + Math.random() * 0.4 });
            orb.spawn({ x: CP.x, y: CP.y + 0.01, z: CP.z + 0.02, life: 1, s0: 0.55, ramp: HALO, shape: 0, br: 0.8 + Math.random() * 0.4 });
            const ix = ((gx + 1.2) / 2.4) * HW;
            for (let k = Math.floor(ix - 3); k <= Math.ceil(ix + 3); k++) if (k >= 0 && k < HW) heat[k] = Math.min(1, heat[k]! + dt * 3.2 * Math.exp(-Math.pow((k - ix) / 1.6, 2)));
            lab.set('갈기 — 불똥이 하얗게 튀어 빨갛게 식으며 바닥에서 쪼개짐', '#ffb050');
          } else lab.set('들어 올림 — 지나간 자리가 노랗게 달았다가 식음', '#ff7030');
          const decay = Math.exp(-dt * 0.5);
          for (let k = 0; k < HW; k++) {
            heat[k] = heat[k]! * decay;
            heatData[k * 4] = Math.round(clamp(heat[k]!, 0, 1) * 255);
            heatData[k * 4 + 3] = 255;
          }
          heatTex.needsUpdate = true;
          light.position.set(CP.x - 0.3, CP.y + 0.03, CP.z - 0.12);
          light.intensity = on ? (5 + Math.random() * 3) * Math.min(1.4, amt) : 0;
          floorGlow.mesh.position.set(gx - 1.0, 0.015, 0);
          floorGlow.set(on ? 0.35 * amt : 0);
          sparks.tick(dt);
          orb.upload();
          smoke.tick(dt);
          S.tick(dt);
        },
        resize: (w, h) => S.resize(w, h),
        dispose: () => {
          S.dispose();
          heatTex.dispose();
        },
      };
    },
  }
```

## 관련 기술
- 이 효과를 만든 기술: [입자 방출기 (수명 곡선)](https://ai-techstudio.web.app/ai/t/i170.md) `i170`
