# AI 꾸러미 — 모래성 무너짐 (덩이 · 모래 줄기 · 먼지 구름)
> 모래 흘러내림(예고) → 아래 칸부터 덩이가 떨어져 구르고 덩이마다 모래 줄기 · 땅에 닿으면 먼지 뭉게 · 모래 더미가 쌓임 → 다시 솟아 쌓기  
> 견본: https://ai-techstudio.web.app/#t/i508

이 문서를 코드 도우미(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: `i508` · 분류: 이펙트 · 연출 › 화려한 효과 (VFX) · 3D · 난이도 보통 · 폰 부담 보통 (폰 주의)
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i508
- 쓰면 좋을 때: 쓰면 좋을 곳 — 무너지는 탑 · 쌓기 실패 · 사막 지형

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### i508 견본 항목 — `src/demos/demosVfx3.ts:2278`
```ts
  i508: {
    kind: '3d',
    caption: '모래가 흘러내리다(예고) 아래부터 덩이가 떨어져 구르고, 덩이마다 모래 줄기 · 땅에 닿으면 먼지 뭉게 · 모래 더미 → 다시 솟아 쌓임',
    make(): Scene3D {
      const S = makeStage({ bg: 0xd9e4ec, cam: [0.4, 1.9, 6.4], look: [0, 0.95, 0], fov: 42, fog: [12, 40] });
      S.scene.add(skyDome(0x5a9ad8, 0xe6ecf0, 0xd8c8a8, 0, { dir: [0.6, 0.5, -0.6], col: 0xfff4d8, pow: 12, I: 0.6 }));
      const lab = S.hud.label('tl');
      S.scene.add(new THREE.HemisphereLight(0xe0f0ff, 0xb08a60, 1.0));
      const sunL = new THREE.DirectionalLight(0xfff0d8, 2.6);
      sunL.position.set(4, 6, 3);
      S.scene.add(sunL);
      const sandC = groundCanvas('v3sand', '#d6b685', [['rgba(120,80,40,0.25)', 9000, 1.2], ['rgba(255,240,210,0.35)', 6000, 1.1]], 61, (g, s) => {
        g.strokeStyle = 'rgba(150,110,60,0.12)';
        g.lineWidth = 3;
        for (let y = 0; y < s; y += 16) {
          g.beginPath();
          for (let x = 0; x <= s; x += 8) g.lineTo(x, y + Math.sin((x / s) * TAU * 3 + y) * 4);
          g.stroke();
        }
      });
      const sandT = texFrom(sandC, true, 6);
      const ground = new THREE.Mesh(new THREE.PlaneGeometry(60, 60), new THREE.MeshStandardMaterial({ map: sandT, bumpMap: sandT, bumpScale: 1.5, roughness: 1 }));
      ground.rotation.x = -Math.PI / 2;
      S.scene.add(ground);
      const shadow = new THREE.Mesh(
        new THREE.PlaneGeometry(2.4, 2.4),
        new THREE.ShaderMaterial({
          uniforms: { uA: { value: 0.35 } },
          vertexShader: FLAT_VS,
          fragmentShader: /* glsl */ `uniform float uA; varying vec2 vUv; void main(){ float r = length(vUv * 2.0 - 1.0); gl_FragColor = vec4(0.18, 0.12, 0.06, (1.0 - smoothstep(0.3, 1.0, r)) * uA); }`,
          transparent: true,
          depthWrite: false,
        }),
      );
      shadow.rotation.x = -Math.PI / 2;
      shadow.position.set(0.15, 0.01, -0.1);
      S.scene.add(shadow);
      // 덩이 모양 (울퉁불퉁 상자 — 같은 자리 꼭짓점은 같이 움직여 틈 없음)
      const lumpGeo = (seed: number): THREE.BufferGeometry => {
        const g = new THREE.BoxGeometry(1, 1, 1, 2, 2, 2);
        const p = g.attributes.position!;
        for (let i = 0; i < p.count; i++) {
          const x = p.getX(i);
          const y = p.getY(i);
          const z = p.getZ(i);
          const h = Math.sin(x * 12.9 + y * 78.2 + z * 37.7 + seed * 3.1) * 43758.5;
          const j = (h - Math.floor(h) - 0.5) * 0.22;
          const h2 = Math.sin(x * 39.3 + y * 11.1 + z * 71.7 + seed) * 12345.6;
          const j2 = (h2 - Math.floor(h2) - 0.5) * 0.22;
          p.setXYZ(i, x * (1 + j), y * (1 + j2 * 0.6), z * (1 + j2));
        }
        g.computeVertexNormals();
        return g;
      };
      const geos = [0, 1, 2, 3].map(lumpGeo);
      const mats = [0xffffff, 0xf2e8dc, 0xfff6ea].map((c) => new THREE.MeshStandardMaterial({ color: c, map: sandT, roughness: 1, flatShading: true }));
      interface Chunk {
        m: THREE.Mesh;
        p0: THREE.Vector3;
        q0: THREE.Quaternion;
        v: THREE.Vector3;
        ax: THREE.Vector3;
        w: number;
        delay: number;
        st: number;
        layer: number;
        hs: number;
      }
      const chunks: Chunk[] = [];
      const L = 7;
      const SEG = 9;
      const LH = 0.26;
      const RAD = 0.52;
      const add = (x: number, y: number, z: number, sx: number, sy: number, sz: number, ry: number, layer: number): void => {
        const m = new THREE.Mesh(geos[chunks.length % 4]!, mats[chunks.length % 3]!);
        m.scale.set(sx, sy, sz);
        m.position.set(x, y, z);
        m.rotation.y = ry;
        S.scene.add(m);
        chunks.push({ m, p0: m.position.clone(), q0: m.quaternion.clone(), v: new THREE.Vector3(), ax: new THREE.Vector3(1, 0, 0), w: 0, delay: 0, st: 0, layer, hs: sy / 2 });
      };
      for (let l = 0; l < L; l++) {
        const rr = RAD * (1 - l * 0.035);
        const off = (l % 2) * (Math.PI / SEG);
        for (let s = 0; s < SEG; s++) {
          const a = (s / SEG) * TAU + off;
          add(Math.cos(a) * rr, LH / 2 + l * LH, Math.sin(a) * rr, ((TAU * rr) / SEG) * 1.02, LH * 1.02, 0.3, -a + Math.PI / 2, l);
        }
        add(0, LH / 2 + l * LH, 0, rr * 1.15, LH, rr * 1.15, l * 0.4, l);
      }
      for (let s = 0; s < 6; s++) {
        const a = (s / 6) * TAU;
        const rr = RAD * (1 - L * 0.035);
        add(Math.cos(a) * rr, L * LH + 0.1, Math.sin(a) * rr, 0.2, 0.2, 0.22, -a, L);
      }
      const pileMat = new THREE.MeshStandardMaterial({ color: 0xf6eee2, map: sandT, roughness: 1 });
      const pile = new THREE.Mesh(new THREE.LatheGeometry(Array.from({ length: 18 }, (_, i) => new THREE.Vector2((i / 17) * 1.4, Math.exp(-Math.pow((i / 17) * 1.4, 2) * 2.2) - Math.exp(-4.3))).reverse(), 48), pileMat);
      pile.scale.set(0.001, 0.001, 0.001);
      S.scene.add(pile);
      const grains = S.pool(2600, 3);
      const dust = S.pool(700, 4);
      const GRAIN = ramp([[0, 0xc8a06a, 1.1, 1, 0], [0.8, 0xc09a64, 1.1, 1, 0], [1, 0xb89060, 1, 0, 0]]);
      const DUST = ramp([[0, 0xe0c8a0, 1.15, 0, 0], [0.1, 0xd8c09a, 1.15, 0.55, 0], [0.6, 0xd0bc9c, 1.1, 0.3, 0], [1, 0xc8b8a0, 1.05, 0, 0]]);
      let cyc = 0;
      let slow = false;
      let dustAmt = 1;
      const CY = 7.5;
      const T_FALL = 1.5;
      const controls: Control[] = [
        ctrlBtn('다시 무너뜨리기', () => (cyc = T_FALL - 0.6)),
        ctrlTog('느리게 보기', false, (v) => (slow = v)),
        ctrlRange('먼지 양', 0, 2, 0.05, 1, (v) => (dustAmt = v)),
      ];
      const q = new THREE.Quaternion();
      const dustPuff = (x: number, z: number, n: number, sp: number): void => {
        for (let i = 0; i < n * dustAmt; i++) {
          const a = R(0, TAU);
          const s = R(0.3, 1) * sp;
          dust.spawn({ x: x + Math.cos(a) * 0.1, y: 0.12, z: z + Math.sin(a) * 0.1, vx: Math.cos(a) * s, vy: R(0.2, 0.7), vz: Math.sin(a) * s, life: R(1.6, 2.8), s0: 0.25, s1: R(0.9, 1.5), ramp: DUST, shape: 1, rot: R(-0.4, 0.4), rv: R(-0.3, 0.3), drag: 1.8, grav: -0.1 });
        }
      };
      let started = false;
      return {
        scene: S.scene,
        camera: S.cam,
        controls,
        update(t, dt0) {
          const dt = Math.min(dt0, 0.05) * (slow ? 0.35 : 1);
          const prev = cyc;
          cyc = (cyc + dt) % CY;
          if (cyc < prev || !started) {
            started = true;
            for (const c of chunks) {
              c.st = 0;
              c.m.position.copy(c.p0);
              c.m.quaternion.copy(c.q0);
            }
          }
          if (cyc < T_FALL) {
            // 예고: 흔들림 + 모래 흘러내림
            const k = clamp((cyc - 0.5) / (T_FALL - 0.5), 0, 1);
            for (const c of chunks) {
              if (c.st !== 0) continue;
              c.m.position.set(c.p0.x + Math.sin(t * 47 + c.layer) * 0.006 * k, c.p0.y, c.p0.z + Math.cos(t * 53 + c.layer) * 0.006 * k);
            }
            if (k > 0 && Math.random() < dt * 70 * k) {
              const a = R(0, TAU);
              grains.spawn({ x: Math.cos(a) * RAD, y: R(0.6, 1.8), z: Math.sin(a) * RAD, vx: Math.cos(a) * 0.3, vz: Math.sin(a) * 0.3, life: 0.8, s0: R(0.035, 0.055), ramp: GRAIN, shape: 0, grav: 9.8, bnc: 0 });
            }
            pile.scale.setScalar(0.001);
            lab.set(cyc > 0.5 ? '금 가며 모래가 흘러내림 (예고)' : '모래성', '#d8a860');
          } else if (cyc < 5.4) {
            const a = cyc - T_FALL;
            lab.set('무너짐 — 덩이 · 모래 줄기 · 먼지 구름 · 모래 더미', '#d8a860');
            for (const c of chunks) {
              if (c.st === 0) {
                c.st = 1;
                c.delay = R(0, 0.18) + c.layer * 0.045;
                const ang = Math.atan2(c.p0.z, c.p0.x) + R(-0.4, 0.4);
                const out = R(0.4, 1.4) * (1.2 - (c.layer / L) * 0.5) * (c.p0.length() < 0.1 ? 0.3 : 1);
                c.v.set(Math.cos(ang) * out, R(-0.3, 0.8), Math.sin(ang) * out);
                c.ax.set(R(-1, 1), R(-0.3, 0.3), R(-1, 1)).normalize();
                c.w = R(1.5, 6);
              }
              if (c.st === 1) {
                if (a < c.delay) continue;
                c.v.y -= 9.8 * dt;
                c.m.position.addScaledVector(c.v, dt);
                q.setFromAxisAngle(c.ax, c.w * dt);
                c.m.quaternion.premultiply(q);
                if (Math.random() < dt * 22) grains.spawn({ x: c.m.position.x + R(-0.08, 0.08), y: c.m.position.y, z: c.m.position.z + R(-0.08, 0.08), vx: c.v.x * 0.3, vy: c.v.y * 0.3, vz: c.v.z * 0.3, life: 0.7, s0: R(0.018, 0.032), ramp: GRAIN, shape: 0, grav: 9.8, bnc: 0 });
                const floorY = c.hs * 0.8;
                if (c.m.position.y < floorY) {
                  c.m.position.y = floorY;
                  const sp = Math.abs(c.v.y);
                  if (sp > 1.2) {
                    dustPuff(c.m.position.x, c.m.position.z, 3, 1.2);
                    for (let j = 0; j < 8; j++) {
                      const an = R(0, TAU);
                      grains.spawn({ x: c.m.position.x, y: 0.05, z: c.m.position.z, vx: Math.cos(an) * R(0.5, 1.5), vy: R(1, 2.5), vz: Math.sin(an) * R(0.5, 1.5), life: 0.6, s0: R(0.02, 0.03), ramp: GRAIN, shape: 0, grav: 9.8, bnc: 0 });
                    }
                  }
                  c.v.y = sp * 0.18;
                  c.v.x *= 0.55;
                  c.v.z *= 0.55;
                  c.w *= 0.5;
                  if (sp < 0.6 && c.v.lengthSq() < 0.05) c.st = 2;
                }
              }
            }
            if (a > 0.25 && a - dt <= 0.25) dustPuff(0, 0, 22, 2.6);
            const pk = smooth((a - 0.2) / 1.6);
            pile.scale.set(0.3 + 0.75 * pk, 0.75 * pk + 0.001, 0.3 + 0.75 * pk);
          } else if (cyc < 6.3) {
            const k = (cyc - 5.4) / 0.9;
            for (const c of chunks) c.m.position.y -= dt * 0.5;
            pile.scale.y = Math.max(0.001, 0.75 * (1 - k));
            lab.set('모래 속으로 가라앉음', '#d8a860');
          } else {
            const k = (cyc - 6.3) / 1.0;
            for (const c of chunks) {
              const kk = smooth((k - c.layer * 0.06) / 0.5);
              c.m.position.set(c.p0.x, lerp(-0.6, c.p0.y, kk), c.p0.z);
              c.m.quaternion.copy(c.q0);
              c.st = 0;
            }
            pile.scale.setScalar(0.001);
            if (Math.random() < dt * 20) dustPuff(R(-0.5, 0.5), R(-0.5, 0.5), 1, 0.6);
            lab.set('다시 쌓기 — 아래부터 솟아오름', '#d8a860');
          }
          grains.tick(dt);
          dust.tick(dt);
          S.tick(dt);
        },
        resize: (w, h) => S.resize(w, h),
        dispose: () => {
          S.dispose();
          for (const g of geos) g.dispose();
        },
      };
    },
  }
```

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