# AI 꾸러미 — 바위 · 돌 절차 생성 — Procedural rock generation
> 공을 찌그리고 → 평면으로 깎고 → 잡음으로 울퉁불퉁하게 → 위쪽에 이끼를 얹어, 씨앗만 바꿔 수십 가지 바위를 만든다.  
> 견본: https://ai-techstudio.web.app/#t/i452

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

## 주문서

### 만들어 줘: 바위 · 돌 절차 생성 — Procedural rock generation

#### 1. 목표
이끼 낀 바위 무더기를 절차 바위로 만들어 줘 — 찌그리기 · 평면 깎기 · 잡음 · 이끼 순서로, 씨앗만 바꿔 다양하게. 분위기는 동화 숲.

#### 2. 핵심 기술 용어
- **Procedural rock generation** — 절차 바위 만들기
- **Icosphere + fBm displacement** — 고른 공 + 여러 겹 잡음으로 표면 밀기
- **Planar cuts (chiseled faces)** — 평면으로 깎인 면 — 바위다운 납작한 결
- **Slope-based moss (normal.y mask)** — 위를 보는 곳에만 이끼

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

#### 4. 조건
- 순서를 지킨다: 찌그리기 → 평면 깎기 → 잡음 → 색 (깎기 전에 잡음을 넣으면 깎인 면이 지저분하다)
- 깎인 면 점은 표시해 두고 잡음을 약하게(0.4배)
- 바닥 평면(아래 방향)은 늘 하나 넣어 바위가 땅에 앉게
- 같은 씨앗이면 같은 바위 — 무작위는 씨앗 난수로
- 굽기는 프레임 예산(3ms)으로 나눠 — 바위 수십 개를 한 번에 만들어도 화면이 안 멈추게

#### 5. 완성 기준 (이게 보이면 성공)
- 공 → 찌그린 덩이 → 깎인 면 → 울퉁불퉁 → 이끼 다섯 단계를 차례로 볼 수 있다
- 「깎는 면 수」 0 ~ 8 · 「잡음 세기」 0 ~ 2 · 「이끼」 0 ~ 1 슬라이더로 바위 성격이 바뀐다
- 「새로 만들기」마다 같은 규칙의 다른 바위 무더기가 나온다
- 위를 보는 면에만 이끼가 끼고, 파인 틈은 어둡다

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

## 원리
- 고르게 나눈 공(정이십면체 나누기)에서 시작한다 — 극에 점이 몰리지 않는다.
- 찌그리기: 아무렇게나 돌리고 x · y · z 를 다르게 늘이고(y 는 0.55 ~ 0.85), 큰 잡음 혹을 곱한다.
- 깎기: 바닥 + 무작위 평면 여러 개(견본 5)로, 평면 밖으로 나간 점을 평면까지 밀어 넣는다. 경계는 살짝 둥글게.
- 잡음 변위: 큰 잡음 + 잔 잡음을 원래 방향으로 더한다. 깎인 면은 0.4배만 — 깎인 결이 살아 있게.
- 색: 얼룩 × 틈 그늘(파인 곳 어둡게) × 층 띠, 그리고 법선 y 가 큰 곳(위를 보는 곳)에 이끼 색을 섞는다.

