# AI 꾸러미 — 동화 뭉게 나무 (구 법선 잎 · 층별 바람) — Spherical normals for foliage (normal transfer)
> 잎 카드의 법선을 덩이를 감싸는 구의 법선으로 바꾸고 줄기 · 가지 · 잎을 다른 빠르기로 흔들어, 동화풍 뭉게 나무를 만든다.  
> 견본: https://ai-techstudio.web.app/#t/i538

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

## 주문서

### 만들어 줘: 동화 뭉게 나무 (구 법선 잎 · 층별 바람) — Spherical normals for foliage (normal transfer)

#### 1. 목표
줄기 + 가지 여섯 + 잎 덩이 여덟 나무를 동화풍 뭉게 나무로 만들어 줘 — 잎 카드 법선은 덩이 구 법선으로, 바람은 줄기 · 가지 · 잎이 다른 빠르기로. 분위기는 파스텔 동화.

#### 2. 핵심 기술 용어
- **Spherical normals for foliage (normal transfer)** — 잎 카드 법선을 감싸는 구 법선으로 바꾸기
- **Alpha-tested leaf cards** — 잎 그림 판 (alphaTest 로 오려 내기)
- **Hierarchical wind (trunk · branch · flutter)** — 줄기 휨 · 가지 흔들림 · 잎 떨림을 층별로
- **onBeforeCompile vertex sway** — 표준 재질에 바람 정점 식 끼워 넣기

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

#### 4. 조건
- 잎 카드 정점 법선 = normalize(정점 위치 − 덩이 중심) — 카드 면 법선을 쓰지 않는다
- 양면 잎(DoubleSide)은 fragment 의 normal_fragment_begin 을 바꿔 뒷면에서 법선을 뒤집지 않기
- 그림자용 customDepthMaterial 에도 같은 alphaTest · 바람 식을 넣는다 (안 넣으면 그림자가 네모 · 안 흔들림)
- 줄기 · 가지 · 잎 떨림은 서로 다른 빠르기와 크기로 (줄기 ×1.1, 가지 ×2.3 ~ 3.1, 잎 ×11)
- 바뀐 셰이더는 customProgramCacheKey 로 구분한다

#### 5. 완성 기준 (이게 보이면 성공)
- 같은 나무 둘을 나란히 — 왼쪽(카드 법선)은 잎이 조각조각 얼룩지고, 오른쪽(구 법선)은 덩이가 공처럼 매끈하게 밝고 어둡다
- 바람에 줄기가 천천히 휘고, 가지 덩이가 저마다 흔들리고, 잎 끝이 잘게 떨린다
- 땅에 잎 모양 그림자가 바람과 함께 흔들린다
- 폰에서도 60fps

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

## 원리
- 잎 카드는 저마다 아무 방향을 보고 있어서, 카드 법선 그대로면 빛이 조각조각 얼룩진다.
- 정점마다 법선을 「덩이 중심 → 그 정점」 방향(구 법선)으로 바꾸면 덩이 전체가 공 하나처럼 매끈하게 빛을 받는다.
- 덩이 안쪽 카드일수록 정점 색을 어둡게(0.62 → 1) 해 덩이 속 그늘을 흉내 낸다.
- 바람: 줄기는 높이² 비례로 천천히 휘고, 가지는 덩이마다 위상을 달리해 흔들리고, 잎은 법선 방향으로 빠르게(×11) 떨린다.
- 양면 잎은 뒷면에서 법선을 뒤집지 않게 고쳐야 구 법선이 그대로 산다.

