# AI 꾸러미 — 꽃잎 · 낙엽 바람에 흩날림
> 꽃잎마다 돌며 팔랑(회전축 · 펄럭) + 지나가는 돌풍이 잔디를 눕히고 땅에 떨어진 꽃잎을 다시 들어 올림 + 바람 줄기 — 벚꽃 ↔ 단풍  
> 견본: https://ai-techstudio.web.app/#t/i515

이 문서를 코드 도우미(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: `i515` · 분류: 이펙트 · 연출 › 입자 · 연출 · 3D · 난이도 보통 · 폰 부담 보통 (폰 주의)
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i515
- 쓰면 좋을 때: 쓰면 좋을 곳 — 봄 · 가을 분위기 · 장면 전환 · 바람 표현

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### i515 견본 항목 — `src/demos/demosVfx3.ts:3907`
```ts
  i515: {
    kind: '3d',
    caption: '꽃잎마다 돌며 팔랑 — 지나가는 돌풍이 잔디를 눕히고 땅에 떨어진 꽃잎을 다시 들어 올림 (벚꽃 ↔ 단풍)',
    make(): Scene3D {
      const S = makeStage({ bg: 0xc4dcef, cam: [0.4, 1.7, 8.2], look: [0, 1.5, 0], fov: 46, fog: [16, 48] });
      S.scene.add(skyDome(0x6aa8e0, 0xf2e6ee, 0x8aa070, 0, { dir: [0.5, 0.6, -0.6], col: 0xfff4e0, pow: 10, I: 0.5 }));
      const lab = S.hud.label('tl');
      S.scene.add(new THREE.HemisphereLight(0xffffff, 0x6a8a50, 1.1));
      const sunL = new THREE.DirectionalLight(0xfff4e0, 2.2);
      sunL.position.set(5, 8, 4);
      S.scene.add(sunL);
      const grassC = groundCanvas('v3grass', '#5c8c3c', [['rgba(30,70,10,0.35)', 9000, 2], ['rgba(170,210,90,0.3)', 6000, 1.5]], 91);
      const ground = new THREE.Mesh(new THREE.PlaneGeometry(80, 80), new THREE.MeshStandardMaterial({ map: texFrom(grassC, true, 14), roughness: 1 }));
      ground.rotation.x = -Math.PI / 2;
      S.scene.add(ground);
      let windK = 1;
      const GUST = /* glsl */ `uniform float uTime, uWind; float gustX(float t){ return -10.0 + mod(t * 4.2, 26.0); }
        float windAt(vec2 p, float t){ float g = exp(-pow((p.x - gustX(t)) / 2.2, 2.0)); return uWind * (0.35 + 0.25 * sin(t * 1.3 + p.x * 0.4 + p.y * 0.7)) + uWind * 2.2 * g; }`;
      const gustX = (t: number): number => -10 + ((t * 4.2) % 26);
      const windAt = (x: number, z: number, t: number): number => {
        const g = Math.exp(-Math.pow((x - gustX(t)) / 2.2, 2));
        return windK * (0.35 + 0.25 * Math.sin(t * 1.3 + x * 0.4 + z * 0.7)) + windK * 2.2 * g;
      };
      // 잔디 (누우는 풀잎)
      const NG = 2600;
      const bladeGeo = new THREE.PlaneGeometry(0.045, 0.32, 1, 3).translate(0, 0.16, 0);
      {
        const p = bladeGeo.attributes.position!;
        for (let i = 0; i < p.count; i++) {
          const h = p.getY(i) / 0.32;
          p.setX(i, p.getX(i) * (1 - h * 0.85));
        }
      }
      const gOff = new THREE.InstancedBufferAttribute(new Float32Array(NG * 4), 4);
      const rg = rng(31);
      for (let i = 0; i < NG; i++) gOff.setXYZW(i, -8 + rg() * 16, -4.5 + rg() * 8, rg() * TAU, 0.6 + rg() * 0.8);
      bladeGeo.setAttribute('iOff', gOff);
      const grassMat = new THREE.ShaderMaterial({
        uniforms: { uTime: { value: 0 }, uWind: { value: 1 } },
        vertexShader: /* glsl */ `attribute vec4 iOff; varying float vH; varying float vV; ${GUST}
          void main(){ vec3 p = position; float h = p.y / 0.32; vH = h; vV = fract(iOff.z * 7.3);
            p *= iOff.w; float c = cos(iOff.z), s = sin(iOff.z); p.xz = vec2(c * p.x - s * p.z, s * p.x + c * p.z);
            float w = windAt(iOff.xy, uTime);
            float bend = w * h * h * 0.16 + 0.02 * sin(uTime * 3.0 + iOff.x * 2.0) * h;
            p.x += bend; p.y -= bend * bend * 0.6;
            vec3 wp = vec3(iOff.x, 0.0, iOff.y) + p;
            gl_Position = projectionMatrix * viewMatrix * vec4(wp, 1.0); }`,
        fragmentShader: /* glsl */ `varying float vH; varying float vV;
          void main(){ vec3 a = vec3(0.12, 0.25, 0.06); vec3 b = mix(vec3(0.45, 0.68, 0.22), vec3(0.6, 0.75, 0.3), vV);
            gl_FragColor = vec4(mix(a, b, vH), 1.0); ${TONE} }`,
        side: THREE.DoubleSide,
      });
      const grass = new THREE.InstancedMesh(bladeGeo, grassMat, NG);
      grass.frustumCulled = false;
      S.scene.add(grass);
      // 나무
      const tree = new THREE.Group();
      const bark = new THREE.MeshStandardMaterial({ color: 0x4a3426, roughness: 0.9 });
      const trunk = new THREE.Mesh(new THREE.CylinderGeometry(0.14, 0.26, 2.6, 12), bark);
      trunk.position.y = 1.3;
      trunk.rotation.z = 0.06;
      const br1 = new THREE.Mesh(new THREE.CylinderGeometry(0.06, 0.11, 1.4, 8), bark);
      br1.position.set(0.45, 2.5, 0);
      br1.rotation.z = -0.8;
      const br2 = new THREE.Mesh(new THREE.CylinderGeometry(0.05, 0.1, 1.2, 8), bark);
      br2.position.set(-0.4, 2.45, 0.1);
      br2.rotation.z = 0.9;
      tree.add(trunk, br1, br2);
      const canopyMat = new THREE.MeshStandardMaterial({ color: 0xffd4e2, roughness: 0.8, flatShading: true, emissive: 0x4a2a34 });
      const canopyMat2 = new THREE.MeshStandardMaterial({ color: 0xd88aa8, roughness: 0.9, flatShading: true });
      const cr = rng(12);
      const lumps: THREE.Vector3[] = [];
      for (let i = 0; i < 9; i++) {
        const s = 0.42 + cr() * 0.3;
        const m = new THREE.Mesh(new THREE.IcosahedronGeometry(s, 1), canopyMat2);
        m.position.set((cr() - 0.5) * 2.2, 2.9 + (cr() - 0.3) * 0.9, (cr() - 0.5) * 1.2);
        lumps.push(m.position.clone().setY(m.position.y + 0.0001), new THREE.Vector3(s, 0, 0));
        tree.add(m);
      }
      // 꽃송이: 작은 덩이 수백 개를 덩어리 겉면에 소복이
      const NB = 520;
      const blossom = new THREE.InstancedMesh(new THREE.IcosahedronGeometry(0.11, 0), canopyMat, NB);
      {
        const Mb = new THREE.Matrix4();
        const qb = new THREE.Quaternion();
        const pb = new THREE.Vector3();
        const sb = new THREE.Vector3();
        const cb = new THREE.Color();
        for (let i = 0; i < NB; i++) {
          const li = Math.floor(cr() * 9) * 2;
          const c = lumps[li]!;
          const s = lumps[li + 1]!.x;
          const d = sphereDir();
          if (d.y < -0.4) d.y = -d.y * 0.5;
          pb.set(c.x + d.x * s * 1.02, c.y + d.y * s * 1.02, c.z + d.z * s * 1.02);
          qb.setFromEuler(new THREE.Euler(cr() * 6, cr() * 6, cr() * 6));
          sb.setScalar(0.7 + cr() * 0.8);
          Mb.compose(pb, qb, sb);
          blossom.setMatrixAt(i, Mb);
          blossom.setColorAt(i, cb.setScalar(0.82 + cr() * 0.3));
        }
      }
      tree.add(blossom);
      tree.position.set(-3.2, 0, -1.6);
      S.scene.add(tree);
      const CANOPY = new THREE.Vector3(-3.2, 3.0, -1.6);
      // 꽃잎 · 잎 모양
      const petalGeo = (() => {
        const sh = new THREE.Shape();
        sh.moveTo(0, -0.05);
        sh.bezierCurveTo(0.045, -0.03, 0.05, 0.03, 0.02, 0.05);
        sh.lineTo(0, 0.035);
        sh.lineTo(-0.02, 0.05);
        sh.bezierCurveTo(-0.05, 0.03, -0.045, -0.03, 0, -0.05);
        const g = new THREE.ShapeGeometry(sh, 6);
        const p = g.attributes.position!;
        const cA = new THREE.Color(0xffffff);
        const cB = new THREE.Color(0xf48ab0);
        const cols = new Float32Array(p.count * 3);
        for (let i = 0; i < p.count; i++) {
          const x = p.getX(i);
          const y = p.getY(i);
          p.setZ(i, x * x * 9 - y * 0.15);
          const c = cA.clone().lerp(cB, clamp(0.85 - (y + 0.05) * 8, 0, 1));
          cols.set([c.r, c.g, c.b], i * 3);
        }
        g.setAttribute('color', new THREE.BufferAttribute(cols, 3));
        g.computeVertexNormals();
        return g;
      })();
      const leafGeo = (() => {
        const sh = new THREE.Shape();
        const n = 5;
        for (let i = 0; i <= n * 2; i++) {
          const a = (i / (n * 2)) * TAU + Math.PI / 2;
          const r = i % 2 === 0 ? 0.075 : 0.032;
          const x = Math.cos(a) * r;
          const y = Math.sin(a) * r * (a > Math.PI && a < TAU ? 0.75 : 1);
          if (i === 0) sh.moveTo(x, y);
          else sh.lineTo(x, y);
        }
        const g = new THREE.ShapeGeometry(sh);
        const p = g.attributes.position!;
        const cols = new Float32Array(p.count * 3).fill(1);
        for (let i = 0; i < p.count; i++) p.setZ(i, (p.getX(i) * p.getX(i) + p.getY(i) * p.getY(i)) * 5);
        g.setAttribute('color', new THREE.BufferAttribute(cols, 3));
        g.computeVertexNormals();
        return g;
      })();
      const NP = 700;
      const pm = new THREE.InstancedMesh(petalGeo, new THREE.MeshStandardMaterial({ vertexColors: true, roughness: 0.6, side: THREE.DoubleSide, emissive: 0x5a3a44 }), NP);
      pm.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
      pm.frustumCulled = false;
      for (let i = 0; i < NP; i++) pm.setColorAt(i, new THREE.Color(1, 1, 1));
      S.scene.add(pm);
      interface Pt {
        p: THREE.Vector3;
        v: THREE.Vector3;
        ax: THREE.Vector3;
        ang: number;
        ph: number;
        rest: number;
        yaw: number;
        c: THREE.Color;
      }
      const PT: Pt[] = [];
      const LEAFC = [0xd8401a, 0xf08a1a, 0xf0c030, 0xb8541a, 0xc02818].map((h) => new THREE.Color(h));
      const PETALC = [0xffffff, 0xfff0f4, 0xffe0ea];
      let leaves = false;
      let amount = 1;
      const streaks = S.pool(300, 6);
      const STREAK = ramp([[0, 0xffffff, 1.3, 0, 0.6], [0.3, 0xffffff, 1.3, 0.22, 0.6], [1, 0xffffff, 1.2, 0, 0.6]]);
      const setLeaves = (v: boolean): void => {
        leaves = v;
        pm.geometry = v ? leafGeo : petalGeo;
        canopyMat.color.setHex(v ? 0xe8902a : 0xf6c0d4);
        canopyMat2.color.setHex(v ? 0xc8501a : 0xe89ab8);
        for (const p of PT) p.c.copy(v ? LEAFC[Math.floor(Math.random() * LEAFC.length)]! : new THREE.Color(PETALC[Math.floor(Math.random() * 3)]!));
      };
      const controls: Control[] = [
        ctrlRange('바람 세기', 0.2, 2, 0.05, 1, (v) => (windK = v)),
        ctrlTog('단풍잎', false, setLeaves),
        ctrlRange('꽃잎 양', 0.2, 2, 0.05, 1, (v) => (amount = v)),
      ];
      const spawnPetal = (): void => {
        if (PT.length >= NP) {
          let oi = -1;
          let best = -1;
          for (let i = 0; i < PT.length; i++)
            if (PT[i]!.rest > best) {
              best = PT[i]!.rest;
              oi = i;
            }
          if (best <= 0) return;
          PT.splice(oi, 1);
        }
        const d = sphereDir();
        PT.push({
          p: new THREE.Vector3(CANOPY.x + d.x * 1.4, CANOPY.y + d.y * 0.7, CANOPY.z + d.z * 0.9),
          v: new THREE.Vector3(),
          ax: new THREE.Vector3(R(-1, 1), R(-1, 1), R(-1, 1)).normalize(),
          ang: R(0, TAU),
          ph: R(0, TAU),
          rest: 0,
          yaw: R(0, TAU),
          c: leaves ? LEAFC[Math.floor(Math.random() * LEAFC.length)]!.clone() : new THREE.Color(PETALC[Math.floor(Math.random() * 3)]!),
        });
      };
      // 처음부터 흩날리게
      for (let i = 0; i < 260; i++) {
        spawnPetal();
        const p = PT[PT.length - 1]!;
        p.p.set(R(-6, 7), R(0.02, 3.5), R(-3, 2));
        if (p.p.y < 0.4) {
          p.p.y = 0.015;
          p.rest = R(0.1, 4);
        }
      }
      const M = new THREE.Matrix4();
      const Q = new THREE.Quaternion();
      const Q2 = new THREE.Quaternion();
      const E = new THREE.Euler();
      const ONE = new THREE.Vector3(1.9, 1.9, 1.9);
      const tv = new THREE.Vector3();
      let acc = 0;
      return {
        scene: S.scene,
        camera: S.cam,
        controls,
        update(t, dt0) {
          const dt = Math.min(dt0, 0.05);
          grassMat.uniforms.uTime!.value = t;
          grassMat.uniforms.uWind!.value = windK;
          acc += dt * 34 * amount * (0.6 + windK * 0.5);
          while (acc >= 1) {
            acc -= 1;
            spawnPetal();
          }
          const gx = gustX(t);
          let n = 0;
          for (let i = PT.length - 1; i >= 0; i--) {
            const p = PT[i]!;
            const w = windAt(p.p.x, p.p.z, t);
            if (p.rest > 0) {
              p.rest += dt;
              const g = Math.exp(-Math.pow((p.p.x - gx) / 1.6, 2)) * windK;
              if (g > 0.5 && Math.random() < dt * 6 * g) {
                p.rest = 0;
                p.v.set(w * 0.8, R(1.2, 2.4) * Math.min(1.5, windK), R(-0.3, 0.3));
              } else if (p.rest > 9) {
                PT.splice(i, 1);
                continue;
              }
            } else {
              tv.set(w + Math.sin(p.ph + t * 5) * 0.35, -0.5 + Math.cos(p.ph + t * 3.7) * 0.3, 0.12 * w + Math.sin(t * 4.3 + p.ph) * 0.3);
              p.v.lerp(tv, 1 - Math.exp(-dt * 2.2));
              p.p.addScaledVector(p.v, dt);
              p.ang += (2.2 + w * 1.6) * dt;
              if (p.p.y < 0.015) {
                p.p.y = 0.015;
                p.rest = 0.001;
              }
              if (p.p.x > 11) {
                PT.splice(i, 1);
                continue;
              }
            }
            if (p.rest > 0) {
              E.set(-Math.PI / 2, 0, p.yaw);
              Q.setFromEuler(E);
            } else {
              Q.setFromAxisAngle(p.ax, p.ang);
              E.set(Math.sin(t * 6 + p.ph) * 0.5, 0, Math.cos(t * 5 + p.ph) * 0.4);
              Q2.setFromEuler(E);
              Q.multiply(Q2);
            }
            M.compose(p.p, Q, ONE);
            pm.setMatrixAt(n, M);
            pm.setColorAt(n, p.c);
            n++;
          }
          pm.count = n;
          pm.instanceMatrix.needsUpdate = true;
          if (pm.instanceColor) pm.instanceColor.needsUpdate = true;
          // 바람 줄기 (돌풍 앞)
          if (Math.random() < dt * 20 * windK) {
            const x = gx + R(-1.5, 1);
            const ww = windAt(x, 0, t);
            streaks.spawn({ x, y: R(0.2, 3), z: R(-3, 2), vx: ww * 1.6 + 1, vy: R(-0.1, 0.1), life: R(0.5, 0.9), s0: 0.025, ramp: STREAK, shape: 2, st: 0.35 });
          }
          streaks.tick(dt);
          lab.set(Math.abs(gx) < 4 ? '돌풍 — 잔디가 눕고 땅의 ' + (leaves ? '잎이' : '꽃잎이') + ' 다시 날아오름' : (leaves ? '단풍잎' : '벚꽃잎') + ' — 돌며 팔랑팔랑 떨어짐', leaves ? '#f0901a' : '#f48ab0');
          S.tick(dt);
        },
        resize: (w, h) => S.resize(w, h),
        dispose: () => {
          S.dispose();
          petalGeo.dispose();
          leafGeo.dispose();
        },
      };
    },
  }
```

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