## 핵심 코드 — 평면 깎기 + 잡음 변위
(발췌: demos/demosModelB.ts makeRock() 2 · 3 단계를 정리)
```ts
// P1 = 찌그린 공의 점들. 평면마다: 가장 튀어나온 거리 ext 의 62 ~ 86% 에서 자른다
const planes = [new THREE.Vector3(0, -1, 0)]; // 바닥은 늘
for (let k = 0; k < cuts; k++) planes.push(new THREE.Vector3(rng() * 2 - 1, rng() * 1.1 - 0.3, rng() * 2 - 1).normalize());
const P2 = P1.slice();
const cutAmt = new Float32Array(n);
for (let k = 0; k < planes.length; k++) {
  const pn = planes[k]!;
  let ext = 0;
  for (let i = 0; i < n; i++) ext = Math.max(ext, pn.x * P2[i * 3] + pn.y * P2[i * 3 + 1] + pn.z * P2[i * 3 + 2]);
  const d = ext * (k === 0 ? 0.62 : 0.66 + rng() * 0.2), b = 0.05;
  for (let i = 0; i < n; i++) {
    const e = pn.x * P2[i * 3] + pn.y * P2[i * 3 + 1] + pn.z * P2[i * 3 + 2] - d;
    if (e <= -b) continue;
    const sh = e < b ? ((e + b) * (e + b)) / (4 * b) : e; // 경계는 살짝 둥글게
    P2[i * 3] -= pn.x * sh; P2[i * 3 + 1] -= pn.y * sh; P2[i * 3 + 2] -= pn.z * sh;
    if (sh > 0.01) cutAmt[i] = 1;
  }
}
// 잡음 변위 — 원래 공 방향(P0)으로, 깎인 면은 약하게
for (let i = 0; i < n; i++) {
  const px = P2[i * 3], py = P2[i * 3 + 1], pz = P2[i * 3 + 2];
  const d = ((fbm3(px * 2.4, py * 2.4, pz * 2.4, s + 5, 4) - 0.5) * 0.3
           + (fbm3(px * 8, py * 8, pz * 8, s + 9, 3) - 0.5) * 0.09) * noiseAmt * (cutAmt[i] ? 0.4 : 1);
  P3[i * 3] = px + P0[i * 3] * d; P3[i * 3 + 1] = py + P0[i * 3 + 1] * d; P3[i * 3 + 2] = pz + P0[i * 3 + 2] * d;
}
// 이끼: m = smooth(0.55, 0.85, normal.y + (fbm - 0.5) * 0.9) * moss → 바위색.lerp(이끼색, m)
```

## 흔한 실수 · 확인 목록
- [ ] **잡음을 깎기 전에 넣으면 깎인 면이 울퉁불퉁해져 바위 결이 사라진다** — 깎기 → 잡음 순서, 깎인 면 점은 잡음을 0.4배로.
- [ ] **UV 공(SphereGeometry)으로 시작하면 극에 점이 몰려 뾰족한 꼭지가 생긴다** — 정이십면체를 고르게 나눈 공(icosphere)으로 시작한다.
- [ ] **바위 수십 개를 한 번에 구우면 시작이 멈춘다** — 생성기로 나눠 프레임마다 3ms 씩 굽는다.

## 완성 기준 체크리스트
- [ ] 공 → 찌그린 덩이 → 깎인 면 → 울퉁불퉁 → 이끼 다섯 단계를 차례로 볼 수 있다
- [ ] 「깎는 면 수」 0 ~ 8 · 「잡음 세기」 0 ~ 2 · 「이끼」 0 ~ 1 슬라이더로 바위 성격이 바뀐다
- [ ] 「새로 만들기」마다 같은 규칙의 다른 바위 무더기가 나온다
- [ ] 위를 보는 면에만 이끼가 끼고, 파인 틈은 어둡다