## 핵심 코드 — 구 법선 잎 카드 + 층별 바람 정점 식
(발췌: demos/demosPlanet.ts makeFluffy() buildTree · windPatch 를 정리)
```ts
// 잎 카드 정점: 법선 = 덩이 중심에서 바깥쪽 (구 법선), 색 = 안쪽일수록 어둡게
const ns = v.clone().sub(cl.c).normalize();
norS.set([ns.x, ns.y, ns.z], o);
const shade = 0.62 + 0.38 * smooth(0.1, 0.9, inner); // inner = 중심에서 거리 / 덩이 반지름
aBranch.set([cl.c.x, cl.c.y, cl.c.z, cl.ph], (qi * 4 + c) * 4); // 덩이 중심 + 위상

// 바람 — 줄기(높이²) · 가지(덩이 위상) · 잎 떨림(법선 방향, 빠르게)
leafMat.onBeforeCompile = (sh) => {
  Object.assign(sh.uniforms, { uT, uWind });
  sh.vertexShader = 'uniform float uT; uniform float uWind; attribute vec4 aBranch; attribute float aH;\n' +
    sh.vertexShader.replace('#include <begin_vertex>', `#include <begin_vertex>
    float gust = 0.6 + 0.4 * sin(uT * 0.37) * sin(uT * 0.21 + 1.3);
    vec3 sway = vec3(sin(uT * 1.1) * 0.9 + 0.35, 0.0, sin(uT * 0.83 + 1.0) * 0.4) * aH * aH * 0.22 * uWind * gust;
    float bw = aBranch.w;
    vec3 branch = vec3(sin(uT * 2.3 + bw * 6.28), sin(uT * 3.1 + bw * 4.0) * 0.4, cos(uT * 1.9 + bw * 6.28)) * 0.07 * uWind * gust;
    vec3 flutter = normal * sin(uT * 11.0 + bw * 40.0 + position.x * 7.0 + position.y * 5.0) * 0.035 * uWind;
    transformed += sway + branch + flutter;`);
  // 양면 잎: 뒷면에서 법선을 뒤집지 않는다 — 구 법선 그대로
  sh.fragmentShader = sh.fragmentShader.replace('#include <normal_fragment_begin>',
    'float faceDirection = gl_FrontFacing ? 1.0 : - 1.0;\nvec3 normal = normalize( vNormal );\nvec3 nonPerturbedNormal = normal;');
};
leafMat.customProgramCacheKey = () => 'wind-sphere';
```

## 흔한 실수 · 확인 목록
- [ ] **양면 잎의 뒷면에서 법선이 뒤집혀 덩이 반쪽이 시커멓다** — normal_fragment_begin 을 바꿔 faceDirection 을 곱하지 않는다 — 구 법선을 그대로 받는다.
- [ ] **그림자용 깊이 재질을 안 바꾸면 그림자가 네모 판이고 안 흔들린다** — MeshDepthMaterial 에 같은 잎 그림 · alphaTest 0.45 · 바람 식을 넣고 customDepthMaterial 로.
- [ ] **모든 층을 같은 빠르기로 흔들면 나무 전체가 고무처럼 출렁인다** — 줄기는 느리고 크게, 가지는 덩이마다 위상을 달리, 잎은 빠르고 작게 — 세 층을 다르게.

## 완성 기준 체크리스트
- [ ] 같은 나무 둘을 나란히 — 왼쪽(카드 법선)은 잎이 조각조각 얼룩지고, 오른쪽(구 법선)은 덩이가 공처럼 매끈하게 밝고 어둡다
- [ ] 바람에 줄기가 천천히 휘고, 가지 덩이가 저마다 흔들리고, 잎 끝이 잘게 떨린다
- [ ] 땅에 잎 모양 그림자가 바람과 함께 흔들린다
- [ ] 폰에서도 60fps

