# AI 꾸러미 — 재질에 셰이더 끼워 넣기 — Material.onBeforeCompile (shader injection)
> 기본 재질의 셰이더 중간에 몇 줄을 끼워 넣어, 빛 · 그림자 계산은 그대로 두고 풀이 흔들리거나 갓이 속에서 빛나게 한다.  
> 견본: https://ai-techstudio.web.app/#t/u08

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

## 주문서

### 만들어 줘: 재질에 셰이더 끼워 넣기 — Material.onBeforeCompile (shader injection)

#### 1. 목표
바람에 흔들리는 풀 · 속에서 빛나는 버섯 갓의 표준 재질에 onBeforeCompile 로 셰이더 몇 줄을 끼워 넣어 줘 — 빛 · 그림자는 그대로. 느낌은 동화 같은 밤 숲.

#### 2. 핵심 기술 용어
- **Material.onBeforeCompile (shader injection)** — 컴파일 직전에 셰이더 문자열을 고치기
- **ShaderChunk (#include <begin_vertex> 등)** — three.js 셰이더 조각 — 끼워 넣을 자리 표시
- **Custom uniforms** — 시간 · 세기 같은 값을 바깥에서 넘기기

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

#### 4. 조건
- ShaderMaterial 로 다시 짜지 말고 onBeforeCompile 로 표준 재질에 끼워 넣기 (빛 · 그림자 · 안개 유지)
- 끼울 자리는 #include <begin_vertex> · #include <emissivemap_fragment> 뒤
- uniform 은 재질 밖 객체({ value })로 두고 sh.uniforms 에 연결 — 매 프레임 value 만 바꾸기
- 흔들림은 높이의 제곱(hh * hh)에 비례 — 뿌리는 그대로
- 「바람」 · 「갓 속빛」 슬라이더

#### 5. 완성 기준 (이게 보이면 성공)
- 왼쪽 그대로 · 오른쪽 셰이더 끼움을 나란히 두면, 오른쪽 풀만 끝부분이 물결처럼 흔들린다
- 오른쪽 버섯 갓은 가장자리부터 따뜻하게 빛나며 천천히 맥박친다
- 「바람」 0 이면 오른쪽도 멈추고, 그림자 · 빛은 양쪽이 똑같다

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

## 원리
- three.js 표준 재질의 셰이더는 #include <begin_vertex> 같은 조각들로 이어져 있다.
- onBeforeCompile 에서 그 조각 문자열을 replace 해 바로 뒤에 내 줄을 넣는다.
- 정점 쪽: transformed(이 정점 위치)를 높이² 만큼 sin 으로 밀면 풀 끝만 흔들린다.
- 조각 쪽: emissivemap 다음에 totalEmissiveRadiance 를 더하면 그 부분이 스스로 빛난다.
- uniform 객체({ value })를 재질 밖에 두고 공유하면, 매 프레임 value 만 바꿔도 모든 재질이 따라온다.

## 핵심 코드 — 풀 흔들기(정점) + 갓 속빛(조각) 끼워 넣기
(발췌: demos/demosMaterial.ts u08 make() 를 정리)
```ts
const uTime = { value: 0 };
const uWind = { value: 0.35 };
const uGlow = { value: 1.2 };

const grass = new THREE.MeshStandardMaterial({ vertexColors: true, side: THREE.DoubleSide, roughness: 0.8 });
grass.onBeforeCompile = (sh) => {
  sh.uniforms.uTime = uTime;
  sh.uniforms.uWind = uWind;
  sh.vertexShader = 'uniform float uTime;\nuniform float uWind;\n' + sh.vertexShader.replace(
    '#include <begin_vertex>',
    '#include <begin_vertex>\n' +
    'vec4 wq = modelMatrix * vec4(position, 1.0);\n' +
    'float hh = max(position.y, 0.0);\n' +   // 높이 — 뿌리는 0
    'transformed.x += sin(uTime * 2.2 + wq.x * 2.5 + wq.z * 1.3) * uWind * hh * hh * 1.6;\n' +
    'transformed.z += cos(uTime * 1.7 + wq.x * 1.5) * uWind * hh * hh * 0.7;',
  );
};

const cap = new THREE.MeshStandardMaterial({ color: 0xd04a6a, roughness: 0.6 });
cap.onBeforeCompile = (sh) => {
  sh.uniforms.uGlow = uGlow;
  sh.uniforms.uTime = uTime;
  sh.fragmentShader = 'uniform float uGlow;\nuniform float uTime;\n' + sh.fragmentShader.replace(
    '#include <emissivemap_fragment>',
    '#include <emissivemap_fragment>\n' +
    'float facing = clamp(dot(normalize(normal), normalize(vViewPosition)), 0.0, 1.0);\n' +
    'float rimL = pow(1.0 - facing, 1.6);\n' +
    'float pulse = 0.75 + 0.25 * sin(uTime * 2.0);\n' +
    'totalEmissiveRadiance += vec3(1.0, 0.75, 0.45) * uGlow * pulse * (0.18 + rimL);',
  );
};

function update(t: number) { uTime.value = t; }
```

## 흔한 실수 · 확인 목록
- [ ] **replace 할 조각 이름이 틀리면 아무 일도 안 일어난다** — 오류 없이 조용히 무시된다. 바꾼 뒤 sh.vertexShader 에 내 줄이 들어갔는지 한 번 확인한다.
- [ ] **그림자는 흔들리지 않고 풀만 흔들린다** — 그림자 지도는 깊이 재질로 따로 그린다. 그림자도 흔들려야 하면 customDepthMaterial 에 같은 줄을 넣는다.
- [ ] **같은 onBeforeCompile 을 쓴 재질 둘이 하나로 합쳐진다** — 설정이 같으면 같은 프로그램으로 캐시될 수 있다. 다르게 고친 재질은 customProgramCacheKey 를 따로 준다.
- [ ] **셰이더 안에서 잡음 반복문을 여러 번 부르면 윈도에서 컴파일이 수십 초** — 보물 동굴 게임에서 겪었다. 잡음은 미리 구운 텍스처로 읽는다.

## 완성 기준 체크리스트
- [ ] 왼쪽 그대로 · 오른쪽 셰이더 끼움을 나란히 두면, 오른쪽 풀만 끝부분이 물결처럼 흔들린다
- [ ] 오른쪽 버섯 갓은 가장자리부터 따뜻하게 빛나며 천천히 맥박친다
- [ ] 「바람」 0 이면 오른쪽도 멈추고, 그림자 · 빛은 양쪽이 똑같다

## 이 기술 정보
- id: `u08` · 분류: 그래픽 · 셰이더 › 재질 · 그림체 · 3D · 난이도 보통 · 폰 부담 가벼움 (폰 OK) — 몇 줄 덧붙일 뿐이라 거의 공짜. 다만 재질마다 셰이더가 하나씩 따로 컴파일된다.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/u08
- 쓰면 좋을 때: 표준 재질의 빛 · 그림자 · 안개를 다 살리면서 조금만 바꾸고 싶을 때 / 같은 효과를 여러 재질에 넣을 때 (함수 하나로)
- 쓰지 말 때: 모양 전체를 새로 그리는 효과(홀로그램 · 레이마칭) — 대신 ShaderMaterial 로 처음부터 / three.js 판을 자주 올리는 프로젝트에서 깊은 조각 고치기 — 조각 이름이 바뀔 수 있다

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### u08 견본 항목 — `src/demos/demosMaterial.ts:622`
```ts
  u08: {
    kind: '3d',
    caption: '같은 재질에 셰이더 몇 줄 — 오른쪽 풀은 바람에 흔들리고 버섯 갓은 속에서 빛나요',
    make() {
      const B = base({ bg: gradBg('#16204a', '#3a4c86', 'rgba(140,255,200,0.18)'), labels: ['그대로', '셰이더 끼움'], fit: [2.5, 0.85], target: [0, 0.25, 0], dir: [0, 0.5, 1] });
      lights(B.scene, 0xbfd8ff, 0x203040, 0.7, 1.3);
      const uTime = { value: 0 };
      const uWind = { value: 0.35 };
      const uGlow = { value: 1.2 };
      // 풀잎 모음 (한 덩어리)
      const pos: number[] = [];
      const col: number[] = [];
      const idx: number[] = [];
      let vi = 0;
      const rnd = (a: number, b: number): number => a + Math.random() * (b - a);
      for (let n = 0; n < 140; n++) {
        const a = Math.random() * Math.PI * 2;
        const rr = Math.sqrt(Math.random()) * 0.95;
        const x0 = Math.cos(a) * rr;
        const z0 = Math.sin(a) * rr * 0.7;
        const h = rnd(0.35, 0.75);
        const w = rnd(0.035, 0.06);
        const ang = Math.random() * Math.PI;
        const dx = Math.cos(ang);
        const dz = Math.sin(ang);
        const lean = rnd(-0.08, 0.08);
        for (let s = 0; s <= 3; s++) {
          const f = s / 3;
          const ww = w * (1 - f * 0.9);
          const y = h * f;
          const lx = lean * f * f;
          pos.push(x0 - dx * ww + lx, y, z0 - dz * ww, x0 + dx * ww + lx, y, z0 + dz * ww);
          const c1 = new THREE.Color().setHSL(0.27 + Math.random() * 0.04, 0.6, 0.18 + f * 0.35);
          col.push(c1.r, c1.g, c1.b, c1.r, c1.g, c1.b);
          if (s < 3) idx.push(vi, vi + 1, vi + 2, vi + 1, vi + 3, vi + 2);
          vi += 2;
        }
      }
      const gg = new THREE.BufferGeometry();
      gg.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3));
      gg.setAttribute('color', new THREE.Float32BufferAttribute(col, 3));
      gg.setIndex(idx);
      gg.computeVertexNormals();
      const grassA = new THREE.MeshStandardMaterial({ vertexColors: true, side: THREE.DoubleSide, roughness: 0.8 });
      const grassB = grassA.clone();
      grassB.onBeforeCompile = (sh) => {
        sh.uniforms['uTime'] = uTime;
        sh.uniforms['uWind'] = uWind;
        sh.vertexShader = 'uniform float uTime;\nuniform float uWind;\n' + sh.vertexShader.replace(
          '#include <begin_vertex>',
          `#include <begin_vertex>
          vec4 wq = modelMatrix * vec4(position, 1.0);
          float hh = max(position.y, 0.0);
          transformed.x += sin(uTime * 2.2 + wq.x * 2.5 + wq.z * 1.3) * uWind * hh * hh * 1.6;
          transformed.z += cos(uTime * 1.7 + wq.x * 1.5) * uWind * hh * hh * 0.7;`,
        );
      };
      const capA = new THREE.MeshStandardMaterial({ color: 0xd04a6a, roughness: 0.6 });
      const capB = capA.clone();
      capB.onBeforeCompile = (sh) => {
        sh.uniforms['uGlow'] = uGlow;
        sh.uniforms['uTime'] = uTime;
        sh.fragmentShader = 'uniform float uGlow;\nuniform float uTime;\n' + sh.fragmentShader.replace(
          '#include <emissivemap_fragment>',
          `#include <emissivemap_fragment>
          {
            float facing = clamp(dot(normalize(normal), normalize(vViewPosition)), 0.0, 1.0);
            float rimL = pow(1.0 - facing, 1.6);
            float pulse = 0.75 + 0.25 * sin(uTime * 2.0);
            totalEmissiveRadiance += vec3(1.0, 0.75, 0.45) * uGlow * pulse * (0.18 + rimL);
          }`,
        );
      };
      const stemM = new THREE.MeshStandardMaterial({ color: 0xf2e6cf, roughness: 0.7 });
      const groundM = new THREE.MeshStandardMaterial({ color: 0x3a5a2a, roughness: 1 });
      const capG = new THREE.SphereGeometry(0.42, 40, 20, 0, Math.PI * 2, 0, Math.PI / 2);
      const stemG = new THREE.CylinderGeometry(0.13, 0.17, 0.5, 20);
      const groundG = new THREE.CylinderGeometry(1.05, 1.0, 0.12, 40);
      const set = (gm: THREE.Material, cm: THREE.Material): THREE.Group => {
        const g = new THREE.Group();
        const ground = new THREE.Mesh(groundG, groundM);
        ground.scale.z = 0.72;
        ground.position.y = -0.06;
        const grass = new THREE.Mesh(gg, gm);
        const stem = new THREE.Mesh(stemG, stemM);
        stem.position.set(0.15, 0.25, 0.15);
        const cap = new THREE.Mesh(capG, cm);
        cap.position.set(0.15, 0.46, 0.15);
        cap.scale.set(1, 0.8, 1);
        g.add(ground, grass, stem, cap);
        return g;
      };
      const L = set(grassA, capA);
      const R = set(grassB, capB);
      L.position.x = -1.2;
      R.position.x = 1.2;
      B.scene.add(L, R);
      return B.done({
        update(t) {
          uTime.value = t;
          for (const g of [L, R]) g.rotation.y = Math.sin(t * 0.3) * 0.4;
        },
        controls: [
          { type: 'range', label: '바람', min: 0, max: 1, step: 0.05, value: 0.35, on: (v) => { uWind.value = v; } },
          { type: 'range', label: '갓 속빛', min: 0, max: 3, step: 0.1, value: 1.2, on: (v) => { uGlow.value = v; } },
        ],
      });
    },
  }
```

## 관련 기술
- 먼저 알면 좋은 기술: [후처리 사슬 (EffectComposer → OutputPass)](https://ai-techstudio.web.app/ai/t/u21.md) `u21`
- 다음에 해 볼 기술: [테두리 빛 (rim light, 프레넬)](https://ai-techstudio.web.app/ai/t/i10.md) `i10` · [정점 흔들기 (바람 · 물결)](https://ai-techstudio.web.app/ai/t/i13.md) `i13` · [디졸브 (잡음으로 사라짐)](https://ai-techstudio.web.app/ai/t/i12.md) `i12`
- 참고 문서: [three.js 예제 — webgl_materials_modified](https://threejs.org/examples/#webgl_materials_modified) · [three.js 소스 — ShaderChunk](https://github.com/mrdoob/three.js/tree/dev/src/renderers/shaders/ShaderChunk)
