# AI 꾸러미 — 바람에 흔들리는 풀밭 (인스턴싱)
> 수천 포기 잎이 파도처럼 일렁 · 캐릭터 둘레는 눕힘  
> 견본: https://ai-techstudio.web.app/#t/i223

이 문서를 코드 도우미(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: `i223` · 분류: 그래픽 · 셰이더 › 재질 · 그림체 · 3D · 난이도 보통 · 폰 부담 보통 (폰 주의)
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i223
- 쓰면 좋을 때: 쓰면 좋을 곳 — 마당 · 정원 배경

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### makeGrass — `src/demos/demosLook2.ts:459`
```ts
function makeGrass(): Scene3D {
  const scene = new THREE.Scene();
  scene.background = gradBg([
    [0, '#6fb6ff'],
    [0.75, '#cfe9ff'],
    [1, '#f6efd6'],
  ]);
  scene.fog = new THREE.Fog(0xdcecf6, 7, 16);
  const cam = new THREE.PerspectiveCamera(40, 1.6, 0.1, 60);
  cam.position.set(0, 2.6, 5.6);
  cam.lookAt(0, 0.2, 0);
  scene.add(new THREE.HemisphereLight(0xeaf6ff, 0x4a6a2a, 1.3));
  const sun = new THREE.DirectionalLight(0xfff0d0, 2.2);
  sun.position.set(3, 6, 2);
  scene.add(sun);
  const root = new THREE.Group();
  scene.add(root);
  const ground = new THREE.Mesh(new THREE.CircleGeometry(9, 64).rotateX(-Math.PI / 2), new THREE.MeshLambertMaterial({ color: 0x3f7a26 }));
  root.add(ground);

  // 잎 하나 (끝으로 갈수록 좁아짐, 7마디)
  const SEG = 6;
  const bp: number[] = [];
  const idx: number[] = [];
  for (let i = 0; i <= SEG; i++) {
    const y = i / SEG;
    const wdt = 0.045 * (1 - y) ** 0.9 + 0.002;
    bp.push(-wdt, y, 0, wdt, y, 0);
    if (i < SEG) {
      const a = i * 2;
      idx.push(a, a + 1, a + 2, a + 1, a + 3, a + 2);
    }
  }
  const MAX = 16000;
  const geo = new THREE.InstancedBufferGeometry();
  geo.setAttribute('position', new THREE.Float32BufferAttribute(bp, 3));
  geo.setIndex(idx);
  const off = new Float32Array(MAX * 4);
  const r0 = rng(31);
  for (let i = 0; i < MAX; i++) {
    const a = r0() * Math.PI * 2;
    const rr = Math.sqrt(r0()) * 8.5;
    off[i * 4] = Math.cos(a) * rr;
    off[i * 4 + 1] = Math.sin(a) * rr;
    off[i * 4 + 2] = r0() * Math.PI * 2;
    off[i * 4 + 3] = 0.55 + r0() * 0.6;
  }
  geo.setAttribute('aOff', new THREE.InstancedBufferAttribute(off, 4));
  geo.instanceCount = 11000;
  const uni = { uTime: { value: 0 }, uWind: { value: 1 }, uPush: { value: 1 }, uChar: { value: new THREE.Vector2() }, fogColor: { value: new THREE.Color(0xdcecf6) } };
  const mat = new THREE.ShaderMaterial({
    uniforms: uni,
    side: THREE.DoubleSide,
    vertexShader: /* glsl */ `
      uniform float uTime, uWind, uPush; uniform vec2 uChar;
      attribute vec4 aOff;
      varying float vY; varying float vGust; varying float vVar; varying float vDist;
      ${NOISE}
      void main(){
        float y = position.y * aOff.w * 0.42;
        float c = cos(aOff.z), s = sin(aOff.z);
        vec3 p = vec3(position.x * c, y, position.x * s);
        vec2 base = aOff.xy;
        vec2 wdir = normalize(vec2(1.0, 0.35));
        float gust = sin(dot(base, wdir) * 0.9 - uTime * 2.1) * 0.5 + 0.5;
        gust = gust * gust * (0.6 + 0.8 * noise(base * 0.35 + uTime * 0.25));
        float k = position.y * position.y;
        float bend = uWind * (0.12 + gust * 0.55) + 0.05 * sin(uTime * 3.0 + aOff.z * 5.0) * uWind;
        p.xz += wdir * bend * k * aOff.w * 0.42;
        vec2 d = base - uChar; float dist = length(d);
        float push = (1.0 - smoothstep(0.15, 0.85, dist)) * uPush;
        p.xz += normalize(d + 1e-4) * push * k * 0.5;
        p.y *= 1.0 - push * 0.55 * position.y - bend * 0.25 * k;
        vec4 w = vec4(p + vec3(base.x, 0.0, base.y), 1.0);
        vY = position.y; vGust = gust * uWind; vVar = hash(base * 3.1);
        vec4 mv = viewMatrix * w; vDist = -mv.z;
        gl_Position = projectionMatrix * mv;
      }`,
    fragmentShader: /* glsl */ `
      uniform vec3 fogColor;
      varying float vY; varying float vGust; varying float vVar; varying float vDist;
      void main(){
        vec3 root = vec3(0.06, 0.20, 0.03);
        vec3 tip = mix(vec3(0.42, 0.72, 0.16), vec3(0.62, 0.78, 0.22), vVar);
        vec3 col = mix(root, tip, pow(vY, 0.8));
        col += vec3(0.25, 0.24, 0.08) * vGust * vY * vY;
        float f = smoothstep(7.0, 16.0, vDist);
        col = mix(col, fogColor, f);
        gl_FragColor = vec4(col, 1.0);
        ${OUT}
      }`,
  });
  const grass = new THREE.Mesh(geo, mat);
  grass.frustumCulled = false;
  root.add(grass);
  // 굴러가는 꼬마 (노란 공 + 눈)
  const kid = new THREE.Group();
  const body = new THREE.Mesh(new THREE.SphereGeometry(0.3, 32, 24), new THREE.MeshStandardMaterial({ color: 0xffd23f, roughness: 0.5 }));
  body.position.y = 0.3;
  const eyeGeo = new THREE.SphereGeometry(0.045, 12, 10);
  const eyeMat = new THREE.MeshBasicMaterial({ color: 0x1a1a2a });
  for (const sx of [-0.1, 0.1]) {
    const e = new THREE.Mesh(eyeGeo, eyeMat);
    e.position.set(sx, 0.38, 0.26);
    kid.add(e);
  }
  const beak = new THREE.Mesh(new THREE.ConeGeometry(0.05, 0.12, 8).rotateX(Math.PI / 2), new THREE.MeshStandardMaterial({ color: 0xff8a2a }));
  beak.position.set(0, 0.3, 0.32);
  kid.add(body, beak);
  root.add(kid);
  return {
    scene,
    camera: cam,
    update(t) {
      uni.uTime.value = t;
      const a = t * 0.45;
      const x = Math.cos(a) * 1.8;
      const z = Math.sin(a) * 1.2 + 0.4;
      kid.position.set(x, Math.abs(Math.sin(t * 6)) * 0.08, z);
      kid.rotation.y = Math.atan2(-Math.sin(a) * 1.8, Math.cos(a) * 1.2);
      uni.uChar.value.set(x, z);
    },
    controls: [
      { type: 'range', label: '바람 세기', min: 0, max: 2, step: 0.05, value: 1, on: (v) => (uni.uWind.value = v) },
      { type: 'range', label: '잎 수', min: 2000, max: MAX, step: 500, value: 11000, on: (v) => (geo.instanceCount = v) },
      { type: 'toggle', label: '꼬마 둘레 눕히기', value: true, on: (v) => (uni.uPush.value = v ? 1 : 0) },
    ],
    dispose() {
      freeAll(root);
      (scene.background as THREE.Texture).dispose();
    },
  };
}
```

### i223 견본 항목 — `src/demos/demosLook2.ts:4507`
```ts
  i223: { kind: '3d', caption: '잎 1만여 포기를 한 번에 그리기(인스턴싱) — 바람 물결이 들판을 지나가고, 꼬마 둘레 풀은 눕는다', make: () => makeGrass() }
```

## 관련 기술
- 이 효과를 만든 기술: [정점 흔들기 (바람 · 물결)](https://ai-techstudio.web.app/ai/t/i13.md) `i13`
