# AI 꾸러미 — 정점 흔들기 (바람 · 물결) — Vertex displacement (wind sway)
> 정점 셰이더에서 시간 · 위치로 만든 sin 물결만큼 정점을 밀어, 깃발 · 나무가 바람에 일렁이게 한다 — CPU 계산 없이.  
> 견본: https://ai-techstudio.web.app/#t/i13

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

## 주문서

### 만들어 줘: 정점 흔들기 (바람 · 물결) — Vertex displacement (wind sway)

#### 1. 목표
깃대에 단 깃발 · 소나무를 정점 셰이더로 바람에 일렁이게 해 줘 — 뿌리 · 깃대 쪽은 고정, 끝으로 갈수록 크게. 느낌은 산들바람 부는 맑은 날.

#### 2. 핵심 기술 용어
- **Vertex displacement (wind sway)** — 정점 흔들기 — 바람 · 물결
- **Travelling sine wave** — 흘러가는 물결 sin(x·k − t·ω)
- **Analytic normal (derivative)** — 물결 식을 미분해 법선도 같이 기울이기

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

#### 4. 조건
- onBeforeCompile 로 표준 재질의 begin_vertex 뒤에 끼워 넣기 (빛 · 그림자 유지)
- 깃발은 beginnormal_vertex 에서 법선도 같은 식의 미분으로 고치기 (안 고치면 빛이 납작)
- 고정된 쪽(깃대 · 뿌리)은 무게 0 — 깃발 판은 translate 로 원점을 깃대 쪽에
- 깃발 판은 가로 30 · 세로 12 칸 이상으로 쪼개기
- 「바람」 · 「빠르기」 슬라이더, 같은 장면을 그대로 / 바람 셰이더로 나란히

#### 5. 완성 기준 (이게 보이면 성공)
- 오른쪽 깃발이 깃대에서 끝으로 물결이 흘러가며 일렁이고, 접힌 곳마다 밝기가 달라진다
- 오른쪽 소나무 위쪽이 살랑 흔들리고 밑동은 그대로다
- 「바람」 0 이면 왼쪽과 똑같이 멈춘다

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

## 원리
- 깃발 정점의 깃대에서 거리 fx 로 무게를 준다: amp = 0.17 × 바람 × (fx / 1.3). 깃대 쪽은 0.
- 물결 위상 ph = fx·5 − 시간·5 + y·1.5 — 깃대에서 끝으로 물결이 흘러간다.
- 정점은 z 로 amp·sin(ph) 만큼 밀고, 법선은 그 식의 기울기(dz/dx)로 다시 계산해 빛이 물결을 따라 바뀐다.
- 나무는 세계 높이의 제곱(hh²)을 무게로 두 sin 을 섞어 x · z 로 흔든다.
- 모든 계산이 GPU 라 물체가 아무리 많아도 CPU 부담이 없다.

## 핵심 코드 — 깃발: 정점 밀기 + 법선 고치기
(발췌: demos/demosMaterial.ts i13 make() 의 flagB.onBeforeCompile)
```ts
const uTime = { value: 0 }, uWind = { value: 1 }, uSpeed = { value: 1 };
const flagG = new THREE.PlaneGeometry(1.3, 0.8, 30, 12); // 충분히 쪼개야 매끈
flagG.translate(0.65, 0, 0);                              // x = 0 이 깃대 쪽
const flag = new THREE.MeshStandardMaterial({ map: flagTex, side: THREE.DoubleSide, roughness: 0.8 });
flag.onBeforeCompile = (sh) => {
  Object.assign(sh.uniforms, { uTime, uWind, uSpeed });
  sh.vertexShader = 'uniform float uTime;\nuniform float uWind;\nuniform float uSpeed;\n' + sh.vertexShader
    .replace('#include <beginnormal_vertex>',
      '#include <beginnormal_vertex>\n' +
      'float fx = position.x;\n' +
      'float amp = 0.17 * uWind * (fx / 1.3);\n' +                 // 깃대에서 멀수록 크게
      'float ph = fx * 5.0 - uTime * 5.0 * uSpeed + position.y * 1.5;\n' +
      'float dzdx = 0.17 * uWind / 1.3 * sin(ph) + amp * 5.0 * cos(ph);\n' + // 미분
      'objectNormal = normalize(vec3(-dzdx, -amp * 1.5 * cos(ph), 1.0));')
    .replace('#include <begin_vertex>',
      '#include <begin_vertex>\n' +
      'transformed.z += amp * sin(ph);\n' +
      'transformed.y += -0.04 * uWind * (fx / 1.3) * (fx / 1.3) + 0.02 * uWind * sin(ph * 0.5) * (fx / 1.3);');
};
// 나무 잎: hh = clamp((세계 y + 0.5) / 1.6, 0, 1.5) 의 제곱만큼
//   transformed.x += (sin(t*1.8 + wx*0.8)*0.5 + sin(t*3.7 + wy*3.0)*0.18) * 0.22 * uWind * hh*hh;
function update(t: number) { uTime.value = t; }
```

