# AI 꾸러미 — 흐르는 무늬 메시 (UV 스크롤) — UV scrolling (panning) shader
> 끝이 열린 원통 · 원뿔 · 원판 메시에 잡음 무늬를 UV 를 따라 흘려서, 입자 없이도 소용돌이 기둥 · 불기둥 · 빔을 화려하게 만든다.  
> 견본: https://ai-techstudio.web.app/#t/i172

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

## 주문서

### 만들어 줘: 흐르는 무늬 메시 (UV 스크롤) — UV scrolling (panning) shader

#### 1. 목표
소용돌이 기둥을 입자 없이 원통 · 원뿔 메시에 흐르는 잡음 무늬로 만들어 줘 — 무늬가 위로 비틀리며 흐르고 양 끝은 자연스럽게 사라지게. 분위기는 보랏빛 소용돌이.

#### 2. 핵심 기술 용어
- **UV scrolling (panning) shader** — UV 를 시간으로 밀어 무늬를 흘리기
- **Tileable fbm noise (periodic)** — 이음새 없이 둘레를 감는 잡음
- **Fresnel rim fade** — 보는 각도로 가장자리 밝기 조절
- **Open-ended CylinderGeometry** — 뚜껑 없는 원통 · 원뿔

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

#### 4. 조건
- 무늬 x 방향은 둘레 칸 수(per)로 감아(mod) 원통 이음새가 안 보이게
- 양 끝은 v 로 부드럽게 사라지게 — 메시 끝이 칼로 자른 듯 보이면 안 된다
- AdditiveBlending · side: DoubleSide · depthWrite: false · CylinderGeometry 는 openEnded true
- 흐름 속도 · 무늬 켬/끔을 uniform 하나씩으로
- 잡음은 셰이더 하나에 fbm 2번까지 — 더 필요하면 텍스처로 굽기

#### 5. 완성 기준 (이게 보이면 성공)
- 소용돌이: 바깥 원뿔 · 안쪽 원통 · 바닥 원판의 무늬가 위로 비틀리며 흐르고 전체가 천천히 돈다
- 빔: 가는 하얀 원통 + 굵은 주황 원통이 겹쳐 가운데가 밝은 광선이 된다
- 「잡음 무늬 흘리기」를 끄면 같은 메시가 밋밋한 반투명 통이 된다
- 「흐름 속도」 0 ~ 3 조절이 바로 먹는다

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

## 원리
- 원통의 UV 는 u = 둘레, v = 높이. 무늬 좌표를 p = (u·per + v·twist + T·0.35, v·sy − T) 로 두면 무늬가 위로 흐르며 비틀린다.
- 잡음은 x 를 per 로 감는(mod) fbm 4겹이라 원통 둘레 이음새가 안 보인다. 두 겹을 섞어 smoothstep(0.36, 0.86) 으로 밝은 줄만 남긴다.
- 양 끝은 v 로 사라지게(아래 0 ~ 0.14 · 위 0.72 ~ 1), 가장자리는 프레넬(보는 각도)로 밝기를 맞춘다. 빔은 반대로 가운데를 밝게(core).
- 더하기 섞기 · 양면 · 깊이 안 씀으로 여러 겹(바깥 원뿔 + 안쪽 원통 + 바닥 원판)을 겹치면 입자 없이도 풍성하다.
- 원판은 극좌표(반지름 · 각도)로 같은 무늬를 돌려 바닥 소용돌이를 만든다.

## 핵심 코드 — 흐르는 무늬 재질 (원통 · 원뿔용)
(발췌: demos/demosSkillA.ts scrollMat() · NOISE_GLSL 을 정리)
```ts
const NOISE =
  'float h21(vec2 p){ return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); }' +
  'float pn(vec2 p, float per){ vec2 i = floor(p), f = fract(p); f = f*f*(3.0-2.0*f);' +
  '  float a = h21(vec2(mod(i.x, per), i.y)), b = h21(vec2(mod(i.x+1.0, per), i.y));' +
  '  float c = h21(vec2(mod(i.x, per), i.y+1.0)), d = h21(vec2(mod(i.x+1.0, per), i.y+1.0));' +
  '  return mix(mix(a,b,f.x), mix(c,d,f.x), f.y); }' +
  'float fbm(vec2 p, float per){ float s = 0.0, a = 0.5; for (int k = 0; k < 4; k++){ s += a*pn(p, per); p *= 2.0; per *= 2.0; a *= 0.5; } return s; }';

const scrollMat = (colA: number, colB: number, per = 5, sy = 2.4, twist = 2.2, bright = 1.2) => new THREE.ShaderMaterial({
  uniforms: { uTime: { value: 0 }, uPer: { value: per }, uSy: { value: sy }, uTwist: { value: twist }, uBright: { value: bright },
    uColA: { value: new THREE.Color(colA) }, uColB: { value: new THREE.Color(colB) } },
  vertexShader: 'varying vec2 vUv; varying float vF; void main(){ vUv = uv;' +
    ' vec3 n = normalize(normalMatrix * normal); vec4 mv = modelViewMatrix * vec4(position, 1.0);' +
    ' vF = abs(dot(n, normalize(-mv.xyz))); gl_Position = projectionMatrix * mv; }',
  fragmentShader: 'uniform float uTime, uPer, uSy, uTwist, uBright; uniform vec3 uColA, uColB; varying vec2 vUv; varying float vF;' + NOISE +
    'void main(){ float T = uTime;' +
    ' vec2 p = vec2(vUv.x * uPer + vUv.y * uTwist + T * 0.35, vUv.y * uSy - T);' +   // 위로 흐르며 비틀림
    ' float fade = smoothstep(0.0, 0.14, vUv.y) * (1.0 - smoothstep(0.72, 1.0, vUv.y));' +
    ' float rim = mix(0.35, 1.0, pow(1.0 - vF, 1.3));' +
    ' float n = fbm(p, uPer), n2 = fbm(vec2(p.x * 2.0, p.y * 1.7 - T * 0.8), uPer * 2.0);' +
    ' float m = smoothstep(0.36, 0.86, n * 0.62 + n2 * 0.55);' +
    ' vec3 col = mix(uColB, uColA, m) * uBright * (0.35 + 2.6 * m * m);' +
    ' gl_FragColor = vec4(col, clamp(m * fade * rim, 0.0, 1.0)); }',
  transparent: true, depthWrite: false, side: THREE.DoubleSide, blending: THREE.AdditiveBlending,
});

const outer = new THREE.Mesh(new THREE.CylinderGeometry(1.35, 0.3, 3.4, 64, 1, true), scrollMat(0xd8b0ff, 0x4020c0));
outer.position.y = 1.7; // 매 프레임: outer.material.uniforms.uTime.value = time; 묶음째 rotation.y = time * 0.6
```