## 이 기술 정보
- id: `i538` · 분류: 3D 모델 · 캐릭터 › 3D 모델링 · 절차 · 3D · 난이도 쉬움 · 폰 부담 가벼움 (폰 OK) — 나무 하나 = 줄기 메시 1 + 잎 카드 320장 메시 1 (그림자 포함 그리기 몇 번). 바람은 정점 셰이더 식 몇 줄.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i538
- 쓰면 좋을 때: 동화 · 지브리풍 숲 · 정원처럼 나무가 덩어리로 보여야 할 때 / 잎을 수천 장 그려도 한 덩이로 정돈돼 보이고 싶을 때
- 쓰지 말 때: 사실적인 나무 — 대신 가지가 자라는 나무(i444) · L-시스템(i445) 에 카드 법선 그대로 / 아주 먼 배경 — 카드 대신 구 몇 개나 그림 판이 싸다

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### makeFluffy — `src/demos/demosPlanet.ts:1120`
```ts
function makeFluffy(): Scene3D {
  const scene = new THREE.Scene();
  const bg = pastelSky('#8fd0ff', '#d2efff', '#fff0dc');
  scene.background = bg;
  const cam = new THREE.PerspectiveCamera(40, 1.6, 0.1, 100);
  scene.add(new THREE.HemisphereLight(0xdcefff, 0x5a4a3a, 0.55));
  const sun = new THREE.DirectionalLight(0xfff0d6, 4.2);
  sun.position.set(10, 7, 3);
  sun.castShadow = true;
  sun.shadow.mapSize.set(1024, 1024);
  const sc = sun.shadow.camera;
  sc.left = sc.bottom = -8;
  sc.right = sc.top = 8;
  sun.shadow.bias = -0.0005;
  sun.shadow.normalBias = 0.04;
  scene.add(sun);
  const W = { uT: { value: 0 }, uWind: { value: 1 }, uLayer: { value: 1 } };
  const atlas = leafAtlas();
  const geos: THREE.BufferGeometry[] = [];
  const mats: THREE.Material[] = [];
  // 땅
  const groundG = new THREE.CircleGeometry(14, 64).rotateX(-Math.PI / 2);
  const groundM = new THREE.MeshStandardMaterial({ color: 0x9fd77a, roughness: 1 });
  const ground = new THREE.Mesh(groundG, groundM);
  ground.receiveShadow = true;
  scene.add(ground);
  geos.push(groundG);
  mats.push(groundM);

  /** 나무 하나 — 같은 씨앗이면 같은 모양. sphere = 잎 법선을 덩이 중심에서 바깥쪽으로 */
  const buildTree = (sphere: boolean): { trunk: THREE.Mesh; leaves: THREE.Mesh; mats: THREE.Material[]; setSphere(on: boolean): void } => {
    const r = rng(31);
    const H = 3.6;
    const tubes: THREE.BufferGeometry[] = [];
    const clusters: { c: THREE.Vector3; rad: number; ph: number }[] = [];
    const addTube = (pts: THREE.Vector3[], r0: number, r1: number): void => {
      const curve = new THREE.CatmullRomCurve3(pts);
      const g = new THREE.TubeGeometry(curve, 12, 1, 8, false);
      // 굵기: 아래 → 위로 가늘게
      const p = g.attributes.position as THREE.BufferAttribute;
      const n = g.attributes.normal as THREE.BufferAttribute;
      const rings = 13;
      for (let i = 0; i < p.count; i++) {
        const ring = Math.floor(i / 9);
        const k = ring / (rings - 1);
        const c = curve.getPointAt(Math.min(1, k));
        const rad = lerp(r0, r1, k);
        p.setXYZ(i, c.x + n.getX(i) * rad, c.y + n.getY(i) * rad, c.z + n.getZ(i) * rad);
      }
      tubes.push(g);
    };
    // 줄기
    const trunkTop = new THREE.Vector3(0.15, H * 0.62, 0.05);
    addTube([new THREE.Vector3(0, -0.1, 0), new THREE.Vector3(0.05, H * 0.25, 0), new THREE.Vector3(-0.05, H * 0.45, 0.04), trunkTop], 0.26, 0.14);
    // 가지 + 끝마다 잎 덩이
    const NB = 6;
    for (let i = 0; i < NB; i++) {
      const a = (i / NB) * TAU + r() * 0.6;
      const up = 0.35 + r() * 0.5;
      const len = 1.1 + r() * 0.6;
      const start = trunkTop.clone().lerp(new THREE.Vector3(0, H * 0.35, 0), r() * 0.6);
      const end = start.clone().add(new THREE.Vector3(Math.cos(a) * len, up * len + 0.3, Math.sin(a) * len));
      const mid = start.clone().lerp(end, 0.5).add(new THREE.Vector3(0, 0.15, 0));
      addTube([start, mid, end], 0.1, 0.04);
      clusters.push({ c: end.clone().add(new THREE.Vector3(0, 0.25, 0)), rad: 0.85 + r() * 0.35, ph: r() });
    }
    clusters.push({ c: trunkTop.clone().add(new THREE.Vector3(0, 1.0, 0)), rad: 1.15, ph: r() });
    clusters.push({ c: trunkTop.clone().add(new THREE.Vector3(0.5, 0.55, -0.4)), rad: 0.9, ph: r() });
    const trunkG = mergeGeometries(tubes)!;
    for (const t of tubes) t.dispose();
    // 줄기에는 가지 위상 없음, 높이만
    {
      const p = trunkG.attributes.position as THREE.BufferAttribute;
      const aH = new Float32Array(p.count);
      for (let i = 0; i < p.count; i++) aH[i] = clamp(p.getY(i) / (H + 1.2), 0, 1);
      trunkG.setAttribute('aH', new THREE.BufferAttribute(aH, 1));
      trunkG.setAttribute('aBranch', new THREE.BufferAttribute(new Float32Array(p.count * 4), 4));
    }
    // 잎 카드: 덩이 안에 무작위 방향 판 — 정점 색은 안쪽일수록 어둡게(덩이 속 그늘)
    const PER = 40;
    const nq = clusters.length * PER;
    const pos = new Float32Array(nq * 4 * 3);
    const nor = new Float32Array(nq * 4 * 3);
    const norS = new Float32Array(nq * 4 * 3);
    const norF = new Float32Array(nq * 4 * 3);
    const colS = new Float32Array(nq * 4 * 3);
    const colF = new Float32Array(nq * 4 * 3);
    const uv = new Float32Array(nq * 4 * 2);
    const colr = new Float32Array(nq * 4 * 3);
    const aH = new Float32Array(nq * 4);
    const aB = new Float32Array(nq * 4 * 4);
    const ind: number[] = [];
    const qd = new THREE.Quaternion();
    const e = new THREE.Euler();
    const corner = [new THREE.Vector3(-1, -1, 0), new THREE.Vector3(1, -1, 0), new THREE.Vector3(1, 1, 0), new THREE.Vector3(-1, 1, 0)];
    const uvs = [0, 0, 1, 0, 1, 1, 0, 1];
    const v = new THREE.Vector3();
    let qi = 0;
    for (const cl of clusters) {
      const hueShift = (r() - 0.5) * 0.08;
      for (let k = 0; k < PER; k++) {
        // 구 안 고르게 (겉에 더 많이)
        const d = new THREE.Vector3(r() * 2 - 1, r() * 2 - 1, r() * 2 - 1).normalize().multiplyScalar(cl.rad * Math.pow(r(), 0.35) * 0.82);
        d.y *= 0.8;
        const center = cl.c.clone().add(d);
        e.set(r() * TAU, r() * TAU, r() * TAU);
        qd.setFromEuler(e);
        const size = 0.5 + r() * 0.18;
        const face = new THREE.Vector3(0, 0, 1).applyQuaternion(qd);
        const inner = d.length() / cl.rad;
        for (let c = 0; c < 4; c++) {
          v.copy(corner[c]!).multiplyScalar(size).applyQuaternion(qd).add(center);
          const o = (qi * 4 + c) * 3;
          pos.set([v.x, v.y, v.z], o);
          const ns = v.clone().sub(cl.c).normalize();
          norS.set([ns.x, ns.y, ns.z], o);
          norF.set([face.x, face.y, face.z], o);
          const shade = 0.62 + 0.38 * smooth(0.1, 0.9, inner);
          colS.set([shade * (1 + hueShift), shade, shade * (1 - hueShift)], o);
          colF.set([1 + hueShift, 1, 1 - hueShift], o);
          uv.set([uvs[c * 2]!, uvs[c * 2 + 1]!], (qi * 4 + c) * 2);
          aH[qi * 4 + c] = clamp(v.y / (H + 1.2), 0, 1);
          aB.set([cl.c.x, cl.c.y, cl.c.z, cl.ph], (qi * 4 + c) * 4);
        }
        const b = qi * 4;
        ind.push(b, b + 1, b + 2, b, b + 2, b + 3);
        qi++;
      }
    }
    nor.set(sphere ? norS : norF);
    colr.set(sphere ? colS : colF);
    const lg = new THREE.BufferGeometry();
    lg.setAttribute('position', new THREE.BufferAttribute(pos, 3));
    lg.setAttribute('normal', new THREE.BufferAttribute(nor, 3));
    lg.setAttribute('uv', new THREE.BufferAttribute(uv, 2));
    lg.setAttribute('color', new THREE.BufferAttribute(colr, 3));
    lg.setAttribute('aH', new THREE.BufferAttribute(aH, 1));
    lg.setAttribute('aBranch', new THREE.BufferAttribute(aB, 4));
    lg.setIndex(ind);
    geos.push(trunkG, lg);
    const barkM = new THREE.MeshStandardMaterial({ color: 0x8a5a3c, roughness: 0.95 });
    windPatch(barkM, W, false);
    const leafM = new THREE.MeshStandardMaterial({ map: atlas, alphaTest: 0.45, side: THREE.DoubleSide, vertexColors: true, roughness: 0.85 });
    windPatch(leafM, W, true);
    const leafF = new THREE.MeshStandardMaterial({ map: atlas, alphaTest: 0.45, side: THREE.DoubleSide, vertexColors: true, roughness: 0.85 });
    windPatch(leafF, W, false);
    const depthM = new THREE.MeshDepthMaterial({ map: atlas, alphaTest: 0.45, depthPacking: THREE.RGBADepthPacking });
    windPatch(depthM, W, false);
    const trunk = new THREE.Mesh(trunkG, barkM);
    trunk.castShadow = trunk.receiveShadow = true;
    const leaves = new THREE.Mesh(lg, sphere ? leafM : leafF);
    leaves.castShadow = true;
    leaves.receiveShadow = true;
    leaves.customDepthMaterial = depthM;
    const setSphere = (on: boolean): void => {
      (lg.attributes.normal as THREE.BufferAttribute).array.set(on ? norS : norF);
      lg.attributes.normal!.needsUpdate = true;
      (lg.attributes.color as THREE.BufferAttribute).array.set(on ? colS : colF);
      lg.attributes.color!.needsUpdate = true;
      leaves.material = on ? leafM : leafF;
    };
    return { trunk, leaves, mats: [barkM, leafM, leafF, depthM], setSphere };
  };
  const left = new THREE.Group();
  const right = new THREE.Group();
  const tl = buildTree(false);
  const tr = buildTree(true);
  left.add(tl.trunk, tl.leaves);
  right.add(tr.trunk, tr.leaves);
  mats.push(...tl.mats, ...tr.mats);
  left.position.x = -3.1;
  right.position.x = 3.1;
  scene.add(left, right);
  // 풀 포기 몇 개 · 버섯 — 땅이 비지 않게
  const tuftG = mergeColored([
    [new THREE.ConeGeometry(0.06, 0.4, 4).translate(0, 0.2, 0).rotateZ(0.2), 0x6fbf4f],
    [new THREE.ConeGeometry(0.06, 0.34, 4).translate(0.08, 0.17, 0).rotateZ(-0.3), 0x7fcf5a],
    [new THREE.ConeGeometry(0.05, 0.3, 4).translate(-0.06, 0.15, 0.05).rotateX(0.3), 0x62b048],
  ]);
  const tuftM = new THREE.MeshStandardMaterial({ vertexColors: true, roughness: 0.9 });
  geos.push(tuftG);
  mats.push(tuftM);
  const tufts = new THREE.InstancedMesh(tuftG, tuftM, 160);
  {
    const rr = rng(4);
    const m4 = new THREE.Matrix4();
    for (let i = 0; i < 160; i++) {
      const a = rr() * TAU;
      const d = 1.2 + Math.sqrt(rr()) * 10;
      m4.compose(new THREE.Vector3(Math.cos(a) * d, 0, Math.sin(a) * d * 0.7), new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), rr() * TAU), new THREE.Vector3(1, 0.7 + rr() * 0.8, 1));
      tufts.setMatrixAt(i, m4);
    }
  }
  tufts.receiveShadow = true;
  scene.add(tufts);
  let sphereOn = true;
  let isBig = false;
  const overlay = new Overlay();
  let yaw = 0;
  const input = new Input(false);
  return {
    scene,
    camera: cam,
    update(t) {
      W.uT.value = t;
      if (input.drag.dx) {
        yaw += input.drag.dx * 0.005;
        input.drag.dx = input.drag.dy = 0;
      }
      const a = yaw + Math.sin(t * 0.15) * 0.35;
      cam.position.set(Math.sin(a) * 13, 4.4, Math.cos(a) * 13);
      cam.lookAt(0, 2.5, 0);
    },
    render(r, w, h) {
      isBig = w >= 700;
      if (isBig) input.attach(w, h);
      r.shadowMap.enabled = true;
      r.shadowMap.type = THREE.PCFShadowMap;
      r.render(scene, cam);
      const layered = W.uLayer.value > 0.5;
      overlay.draw(r, w, h, [
        { text: '잎 카드 법선 그대로 — 자글자글', x: 0.25, y: 0.05, ax: 0.5, ay: 0 },
        { text: sphereOn ? '감싸는 구 법선 — 뭉게뭉게 한 덩이' : '(구 법선 끔)', x: 0.75, y: 0.05, ax: 0.5, ay: 0, bg: sphereOn ? 'rgba(40,120,90,0.72)' : undefined },
        { text: layered ? '바람: 줄기 휨 · 가지 흔들림 · 잎 떨림' : '바람: 나무 전체가 통째로 기울기', x: 0.5, y: 0.95, ax: 0.5, ay: 1, bg: layered ? undefined : 'rgba(170,70,60,0.75)' },
      ]);
    },
    controls: [
      { type: 'toggle', label: '오른쪽 나무 — 감싸는 구 법선', value: true, on: (v) => ((sphereOn = v), tr.setSphere(v)) },
      { type: 'toggle', label: '층별 바람 (끄면 통째로 기울기)', value: true, on: (v) => (W.uLayer.value = v ? 1 : 0) },
      { type: 'range', label: '바람 세기', min: 0, max: 2.5, step: 0.05, value: 1, on: (v) => (W.uWind.value = v) },
    ],
    dispose() {
      input.dispose();
      overlay.dispose();
      tufts.dispose();
      for (const g of geos) g.dispose();
      for (const m of mats) m.dispose();
      atlas.dispose();
      bg.dispose();
    },
  };
}
```

### i538 견본 항목 — `src/demos/demosPlanet.ts:1632`
```ts
  i538: { kind: '3d', caption: '같은 나무 둘 — 왼쪽은 잎 카드 법선 그대로, 오른쪽은 잎 덩이를 감싸는 구 법선 + 줄기 · 가지 · 잎이 다른 빠르기로 흔들리는 바람', make: () => makeFluffy() }
```

## 관련 기술
- 먼저 알면 좋은 기술: [정점 흔들기 (바람 · 물결)](https://ai-techstudio.web.app/ai/t/i13.md) `i13` · [재질에 셰이더 끼워 넣기](https://ai-techstudio.web.app/ai/t/u08.md) `u08`
- 다음에 해 볼 기술: [표면에 디테일 흩뿌리기 (풀 · 꽃 · 자갈)](https://ai-techstudio.web.app/ai/t/i455.md) `i455` · [가지가 자라는 나무 (공간 군체화)](https://ai-techstudio.web.app/ai/t/i444.md) `i444`