## 흔한 실수 · 확인 목록
- [ ] **정점만 밀고 법선을 안 고치면 깃발이 휘어도 빛은 납작하다** — beginnormal_vertex 에서 같은 물결 식의 미분으로 objectNormal 을 다시 만든다.
- [ ] **판을 너무 적게 쪼개면 물결이 각진다** — 견본은 30 × 12 칸. 물결 한 주기에 최소 6~8 정점.
- [ ] **위치를 세계 좌표로 쓰면 같은 나무 여러 그루가 각자 다르게 흔들린다** — 나무는 일부러 세계 x 를 넣어 서로 엇갈리게 했다. 같게 흔들려야 하면 물체 좌표만 쓴다.
- [ ] **그림자는 흔들리지 않는다** — 그림자도 흔들려야 하면 customDepthMaterial 에 같은 줄을 넣는다.

## 완성 기준 체크리스트
- [ ] 오른쪽 깃발이 깃대에서 끝으로 물결이 흘러가며 일렁이고, 접힌 곳마다 밝기가 달라진다
- [ ] 오른쪽 소나무 위쪽이 살랑 흔들리고 밑동은 그대로다
- [ ] 「바람」 0 이면 왼쪽과 똑같이 멈춘다

## 이 기술 정보
- id: `i13` · 분류: 그래픽 · 셰이더 › 재질 · 그림체 · 3D · 난이도 쉬움 · 폰 부담 가벼움 (폰 OK) — 정점마다 sin 몇 번. 깃발은 정점 수(30×12 칸)가 충분해야 매끈하다.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i13
- 쓰면 좋을 때: 깃발 · 나무 · 풀 · 천처럼 배경이 살아 있어야 할 때 / 물리 시뮬레이션까지는 필요 없는 흔들림
- 쓰지 말 때: 손으로 잡아당기는 천 — 대신 천 시뮬레이션(질점 · 용수철) / 충돌해야 하는 물체 — 화면에서만 움직이고 판정 위치는 그대로다

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### i13 견본 항목 — `src/demos/demosMaterial.ts:1326`
```ts
  i13: {
    kind: '3d',
    caption: '왼쪽 멈춘 깃발 · 나무 — 오른쪽은 셰이더가 정점을 밀어 바람에 일렁여요',
    make() {
      const B = base({ bg: gradBg('#7ec8f5', '#dff3ff'), labels: ['그대로', '바람 셰이더'], fit: [2.5, 1.05], target: [0, 0.35, 0], dir: [0, 0.18, 1] });
      lights(B.scene, 0xffffff, 0x7a9a60, 1.0, 1.7);
      const uTime = { value: 0 };
      const uWind = { value: 1 };
      const uSpeed = { value: 1 };
      // 깃발 그림
      const [c, g] = cv(256, 160);
      g.fillStyle = '#ff5a6a';
      g.fillRect(0, 0, 256, 160);
      g.fillStyle = '#ffffff';
      g.fillRect(0, 60, 256, 40);
      g.fillStyle = '#2a4aa8';
      g.beginPath();
      g.arc(110, 80, 46, 0, Math.PI * 2);
      g.fill();
      g.fillStyle = '#fff';
      g.font = `900 64px ${FONT}`;
      g.textAlign = 'center';
      g.textBaseline = 'middle';
      g.fillText('7', 110, 84);
      const flagT = tex(c);
      const flagG = new THREE.PlaneGeometry(1.3, 0.8, 30, 12);
      flagG.translate(0.65, 0, 0);
      const flagA = new THREE.MeshStandardMaterial({ map: flagT, side: THREE.DoubleSide, roughness: 0.8 });
      const flagB = flagA.clone();
      flagB.onBeforeCompile = (sh) => {
        Object.assign(sh.uniforms, { uTime, uWind, uSpeed });
        sh.vertexShader = 'uniform float uTime;\nuniform float uWind;\nuniform float uSpeed;\n' + sh.vertexShader
          .replace(
            '#include <beginnormal_vertex>',
            `#include <beginnormal_vertex>
            float fx = position.x;
            float amp = 0.17 * uWind * (fx / 1.3);
            float ph = fx * 5.0 - uTime * 5.0 * uSpeed + position.y * 1.5;
            float dzdx = 0.17 * uWind / 1.3 * sin(ph) + amp * 5.0 * cos(ph);
            objectNormal = normalize(vec3(-dzdx, -amp * 1.5 * cos(ph), 1.0));`,
          )
          .replace(
            '#include <begin_vertex>',
            `#include <begin_vertex>
            transformed.z += amp * sin(ph);
            transformed.y += -0.04 * uWind * (fx / 1.3) * (fx / 1.3) + 0.02 * uWind * sin(ph * 0.5) * (fx / 1.3);`,
          );
      };
      const leafA = new THREE.MeshStandardMaterial({ color: 0x3aa860, roughness: 0.7, flatShading: true });
      const leafB = leafA.clone();
      leafB.onBeforeCompile = (sh) => {
        Object.assign(sh.uniforms, { uTime, uWind, uSpeed });
        sh.vertexShader = 'uniform float uTime;\nuniform float uWind;\nuniform float uSpeed;\n' + sh.vertexShader.replace(
          '#include <begin_vertex>',
          `#include <begin_vertex>
          vec4 wq = modelMatrix * vec4(position, 1.0);
          float hh = clamp((wq.y + 0.5) / 1.6, 0.0, 1.5);
          float sway = sin(uTime * 1.8 * uSpeed + wq.x * 0.8) * 0.5 + sin(uTime * 3.7 * uSpeed + wq.y * 3.0) * 0.18;
          transformed.x += sway * 0.22 * uWind * hh * hh;
          transformed.z += cos(uTime * 1.3 * uSpeed) * 0.06 * uWind * hh * hh;`,
        );
      };
      const poleM = new THREE.MeshStandardMaterial({ color: 0xe8e8f0, roughness: 0.3, metalness: 0.5 });
      const trunkM = new THREE.MeshStandardMaterial({ color: 0x8a5a32, roughness: 0.8 });
      const groundM = new THREE.MeshStandardMaterial({ color: 0x6cc05a, roughness: 1 });
      const poleG = new THREE.CylinderGeometry(0.03, 0.035, 2.0, 12);
      const knobG = new THREE.SphereGeometry(0.06, 12, 8);
      const coneG = [new THREE.ConeGeometry(0.5, 0.6, 7), new THREE.ConeGeometry(0.4, 0.5, 7), new THREE.ConeGeometry(0.28, 0.42, 7)];
      const trunkG = new THREE.CylinderGeometry(0.07, 0.09, 0.4, 8);
      const groundG = new THREE.CylinderGeometry(1.15, 1.15, 0.1, 40);
      const set = (fm: THREE.Material, lm: THREE.Material): THREE.Group => {
        const gr = new THREE.Group();
        const ground = new THREE.Mesh(groundG, groundM);
        ground.position.y = -0.55;
        ground.scale.z = 0.5;
        const pole = new THREE.Mesh(poleG, poleM);
        pole.position.set(-0.75, 0.45, 0);
        const knob = new THREE.Mesh(knobG, poleM);
        knob.position.set(-0.75, 1.47, 0);
        const flag = new THREE.Mesh(flagG, fm);
        flag.position.set(-0.73, 1.0, 0);
        const trunk = new THREE.Mesh(trunkG, trunkM);
        trunk.position.set(0.6, -0.3, 0.1);
        gr.add(ground, pole, knob, flag, trunk);
        coneG.forEach((cg, k) => {
          const cone = new THREE.Mesh(cg, lm);
          cone.position.set(0.6, 0.1 + k * 0.33, 0.1);
          gr.add(cone);
        });
        return gr;
      };
      const L = set(flagA, leafA);
      const R = set(flagB, leafB);
      L.position.x = -1.2;
      R.position.x = 1.25;
      B.scene.add(L, R);
      return B.done({
        update(t) {
          uTime.value = t;
        },
        controls: [
          { type: 'range', label: '바람 세기', min: 0, max: 2, step: 0.05, value: 1, on: (v) => { uWind.value = v; } },
          { type: 'range', label: '일렁임 빠르기', min: 0.2, max: 3, step: 0.1, value: 1, on: (v) => { uSpeed.value = v; } },
        ],
      });
    },
  }
```

## 관련 기술
- 같은 방법으로 만든 효과 예시: [바람에 흔들리는 풀밭 (인스턴싱)](https://ai-techstudio.web.app/ai/t/i223.md) `i223`
- 먼저 알면 좋은 기술: [재질에 셰이더 끼워 넣기](https://ai-techstudio.web.app/ai/t/u08.md) `u08`
- 다음에 해 볼 기술: [절차 마모 재질 (CG 티 없애기)](https://ai-techstudio.web.app/ai/t/i84.md) `i84`
- 참고 문서: [three.js 예제 — webgl_materials_modified](https://threejs.org/examples/#webgl_materials_modified)