## 흔한 실수 · 확인 목록
- [ ] **잡음을 mod 로 감지 않으면 원통 둘레에 세로 이음새가 생긴다** — 무늬 x 에 둘레 칸 수(per)를 곱하고, 잡음 격자 x 를 mod(i.x, per) 로 감는다. 옥타브마다 per 도 2배.
- [ ] **잡음 반복문을 셰이더 여러 곳에서 부르면 첫 화면이 몇 초 멈춘다** — 윈도는 반복문 잡음을 D3D 로 바꾸는 데 오래 걸린다(보물 동굴 17~20초). fbm 은 두 번까지, 더 필요하면 잡음 텍스처를 구워 읽고 compileAsync 로 미리 데운다.
- [ ] **끝을 안 지우면 원통 단면이 보인다** — v 로 아래 · 위를 smoothstep 으로 사라지게, CylinderGeometry 는 openEnded = true.
- [ ] **깊이를 쓰면 겹친 원통끼리 서로 가린다** — depthWrite: false · 더하기 섞기면 순서와 상관없이 겹쳐 밝아진다.

## 완성 기준 체크리스트
- [ ] 소용돌이: 바깥 원뿔 · 안쪽 원통 · 바닥 원판의 무늬가 위로 비틀리며 흐르고 전체가 천천히 돈다
- [ ] 빔: 가는 하얀 원통 + 굵은 주황 원통이 겹쳐 가운데가 밝은 광선이 된다
- [ ] 「잡음 무늬 흘리기」를 끄면 같은 메시가 밋밋한 반투명 통이 된다
- [ ] 「흐름 속도」 0 ~ 3 조절이 바로 먹는다

