# AI 꾸러미 — 얼음 창 · 서리 폭발
> 푸른 결정 창 + 서리 입자 꼬리 → 깨지며 얼음 파편 · 바닥 서리 꽃  
> 견본: https://ai-techstudio.web.app/#t/i183

이 문서를 코드 도우미(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: `i183` · 분류: 이펙트 · 연출 › 스킬 VFX (마법 · 미사일) · 3D · 난이도 보통 · 폰 부담 보통 (폰 주의)
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i183
- 쓰면 좋을 때: 쓰면 좋을 곳 — 얼음 별 · 차가운 정답

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### i183 견본 항목 — `src/demos/demosSkillA.ts:2795`
```ts
  i183: {
    kind: '3d',
    caption: '푸른 결정 창이 서리 꼬리를 끌고 날아와 깨지며, 얼음 파편 · 솟는 가시 · 바닥 서리꽃',
    make(): Scene3D {
      const S = makeStage();
      const fx = new Fx(S, 2200);
      const lab = S.hud.label('tl');
      const iceMat = new THREE.MeshStandardMaterial({ color: 0xc8ecff, emissive: 0x2a86d8, emissiveIntensity: 0.7, roughness: 0.12, metalness: 0.05, flatShading: true, transparent: true, opacity: 0.92 });
      const rimMat = new THREE.ShaderMaterial({
        uniforms: { uA: { value: 1 } },
        vertexShader: /* glsl */ `varying vec3 vN; varying vec3 vV; void main(){ vec4 mv = modelViewMatrix * vec4(position, 1.0); vN = normalize(normalMatrix * normal); vV = normalize(-mv.xyz); gl_Position = projectionMatrix * mv; }`,
        fragmentShader: /* glsl */ `uniform float uA; varying vec3 vN; varying vec3 vV;
          void main(){ float f = 1.0 - abs(dot(vN, vV)); vec3 col = vec3(0.45, 0.85, 1.0) * 4.0;
            gl_FragColor = vec4(col, 1.0); ${TONE}
            gl_FragColor.a = clamp(pow(f, 2.0) * uA, 0.0, 1.0); }`,
        transparent: true,
        depthWrite: false,
        blending: THREE.AdditiveBlending,
      });
      const crystalGeo = new THREE.OctahedronGeometry(1, 0);
      const lance = new THREE.Group();
      const main = new THREE.Mesh(crystalGeo, iceMat);
      main.scale.set(0.16, 0.16, 0.95);
      const mainRim = new THREE.Mesh(crystalGeo, rimMat);
      mainRim.scale.set(0.2, 0.2, 1.05);
      lance.add(main, mainRim);
      for (let i = 0; i < 3; i++) {
        const a = (i / 3) * TAU;
        const s = new THREE.Mesh(crystalGeo, iceMat);
        s.scale.set(0.07, 0.07, 0.42);
        s.position.set(Math.cos(a) * 0.12, Math.sin(a) * 0.12, -0.45);
        s.rotation.set(Math.sin(a) * 0.35, -Math.cos(a) * 0.35, 0);
        lance.add(s);
      }
      S.scene.add(lance);
      const carry = new THREE.PointLight(0x70c8ff, 0, 7, 1.5);
      S.scene.add(carry);
      // 파편
      const MAXS = 70;
      const shards = new THREE.InstancedMesh(crystalGeo, iceMat, MAXS);
      shards.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
      shards.count = 0;
      shards.frustumCulled = false;
      S.scene.add(shards);
      interface Shard {
        p: THREE.Vector3;
        v: THREE.Vector3;
        r: THREE.Euler;
        w: THREE.Vector3;
        s: number;
        age: number;
      }
      const SH: Shard[] = [];
      // 솟는 가시
      const spikeGeo = new THREE.ConeGeometry(0.16, 1, 5);
      spikeGeo.translate(0, 0.5, 0);
      const spikes: { m: THREE.Mesh; h: number; tilt: THREE.Euler }[] = [];
      for (let i = 0; i < 8; i++) {
        const m = new THREE.Mesh(spikeGeo, iceMat);
        m.visible = false;
        S.scene.add(m);
        spikes.push({ m, h: 1, tilt: new THREE.Euler() });
      }
      // 서리꽃 데칼
      const frostMat = new THREE.ShaderMaterial({
        uniforms: { uTex: { value: texFrom(frostCanvas()) }, uA: { value: 0 }, uGrow: { value: 0 }, uGlow: { value: 0 } },
        vertexShader: FLAT_VS,
        fragmentShader: /* glsl */ `uniform sampler2D uTex; uniform float uA, uGrow, uGlow; varying vec2 vUv;
          void main(){ vec4 t = texture2D(uTex, vUv); float r = length(vUv*2.0-1.0);
            float g = 1.0 - smoothstep(uGrow - 0.08, uGrow, r);
            float fr = t.r * g * uA; float gl = t.g * g * uA;
            vec3 col = vec3(0.85, 0.95, 1.0) * fr * 1.1 + vec3(0.3, 0.75, 1.0) * gl * (0.8 + uGlow * 4.0);
            float lead = exp(-pow((r - uGrow) / 0.04, 2.0)) * t.r * uA * 3.0;
            col += vec3(0.6, 0.9, 1.0) * lead;
            gl_FragColor = vec4(col, 1.0); ${TONE}
            gl_FragColor.a = clamp(fr * 0.75, 0.0, 0.9); }`,
        transparent: true,
        depthWrite: false,
        blending: THREE.CustomBlending,
        blendSrc: THREE.OneFactor,
        blendDst: THREE.OneMinusSrcAlphaFactor,
        polygonOffset: true,
        polygonOffsetFactor: -2,
        polygonOffsetUnits: -2,
      });
      const frost = new THREE.Mesh(new THREE.PlaneGeometry(1, 1), frostMat);
      frost.rotation.x = -Math.PI / 2;
      frost.position.y = 0.013;
      frost.visible = false;
      S.scene.add(frost);
      const A = new THREE.Vector3(-3.8, 1.6, -1.5);
      const B = new THREE.Vector3(2.2, 0.1, 1.0);
      const p = new THREE.Vector3();
      const prev = new THREE.Vector3();
      const dir = new THREE.Vector3();
      const m4 = new THREE.Matrix4();
      const qq = new THREE.Quaternion();
      const sc = new THREE.Vector3();
      let k = -1;
      let wait = 0.3;
      let since = 99;
      let nShards = 40;
      let frostOn = true;
      let slow = false;
      const FLY = 0.62;
      const shatter = (): void => {
        since = 0;
        SH.length = 0;
        for (let i = 0; i < nShards; i++) {
          const d = coneDir(1.2);
          const sp = R(2.5, 6.5);
          SH.push({ p: new THREE.Vector3(B.x, 0.25, B.z), v: new THREE.Vector3(d.x * sp - 1.2, d.y * sp, d.z * sp - 0.6), r: new THREE.Euler(R(0, TAU), R(0, TAU), 0), w: new THREE.Vector3(R(-12, 12), R(-12, 12), R(-12, 12)), s: R(0.05, 0.13), age: 0 });
        }
        fx.fire.spawn({ x: B.x, y: 0.4, z: B.z, life: 0.25, s0: 2.2, s1: 3.6, ramp: flat(0xe8f8ff, 9, 1), shape: 0 });
        for (let i = 0; i < 80; i++) {
          const d = sphereDir();
          const sp = R(2, 7);
          fx.fire.spawn({ x: B.x, y: 0.3, z: B.z, vx: d.x * sp, vy: Math.abs(d.y) * sp, vz: d.z * sp, life: R(0.5, 1.1), s0: 0.09, s1: 0.03, ramp: RAMP.ice, st: 0.04, shape: Math.random() < 0.4 ? 4 : 3, drag: 1.4, grav: 3, rot: R(0, TAU) });
        }
        for (let i = 0; i < 26; i++) {
          const a = (i / 26) * TAU;
          const sp = R(2.5, 4);
          fx.smoke.spawn({ x: B.x, y: 0.2, z: B.z, vx: Math.cos(a) * sp, vy: R(0.1, 0.5), vz: Math.sin(a) * sp, life: R(1.2, 1.9), s0: 0.4, s1: 1.5, ramp: RAMP.frostMist, shape: 1, rot: R(0, TAU), rv: R(-0.8, 0.8), drag: 2.4 });
        }
        fx.ring(B.x, B.z, 2.8, 0.5, 0x8ad8ff, 3);
        fx.ring(B.x, B.z, 1.8, 0.35, 0xffffff, 2);
        fx.flash(B.x, 0.9, B.z, 120, 0x80c8ff);
        fx.shake(0.1);
        spikes.forEach((s, i) => {
          const a = (i / spikes.length) * TAU + R(-0.2, 0.2);
          const r = R(0.35, 0.95);
          s.m.position.set(B.x + Math.cos(a) * r, 0, B.z + Math.sin(a) * r);
          s.tilt.set(Math.sin(a) * R(0.3, 0.6), 0, -Math.cos(a) * R(0.3, 0.6));
          s.m.rotation.set(Math.sin(a) * 0.5, R(0, TAU), -Math.cos(a) * 0.5);
          s.m.rotation.x = Math.sin(a) * 0.45;
          s.m.rotation.z = -Math.cos(a) * 0.45;
          s.h = R(0.5, 1.1);
          s.m.visible = true;
        });
        if (frostOn) {
          frost.position.x = B.x;
          frost.position.z = B.z;
          frost.scale.setScalar(4.2);
          frost.rotation.z = R(0, TAU);
          frost.visible = true;
        }
      };
      const controls: Control[] = [
        ctrlBtn('다시 쏘기', () => {
          k = 0;
        }),
        ctrlRange('얼음 파편 수', 10, 70, 5, 40, (v) => (nShards = v)),
        ctrlTog('바닥 서리꽃', true, (v) => (frostOn = v)),
        ctrlTog('느리게 보기', false, (v) => (slow = v)),
      ];
      return {
        scene: S.scene,
        camera: S.cam,
        controls,
        update(t, dt0) {
          const dt = Math.min(dt0, 0.05) * (slow ? 0.3 : 1);
          since += dt;
          fx.orb.clear();
          if (k < 0) {
            wait -= dt;
            lance.visible = false;
            carry.intensity = 0;
            if (wait <= 0) {
              k = 0;
              prev.copy(A);
            }
          } else {
            k += dt / FLY;
            if (k >= 1) {
              k = -1;
              wait = 2.2;
              lance.visible = false;
              shatter();
            } else {
              prev.copy(p);
              p.lerpVectors(A, B, k);
              p.y += Math.sin(k * Math.PI) * 0.5;
              if (k * FLY < dt * 1.5) prev.copy(A);
              dir.subVectors(p, prev);
              lance.visible = true;
              lance.position.copy(p);
              if (dir.lengthSq() > 1e-8) lance.lookAt(p.x + dir.x, p.y + dir.y, p.z + dir.z);
              lance.rotateZ(t * 6);
              carry.position.copy(p);
              carry.intensity = 18;
              const vx = dir.x / dt;
              const vy = dir.y / dt;
              const vz = dir.z / dt;
              fx.orb.spawn({ x: p.x, y: p.y, z: p.z, life: 1, s0: 1.4, ramp: flat(0x60b8ff, 0.9, 0.5), shape: 0 });
              for (let j = 0; j < 3; j++) {
                const o = R(0.2, 1.2);
                fx.smoke.spawn({ x: p.x - vx * dt * o * 2, y: p.y - vy * dt * o * 2, z: p.z - vz * dt * o * 2, vx: R(-0.3, 0.3), vy: R(-0.2, 0.2), vz: R(-0.3, 0.3), life: R(0.7, 1.2), s0: 0.22, s1: 0.75, ramp: RAMP.frostMist, shape: 1, rot: R(0, TAU), rv: R(-1, 1), drag: 1.5 });
                fx.fire.spawn({ x: p.x - vx * dt * o * 3 + R(-0.12, 0.12), y: p.y - vy * dt * o * 3 + R(-0.12, 0.12), z: p.z - vz * dt * o * 3 + R(-0.12, 0.12), vx: R(-0.5, 0.5), vy: R(-0.6, 0.2), vz: R(-0.5, 0.5), life: R(0.5, 0.9), s0: 0.08, s1: 0.03, ramp: RAMP.ice, shape: Math.random() < 0.5 ? 4 : 3, rot: R(0, TAU), rv: R(-4, 4), grav: 1 });
              }
            }
          }
          // 파편 물리
          let n = 0;
          for (const s of SH) {
            s.age += dt;
            if (s.age > 1.9) continue;
            s.v.y -= 9.8 * dt;
            s.p.addScaledVector(s.v, dt);
            if (s.p.y < s.s * 0.5) {
              s.p.y = s.s * 0.5;
              if (s.v.y < 0) s.v.y *= -0.3;
              s.v.x *= 0.6;
              s.v.z *= 0.6;
              s.w.multiplyScalar(0.7);
            }
            s.r.x += s.w.x * dt;
            s.r.y += s.w.y * dt;
            s.r.z += s.w.z * dt;
            const shrink = 1 - clamp((s.age - 1.4) / 0.5, 0, 1);
            qq.setFromEuler(s.r);
            sc.set(s.s, s.s, s.s * 2.4).multiplyScalar(shrink);
            m4.compose(s.p, qq, sc);
            shards.setMatrixAt(n++, m4);
          }
          shards.count = n;
          shards.instanceMatrix.needsUpdate = true;
          // 가시 · 서리꽃
          const grow = clamp(since / 0.14, 0, 1);
          const sink = clamp((since - 1.3) / 0.6, 0, 1);
          for (const s of spikes) {
            if (!s.m.visible) continue;
            const hh = s.h * easeOut(grow) * (1 - sink);
            s.m.scale.set(1 - sink * 0.5, Math.max(hh, 0.001), 1 - sink * 0.5);
            if (sink >= 1) s.m.visible = false;
          }
          if (frost.visible) {
            frostMat.uniforms.uGrow!.value = easeOut(since / 0.45) * 1.02;
            frostMat.uniforms.uA!.value = 1 - clamp((since - 2.0) / 0.7, 0, 1);
            frostMat.uniforms.uGlow!.value = Math.exp(-since * 2.5);
            if (since > 2.7) frost.visible = false;
          }
          lab.set(k >= 0 ? '얼음 창 — 결정 + 서리 꼬리 + 반짝임' : since < 2 ? '깨짐 — 파편 · 가시 · 서리 김 · 서리꽃' : '시전 준비', '#8ad8ff');
          fx.update(dt, t);
          S.tick(dt);
        },
        resize: (w, h) => S.resize(w, h),
        dispose: () => {
          S.dispose();
        },
      };
    },
  }
```

## 관련 기술
- 이 효과를 만든 기술: [층 쌓기 (핵 · 빛무리 · 불티 · 연기)](https://ai-techstudio.web.app/ai/t/i171.md) `i171`