## 이 기술 정보
- id: `i452` · 분류: 3D 모델 · 캐릭터 › 3D 모델링 · 절차 · 3D · 난이도 보통 · 폰 부담 보통 (폰 주의) — 바위 하나 정점 수천(나눔 13 ~ 24단계) × 잡음 여러 번 — 굽기는 무겁지만 한 번뿐, 프레임당 3ms 로 나눠 굽는다. 다 구우면 보통 메시.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i452
- 쓰면 좋을 때: 바위 · 돌이 많이 필요한 섬 · 동굴 · 길 / 같은 규칙으로 모양이 다른 변형을 씨앗으로 뽑을 때
- 쓰지 말 때: 딱 한 개의 주인공 바위 — 손으로 다듬거나 스캔 재질(i474)이 낫다 / 매끈한 조약돌 — 찌그린 공만으로 충분

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### rocksDemo — `src/demos/demosModelB.ts:1688`
```ts
function rocksDemo(): Scene3D {
  const scene = new THREE.Scene();
  const bg = bgTex('#14262a', '#3a5a52');
  scene.background = bg;
  addLights(scene, { sky: 0xdff0ff, ground: 0x4a4030, hemi: 1.15, sun: 3.0, dir: [-3, 6, 3.5], ext: 3.4 });
  const camera = new THREE.PerspectiveCamera(36, 1.6, 0.1, 50);
  // 디오라마 받침: 흙 · 이끼 얼룩
  const gc = document.createElement('canvas');
  gc.width = gc.height = 1024;
  const gctx = gc.getContext('2d')!;
  gctx.fillStyle = '#5c5638';
  gctx.fillRect(0, 0, 1024, 1024);
  const gt = new THREE.CanvasTexture(gc);
  // 흙 무늬는 돌을 다 만든 뒤 나눠서 칠한다
  const groundJob = new Job();
  groundJob.start(
    (function* (): G {
    const g = gctx;
    const im = g.createImageData(1024, 1024);
    for (let y = 0; y < 1024; y++) {
      if ((y & 3) === 3 && late()) yield;
      for (let x = 0; x < 1024; x++) {
        const m = smooth(0.48, 0.58, fbm2(x / 90, y / 90, 3, 3) + (noise2(x / 6, y / 6, 4) - 0.5) * 0.12);
        const d = 0.55 * noise2(x / 3, y / 3, 8) + 0.45 * noise2(x / 11, y / 11, 9);
        const k = 0.75 + 0.45 * d;
        const o = (y * 1024 + x) * 4;
        im.data[o] = lerp(116, 74, m) * k;
        im.data[o + 1] = lerp(88, 122, m) * k;
        im.data[o + 2] = lerp(62, 46, m) * k;
        im.data[o + 3] = 255;
      }
    }
    g.putImageData(im, 0, 0);
    gt.needsUpdate = true;
    })(),
  );
  gt.colorSpace = THREE.SRGBColorSpace;
  const ground = new THREE.Mesh(new THREE.CylinderGeometry(3.1, 3.2, 0.3, 80), [
    new THREE.MeshStandardMaterial({ color: 0x4a3626, roughness: 1 }),
    new THREE.MeshStandardMaterial({ map: gt, roughness: 1 }),
    new THREE.MeshStandardMaterial({ color: 0x4a3626, roughness: 1 }),
  ]);
  ground.position.y = -0.15;
  ground.receiveShadow = true;
  scene.add(ground);

  const opts: RockOpts = { cuts: 5, noise: 1, moss: 0.6 };
  let seed = 11;
  let wire = false;
  let ms = 0;
  let tris = 0;
  let field = new THREE.Group();
  let heroes: { mesh: THREE.Mesh; d: RockData; col: THREE.BufferAttribute }[] = [];
  const mat = new THREE.MeshStandardMaterial({ vertexColors: true, roughness: 0.9 });
  const tags = new Tags(2, ['rgba(12,16,34,0.72)', 'rgba(40,90,60,0.85)']);
  const HS = 0.78;
  const job = new Job();
  const warm = new Warm();
  let total = 1;
  let made = 0;
  let t00 = -1;

  function build(): void {
    scene.remove(field);
    freeAll(field);
    field = new THREE.Group();
    scene.add(field);
    const rng = rngOf(seed);
    tris = 0;
    const spots: [number, number, number][] = [];
    let tries = 0;
    while (spots.length < 26 && tries++ < 600) {
      const a = rng() * TAU;
      const r = 0.9 + Math.sqrt(rng()) * 1.9;
      const x = Math.cos(a) * r;
      const z = Math.sin(a) * r * 0.85 - 0.2;
      if (Math.abs(x) < 1.0 && z > 0.1) continue;
      const s = 0.16 + rng() * rng() * 0.42;
      if (spots.some(([px, pz, ps]) => Math.hypot(px - x, pz - z) < (ps + s) * 1.05)) continue;
      spots.push([x, z, s]);
    }
    heroes = [];
    t00 = -1;
    total = spots.length + 3;
    made = 0;
    const seeds = spots.map(() => Math.floor(rng() * 1e6));
    const hseeds = [0, 1, 2].map(() => Math.floor(rng() * 1e6));
    const yaws = spots.map(() => rng() * TAU);
    const f0 = field;
    // 돌은 하나씩 나눠 굽는다 — 큰 돌 먼저, 그다음 둘레 돌이 하나씩 생긴다
    const hero = function* (k: number): G {
      const d = yield* makeRock(hseeds[k]!, 24, opts);
      if (field !== f0) return;
      const g = d.geo;
      (g.getAttribute('position').array as Float32Array).set(d.stages[0]!);
      (g.getAttribute('normal').array as Float32Array).set(d.normals[0]!);
      g.morphAttributes.position = [1, 2, 3].map((i) => new THREE.BufferAttribute(d.stages[i]!, 3));
      g.morphAttributes.normal = [1, 2, 3].map((i) => new THREE.BufferAttribute(d.normals[i]!, 3));
      const col = new THREE.BufferAttribute(d.base.slice(), 3);
      g.setAttribute('color', col);
      const m = new THREE.Mesh(g, mat);
      m.updateMorphTargets();
      m.scale.setScalar(HS);
      m.position.set(0, 0, 1.05);
      m.castShadow = m.receiveShadow = true;
      m.visible = false;
      warm.add(m, field);
      heroes.push({ mesh: m, d, col });
      made++;
      if (k === 0) tris += triCount(g);
    };
    job.start(
      (function* (): G {
        yield* hero(0);
        for (let i = 0; i < spots.length; i++) {
          const [x, z, s] = spots[i]!;
          const d = yield* makeRock(seeds[i]!, 13, opts);
          if (field !== f0) return;
          const g = d.geo;
          (g.getAttribute('position').array as Float32Array).set(d.stages[3]!);
          (g.getAttribute('normal').array as Float32Array).set(d.normals[3]!);
          g.setAttribute('color', new THREE.BufferAttribute(d.final, 3));
          const m = new THREE.Mesh(g, mat);
          m.scale.setScalar(s);
          m.position.set(x, -d.minY[3]! * s - 0.02, z);
          m.rotation.y = yaws[i]!;
          m.castShadow = m.receiveShadow = true;
          warm.add(m, field);
          tris += triCount(g);
          made++;
        }
        yield* hero(1);
        yield* hero(2);
        ms = job.spent;
      })(),
    );
    mat.wireframe = wire;
  }
  build();

  const CYC = 7.2;
  let stage = 0;
  let lastMoss = -1;
  let lastHero = -1;
  return {
    scene,
    camera,
    update(t) {
      camera.position.set(Math.sin(t * 0.15) * 0.7, 2.35, 5.2);
      camera.lookAt(0, 0.3, 0.3);
      if (job.busy) job.step();
      else if (groundJob.busy) groundJob.step();
      if (!heroes.length || !heroes[0]!.mesh.parent) {
        stage = -1;
        return;
      }
      if (t00 < 0) t00 = t;
      const cyc = Math.floor((t - t00) / CYC);
      const p = (t - t00) % CYC;
      const hi = cyc % heroes.length;
      // 단계 값 s: 0 → 1 → 2 → 3, 이끼 m
      const seg = (a: number): number => easeIO(sat((p - a) / 0.8));
      let s = seg(0.5) + seg(1.9) + seg(3.3);
      const back = easeIO(sat((p - (CYC - 0.5)) / 0.5));
      s *= 1 - back;
      const m = smooth(4.6, 5.4, p) * (1 - back);
      stage = p < 0.5 ? 0 : p < 1.9 ? 1 : p < 3.3 ? 2 : p < 4.6 ? 3 : 4;
      heroes.forEach((h, k) => (h.mesh.visible = k === hi));
      const h = heroes[hi]!;
      const inf = h.mesh.morphTargetInfluences!;
      inf[0] = s <= 1 ? s : s <= 2 ? 2 - s : 0;
      inf[1] = s <= 1 ? 0 : s <= 2 ? s - 1 : 3 - s;
      inf[2] = s <= 2 ? 0 : s - 2;
      const my = h.d.minY;
      const w0 = 1 - inf[0]! - inf[1]! - inf[2]!;
      const minY = w0 * my[0]! + inf[0]! * my[1]! + inf[1]! * my[2]! + inf[2]! * my[3]!;
      h.mesh.position.y = -minY * HS - 0.02;
      h.mesh.rotation.y = t * 0.3;
      const mq = Math.round(m * 40) / 40;
      if (mq !== lastMoss || hi !== lastHero) {
        lastMoss = mq;
        lastHero = hi;
        const a = h.col.array as Float32Array;
        const b0 = h.d.base;
        const f = h.d.final;
        for (let i = 0; i < a.length; i++) a[i] = b0[i]! + (f[i]! - b0[i]!) * mq;
        h.col.needsUpdate = true;
      }
    },
    render(r, w, h) {
      tags.set(0, job.busy ? `돌 만드는 중 ${made} / ${total} · 나눠 굽기 ${job.spent.toFixed(0)}ms` : `돌 ${total - 2}개 · 삼각형 ${fmt(tris)} · 만들기 ${ms.toFixed(0)}ms (나눠서)`);
      tags.set(1, stage < 0 ? '큰 돌 만드는 중…' : ['① 둥근 공 (다면체)', '② 찌그리기 — 늘이고 돌리고 혹', '③ 깎기 — 평면으로 잘라 낸 면', '④ 잡음 변위 — 울퉁불퉁 결', '⑤ 이끼 — 위를 보는 곳만 정점 색'][stage]!);
      if (warm.tick(r, scene, camera, 0x22383a)) shadowed(r, () => r.render(scene, camera));
      tags.draw(r, w, h, [
        [0.02, 0.06, -1],
        [0.5, 0.92, 0],
      ]);
    },
    controls: [
      {
        type: 'range',
        label: '깎는 면 수',
        min: 0,
        max: 8,
        step: 1,
        value: 5,
        on: (v) => {
          opts.cuts = v;
          build();
        },
      },
      {
        type: 'range',
        label: '잡음 세기',
        min: 0,
        max: 2,
        step: 0.1,
        value: 1,
        on: (v) => {
          opts.noise = v;
          build();
        },
      },
      {
        type: 'range',
        label: '이끼',
        min: 0,
        max: 1,
        step: 0.1,
        value: 0.6,
        on: (v) => {
          opts.moss = v;
          build();
        },
      },
      {
        type: 'toggle',
        label: '와이어프레임',
        value: false,
        on: (v) => {
          wire = v;
          mat.wireframe = v;
        },
      },
      {
        type: 'button',
        label: '새로 만들기',
        on: () => {
          seed = (seed * 7919 + 13) % 100000;
          build();
        },
      },
    ] as Control[],
    dispose() {
      freeAll(field);
      freeAll(ground);
      gt.dispose();
      mat.dispose();
      bg.dispose();
      tags.dispose();
    },
  };
}
```

### i452 견본 항목 — `src/demos/demosModelB.ts:3141`
```ts
  i452: { kind: '3d', caption: '공 → 찌그리기 → 평면 깎기 → 잡음 → 이끼 — 같은 규칙, 씨앗만 바꿔 수십 가지 돌', make: () => rocksDemo() }
```

## 관련 기술
- 먼저 알면 좋은 기술: [절차 마모 재질 (CG 티 없애기)](https://ai-techstudio.web.app/ai/t/i84.md) `i84`
- 다음에 해 볼 기술: [정점 구석 그늘 굽기 (AO 를 미리)](https://ai-techstudio.web.app/ai/t/i453.md) `i453` · [표면에 디테일 흩뿌리기 (풀 · 꽃 · 자갈)](https://ai-techstudio.web.app/ai/t/i455.md) `i455`