## 이 기술 정보
- id: `i172` · 분류: 이펙트 · 연출 › 스킬 VFX (마법 · 미사일) · 3D · 난이도 보통 · 폰 부담 가벼움 (폰 OK) — 메시 몇 개뿐이지만 픽셀마다 fbm 2번(4겹씩)을 계산한다. 화면을 꽉 채우면 폰에서 무거워지니 기둥 굵기를 적당히.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i172
- 쓰면 좋을 때: 소용돌이 · 빔 · 빛기둥처럼 「이어진 모양」의 효과 / 입자 수를 아끼고 싶을 때 — 메시 3~5개면 된다
- 쓰지 말 때: 흩어지는 불티 · 연기 — 입자(i170)가 맞다 / 셰이더에 잡음을 더 많이 겹치고 싶을 때 — 그 대신 미리 구운 잡음 텍스처를 읽는다

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### i172 견본 항목 — `src/demos/demosSkillA.ts:1691`
```ts
  i172: {
    kind: '3d',
    caption: '입자 없이 원뿔 · 원통 · 원판 메시에 잡음 무늬를 흘려 만든 소용돌이 기둥',
    make(): Scene3D {
      const S = makeStage({ cam: [0, 5.4, 7.6], look: [0, 1.2, 0] });
      const lab = S.hud.label('tl');
      const swirl = new THREE.Group();
      const mOuter = scrollMat(0xd8b0ff, 0x4020c0, { per: 5, sy: 2.4, twist: 2.2, bright: 1.2 });
      const mInner = scrollMat(0xffffff, 0x60a0ff, { per: 4, sy: 3.5, twist: 3.0, bright: 1.6, speed: 1.6 });
      const mDisc = scrollMat(0xe0c0ff, 0x5030d0, { per: 7, sy: 3, twist: 3, bright: 1.2, polar: true });
      const outer = new THREE.Mesh(new THREE.CylinderGeometry(1.35, 0.3, 3.4, 64, 1, true), mOuter);
      outer.position.y = 1.7;
      const inner = new THREE.Mesh(new THREE.CylinderGeometry(0.6, 0.18, 3.8, 48, 1, true), mInner);
      inner.position.y = 1.9;
      const disc = new THREE.Mesh(new THREE.CircleGeometry(2.4, 96, 0, TAU), mDisc);
      disc.rotation.x = -Math.PI / 2;
      disc.position.y = 0.03;
      disc.scale.setScalar(1);
      // 원판 극좌표: 위치 길이가 0..1 이 되게 반지름 1 로 만들고 크기로 키움
      disc.geometry.dispose();
      disc.geometry = new THREE.CircleGeometry(1, 96);
      disc.scale.setScalar(2.4);
      swirl.add(disc, outer, inner);
      S.scene.add(swirl);
      const beam = new THREE.Group();
      const mBeam = scrollMat(0xffffff, 0xffa040, { per: 4, sy: 6, twist: 0.6, bright: 2.2, speed: 2.4, core: true });
      const mBeam2 = scrollMat(0xffb040, 0xff3000, { per: 5, sy: 4, twist: -1.0, bright: 1.3, speed: 1.4, core: true });
      const b1 = new THREE.Mesh(new THREE.CylinderGeometry(0.22, 0.22, 7.5, 32, 1, true), mBeam);
      const b2 = new THREE.Mesh(new THREE.CylinderGeometry(0.5, 0.42, 7.5, 32, 1, true), mBeam2);
      for (const b of [b1, b2]) {
        b.rotation.z = Math.PI / 2;
        b.position.y = 1.1;
      }
      beam.add(b2, b1);
      S.scene.add(beam);
      const gd = glowDisc(0x9060ff, 2.2, 6);
      S.scene.add(gd.mesh);
      const pl = new THREE.PointLight(0x9a70ff, 14, 9, 1.4);
      pl.position.set(0, 1.0, 0);
      S.scene.add(pl);
      let mode = -1;
      let speed = 1;
      let pattern = true;
      const mats = [mOuter, mInner, mDisc, mBeam, mBeam2];
      const controls: Control[] = [
        ctrlTog('잡음 무늬 흘리기', true, (v) => (pattern = v)),
        ctrlRange('흐름 속도', 0, 3, 0.1, 1, (v) => (speed = v)),
        ctrlRange('모양 (-1 자동 · 0 소용돌이 · 1 빔)', -1, 1, 1, -1, (v) => (mode = v)),
      ];
      let time = 0;
      return {
        scene: S.scene,
        camera: S.cam,
        controls,
        update(t, dt0) {
          const dt = Math.min(dt0, 0.05);
          time += dt * speed;
          const m = mode < 0 ? Math.floor(t / 5) % 2 : mode;
          swirl.visible = m === 0;
          beam.visible = m === 1;
          for (const mt of mats) {
            mt.uniforms.uTime!.value = time;
            mt.uniforms.uOn!.value = pattern ? 1 : 0;
          }
          swirl.rotation.y = time * 0.6;
          if (m === 0) {
            gd.mesh.scale.set(1, 1, 1);
            gd.set(2.2, 0x9060ff);
            pl.color.setHex(0x9a70ff);
            pl.position.set(0, 1, 0);
          } else {
            gd.mesh.scale.set(1.5, 0.45, 1);
            gd.set(1.6, 0xff7020);
            pl.color.setHex(0xff8030);
            pl.position.set(0, 0.8, 0);
          }
          pl.intensity = 14 + Math.sin(t * 13) * 2;
          lab.set((m === 0 ? '소용돌이 (원뿔 + 원통 + 원판)' : '빔 (원통 두 겹)') + (pattern ? ' · 무늬 흐름' : ' · 무늬 없음'), m === 0 ? '#c08cff' : '#ffa050');
          S.tick(dt);
        },
        resize: (w, h) => S.resize(w, h),
        dispose: () => S.dispose(),
      };
    },
  }
```

## 관련 기술
- 같은 방법으로 만든 효과 예시: [빔 · 레이저 (지속 광선)](https://ai-techstudio.web.app/ai/t/i189.md) `i189` · [검기 · 휘두름 호 (메시 슬래시)](https://ai-techstudio.web.app/ai/t/i192.md) `i192` · [오라 불꽃 (실루엣 둘레 타오름 · 기 폭발)](https://ai-techstudio.web.app/ai/t/i510.md) `i510` · [회오리 · 토네이도](https://ai-techstudio.web.app/ai/t/i512.md) `i512`
- 먼저 알면 좋은 기술: [입자 방출기 (수명 곡선)](https://ai-techstudio.web.app/ai/t/i170.md) `i170`
- 다음에 해 볼 기술: [마법진 (돌아가는 룬 고리)](https://ai-techstudio.web.app/ai/t/i180.md) `i180` · [층 쌓기 (핵 · 빛무리 · 불티 · 연기)](https://ai-techstudio.web.app/ai/t/i171.md) `i171`
