# AI 꾸러미 — 정점 구석 그늘 굽기 (AO 를 미리) — Baked per-vertex ambient occlusion
> 정점마다 반구 방향으로 광선 수십 개를 쏴 가려진 정도를 미리 계산해 정점 색에 구워, 실행 비용 0 으로 구석 · 맞닿은 곳을 어둡게 한다.  
> 견본: https://ai-techstudio.web.app/#t/i453

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

## 주문서

### 만들어 줘: 정점 구석 그늘 굽기 (AO 를 미리) — Baked per-vertex ambient occlusion

#### 1. 목표
블록 · 기둥 · 공을 쌓은 무대에 정점 구석 그늘을 미리 구워 줘 — 정점마다 반구 광선으로 가려진 정도를 재서 정점 색에, 후처리 없이. 분위기는 밝은 장난감.

#### 2. 핵심 기술 용어
- **Baked per-vertex ambient occlusion** — 정점 구석 그늘 미리 굽기
- **Cosine-weighted hemisphere sampling** — 법선 쪽으로 몰린 반구 광선 고르기
- **Ray–primitive intersection** — 상자 · 구 · 원기둥과 광선 만나는 거리
- **Vertex color multiply** — 구운 그늘을 정점 색으로 곱하기

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

#### 4. 조건
- 광선은 법선 쪽 반구로, 코사인 가중 · 나선 배치 + 정점마다 무작위 회전 (줄무늬 막기)
- 광선 시작점을 법선 쪽으로 살짝(0.003) 띄워 자기 자신에 맞지 않게
- 먼 물체는 미리 거른다 (중심 거리 > maxD + 반지름 이면 건너뜀)
- 굽고 나서 이웃 정점 평균 두 번으로 잡음을 줄인다
- 전/후를 반씩 나눈 비교 막대와 「AO 만 보기」(흰 재질)로 차이를 보여 준다

#### 5. 완성 기준 (이게 보이면 성공)
- 블록이 바닥에 닿은 곳 · 블록 사이 틈 · 안쪽 구석이 부드럽게 어둡다
- 나눔 막대 왼쪽(그늘 없음)과 오른쪽(구운 그늘)이 한 화면에서 비교된다
- 「AO 세기」 · 「광선 거리」 · 「광선 수」 슬라이더로 그늘 넓이 · 진하기 · 매끈함이 바뀐다
- 구운 뒤에는 FPS 가 그늘 없을 때와 같다

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

## 원리
- 구석에 있는 점은 반구 하늘의 많은 부분이 이웃 물체에 가려 빛을 덜 받는다.
- 정점마다 법선 쪽 반구로 광선 K 개(견본 48)를 쏜다. 코사인 가중 + 황금각 나선이라 고르게 퍼진다.
- 광선이 거리 maxD(0.9) 안에서 무엇에 맞으면 가까울수록 많이 가린 것으로 센다: 가림 += 1 − smooth(0.35, 1, t/maxD).
- AO = 1 − 가림/K. 이웃 정점과 두 번 평균 내 광선 잡음을 줄이고, 정점 색 = lerp(1, AO^2.2, 세기).
- 광선은 모든 삼각형 대신 간단한 도형(상자 · 구 · 원기둥 · 바닥 평면)과 맞혀 빠르게 계산한다.

## 핵심 코드 — 정점 하나의 반구 광선 가림 계산
(발췌: demos/demosModelB.ts aoDemo() bakeG 를 정리)
```ts
// 코사인 가중 반구 표본 (황금각 나선) — 지역 좌표 (x, y, z=법선 쪽)
const K = 48, maxD = 0.9;
const sdir: number[] = [];
for (let k = 0; k < K; k++) {
  const u = (k + 0.5) / K, ph = k * 2.399963, r = Math.sqrt(u);
  sdir.push(r * Math.cos(ph), r * Math.sin(ph), Math.sqrt(1 - u));
}
const smooth = (a: number, b: number, x: number) => { const t = Math.min(1, Math.max(0, (x - a) / (b - a))); return t * t * (3 - 2 * t); };

function aoAt(v: THREE.Vector3, n: THREE.Vector3, near: Prim[], seed: number): number {
  const tA = new THREE.Vector3().crossVectors(Math.abs(n.y) < 0.99 ? UP : EX, n).normalize();
  const tB = new THREE.Vector3().crossVectors(n, tA);
  const rot = seed * Math.PI * 2, cr = Math.cos(rot), sr = Math.sin(rot); // 정점마다 돌려 줄무늬 막기
  const o = v.clone().addScaledVector(n, 0.003);                          // 자기 면에 맞지 않게
  let occ = 0;
  for (let k = 0; k < K; k++) {
    const lx = sdir[k * 3] * cr - sdir[k * 3 + 1] * sr, ly = sdir[k * 3] * sr + sdir[k * 3 + 1] * cr, lz = sdir[k * 3 + 2];
    const d = tA.clone().multiplyScalar(lx).addScaledVector(tB, ly).addScaledVector(n, lz);
    let t = d.y < -1e-6 ? -o.y / d.y : Infinity;                           // 바닥 평면
    for (const p of near) t = Math.min(t, hitPrim(p, o, d));                // 상자 · 구 · 원기둥
    if (t < maxD) occ += 1 - smooth(0.35, 1, t / maxD);                    // 가까울수록 많이 가림
  }
  return 1 - occ / K;
}
// 다 구운 뒤: 이웃 정점 평균 2번 → 정점 색 = lerp(1, Math.pow(ao, 2.2), strength)
// 재질: new THREE.MeshStandardMaterial({ color, vertexColors: true })
```

## 흔한 실수 · 확인 목록
- [ ] **광선을 모든 정점에 같은 방향으로 쏘면 그늘에 줄무늬가 생긴다** — 정점마다 표본 묶음을 무작위 각도로 돌리고, 굽은 뒤 이웃 평균을 두 번 낸다.
- [ ] **시작점을 표면에 딱 붙이면 자기 면에 맞아 온통 시커멓다** — 법선 쪽으로 0.003 띄워서 쏜다.
- [ ] **모든 삼각형과 광선을 맞추면 굽기가 몇 초 걸린다** — 견본은 물체를 상자 · 구 · 원기둥 도형으로 보고 맞추며, 먼 물체는 미리 거른다. 진짜 메시면 BVH(three-mesh-bvh)를 쓴다.
- [ ] **굽는 동안 화면이 멈춘다** — 정점 16개마다 시간 검사 후 yield — 프레임당 3ms 예산.

## 완성 기준 체크리스트
- [ ] 블록이 바닥에 닿은 곳 · 블록 사이 틈 · 안쪽 구석이 부드럽게 어둡다
- [ ] 나눔 막대 왼쪽(그늘 없음)과 오른쪽(구운 그늘)이 한 화면에서 비교된다
- [ ] 「AO 세기」 · 「광선 거리」 · 「광선 수」 슬라이더로 그늘 넓이 · 진하기 · 매끈함이 바뀐다
- [ ] 구운 뒤에는 FPS 가 그늘 없을 때와 같다

## 이 기술 정보
- id: `i453` · 분류: 3D 모델 · 캐릭터 › 3D 모델링 · 절차 · 3D · 난이도 보통 · 폰 부담 가벼움 (폰 OK) — 실행 중 비용 0 (정점 색 곱하기뿐). 굽기는 정점 수 × 광선 48 — 프레임당 3ms 로 나눠 굽는다.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i453
- 쓰면 좋을 때: 움직이지 않는 무대 · 소품에 깊이감을 줄 때 / 폰에서 GTAO 같은 후처리를 못 쓸 때 (실행 비용 0)
- 쓰지 말 때: 물체가 움직여 맞닿는 곳이 바뀌는 장면 — 대신 후처리 구석 그늘(i02) 이나 접촉 그림자(i15) / 정점이 아주 적은 큰 판 — 그늘이 정점 사이로 번진다. 판을 잘게 나누거나 라이트맵

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### aoDemo — `src/demos/demosModelB.ts:2007`
```ts
function aoDemo(): Scene3D {
  const scene = new THREE.Scene();
  const bg = bgTex('#1d2338', '#4a4f72');
  scene.background = bg;
  addLights(scene, { sky: 0xeef2ff, ground: 0x8a7a68, hemi: 1.9, sun: 1.1, dir: [-3, 6, 4], ext: 3.4 });
  const camera = new THREE.PerspectiveCamera(36, 1.6, 0.1, 50);
  const tags = new Tags(3, ['rgba(12,16,34,0.72)', 'rgba(60,60,80,0.8)', 'rgba(110,60,150,0.85)']);
  let seed = 5;
  let rays = 48;
  let maxD = 0.9;
  let strength = 1;
  let auto = true;
  let frac = 0.5;
  let aoOnly = false;
  let wire = false;
  let ms = 0;
  let verts = 0;
  let group = new THREE.Group();
  const job = new Job();
  const warm = new Warm();
  let done = 0;
  type Obj = { mesh: THREE.Mesh; plain: THREE.MeshStandardMaterial; ao: THREE.MeshStandardMaterial; aoArr: Float32Array; prim: Prim | null; col: THREE.Color };
  let objs: Obj[] = [];
  const PAL = [0xf28b82, 0xffd36e, 0x8cc8f0, 0x8fd9b6, 0xc3a6f0, 0xffb38a];

  function build(): void {
    job.start(buildG());
  }
  function* buildG(): G {
    for (const o of objs) {
      o.plain.dispose();
      o.ao.dispose();
    }
    scene.remove(group);
    freeAll(group);
    group = new THREE.Group();
    const g0 = group;
    objs = [];
    const rng = rngOf(seed);
    const j = (s: number): number => (rng() - 0.5) * s;
    const pick = (): number => PAL[Math.floor(rng() * PAL.length)]!;
    const seg = (s: number): number => Math.max(2, Math.ceil(s / 0.05));
    const add = (kind: 0 | 1 | 2, a: number, b: number, c: number, x: number, y: number, z: number, ry: number, color: number): void => {
      const geo =
        kind === 0
          ? new THREE.BoxGeometry(a * 2, b * 2, c * 2, seg(a * 2), seg(b * 2), seg(c * 2))
          : kind === 1
            ? new THREE.CylinderGeometry(a, a, b * 2, 40, seg(b * 2))
            : new THREE.SphereGeometry(a, 40, 28);
      const col = new THREE.Color(color);
      const plain = new THREE.MeshStandardMaterial({ color: col, roughness: 0.7 });
      const ao = new THREE.MeshStandardMaterial({ color: col, roughness: 0.7, vertexColors: true });
      const mesh = new THREE.Mesh(geo, plain);
      mesh.position.set(x, y, z);
      mesh.rotation.y = ry;
      mesh.castShadow = mesh.receiveShadow = true;
      group.add(mesh);
      mesh.updateMatrixWorld(true);
      const br = kind === 0 ? Math.hypot(a, b, c) : kind === 1 ? Math.hypot(a, b) : a;
      objs.push({ mesh, plain, ao, aoArr: new Float32Array(0), prim: { kind, inv: mesh.matrixWorld.clone().invert(), a, b, c, cx: x, cy: y, cz: z, br }, col });
    };
    // 바닥 (잘게 나눈 판)
    {
      const geo = new THREE.PlaneGeometry(6, 3.6, 120, 72).rotateX(-PI / 2);
      const col = new THREE.Color(0xf1e6d2);
      const plain = new THREE.MeshStandardMaterial({ color: col, roughness: 0.85 });
      const ao = new THREE.MeshStandardMaterial({ color: col, roughness: 0.85, vertexColors: true });
      const mesh = new THREE.Mesh(geo, plain);
      mesh.receiveShadow = true;
      group.add(mesh);
      objs.push({ mesh, plain, ao, aoArr: new Float32Array(0), prim: null, col });
    }
    yield;
    // 아치
    const ax = -1.6 + j(0.2);
    const ac = pick();
    add(0, 0.15, 0.45, 0.15, ax - 0.45, 0.45, 0, 0, ac);
    add(0, 0.15, 0.45, 0.15, ax + 0.45, 0.45, 0, 0, ac);
    add(0, 0.68, 0.13, 0.2, ax, 1.03, 0, 0, pick());
    add(2, 0.2, 0, 0, ax + j(0.2), 0.2, 0.05, 0, pick());
    if (late()) yield;
    // 쌓은 상자
    const cx = -0.25 + j(0.2);
    add(0, 0.3, 0.3, 0.3, cx, 0.3, -0.35, j(0.3), pick());
    add(0, 0.22, 0.22, 0.22, cx + j(0.12), 0.82, -0.35 + j(0.1), 0.4 + j(0.5), pick());
    add(0, 0.2, 0.2, 0.2, cx + 0.42, 0.2, 0.3, -0.3 + j(0.5), pick());
    if (late()) yield;
    // 계단
    const sx = 0.85 + j(0.2);
    const sc = pick();
    for (let k = 0; k < 4; k++) add(0, 0.28, 0.1 * (k + 1), 0.13, sx, 0.1 * (k + 1), 0.4 - k * 0.26, 0, sc);
    if (late()) yield;
    // 탑 + 공
    const tx = 1.85 + j(0.15);
    add(1, 0.28, 0.5, 0, tx, 0.5, -0.1, 0, pick());
    add(2, 0.28, 0, 0, tx, 1.28, -0.1, 0, pick());
    add(2, 0.17 + rng() * 0.06, 0, 0, tx - 0.45, 0.2, 0.5, 0, pick());
    // 뒤 담
    add(0, 2.6, 0.22, 0.08, 0, 0.22, -1.15, 0, 0xe7d9c0);
    hiddenUsers(group, objs.map((o) => o.ao));
    verts = 0;
    for (const o of objs) {
      o.aoArr = new Float32Array(o.mesh.geometry.getAttribute('position').count).fill(1);
      verts += o.aoArr.length;
    }
    done = 0;
    applyAO();
    warm.add(group, scene, () => group === g0);
    yield;
    yield* bakeG(objs);
  }

  function bake(): void {
    verts = 0;
    for (const o of objs) {
      o.aoArr = new Float32Array(o.mesh.geometry.getAttribute('position').count).fill(1);
      verts += o.aoArr.length;
    }
    done = 0;
    applyAO();
    job.start(bakeG(objs));
  }
  // 정점마다 반구 광선 — 나눠서 (프레임마다 3ms), 물체 하나 끝날 때마다 바로 색을 바꾼다
  function* bakeG(list: Obj[]): G {
    const prims = list.map((o) => o.prim);
    // 코사인 가중 반구 표본 (나선)
    const K = rays;
    const sdir: number[] = [];
    for (let k = 0; k < K; k++) {
      const u = (k + 0.5) / K;
      const ph = k * 2.399963;
      const r = Math.sqrt(u);
      sdir.push(r * Math.cos(ph), r * Math.sin(ph), Math.sqrt(1 - u));
    }
    const v = new THREE.Vector3();
    const n = new THREE.Vector3();
    const tA = new THREE.Vector3();
    const tB = new THREE.Vector3();
    const up = new THREE.Vector3(0, 1, 0);
    const ex = new THREE.Vector3(1, 0, 0);
    for (const o of list) {
      const g = o.mesh.geometry;
      const pos = g.getAttribute('position');
      const nor = g.getAttribute('normal');
      const m = o.mesh.matrixWorld;
      const nm = new THREE.Matrix3().getNormalMatrix(m);
      const cnt = pos.count;
      const ao = new Float32Array(cnt);
      for (let i = 0; i < cnt; i++) {
        if ((i & 15) === 15 && late()) yield;
        v.fromBufferAttribute(pos, i).applyMatrix4(m);
        n.fromBufferAttribute(nor, i).applyMatrix3(nm).normalize();
        const near: Prim[] = [];
        for (const p of prims) if (p && p !== o.prim && Math.hypot(p.cx - v.x, p.cy - v.y, p.cz - v.z) < maxD + p.br) near.push(p);
        const onGround = o.prim === null;
        if (!near.length && onGround) {
          ao[i] = 1;
          continue;
        }
        tA.crossVectors(Math.abs(n.y) < 0.99 ? up : ex, n).normalize();
        tB.crossVectors(n, tA);
        const rot = hash3(i, cnt, 7, 1) * TAU;
        const cr = Math.cos(rot);
        const sr = Math.sin(rot);
        const ox = v.x + n.x * 0.003;
        const oy = v.y + n.y * 0.003;
        const oz = v.z + n.z * 0.003;
        let occ = 0;
        for (let k = 0; k < K; k++) {
          const lx = sdir[k * 3]! * cr - sdir[k * 3 + 1]! * sr;
          const ly = sdir[k * 3]! * sr + sdir[k * 3 + 1]! * cr;
          const lz = sdir[k * 3 + 2]!;
          const dx = tA.x * lx + tB.x * ly + n.x * lz;
          const dy = tA.y * lx + tB.y * ly + n.y * lz;
          const dz = tA.z * lx + tB.z * ly + n.z * lz;
          let tmin = !onGround && dy < -1e-6 ? -oy / dy : Infinity;
          for (const p of near) {
            const e = p.inv.elements;
            const qx = e[0]! * ox + e[4]! * oy + e[8]! * oz + e[12]!;
            const qy = e[1]! * ox + e[5]! * oy + e[9]! * oz + e[13]!;
            const qz = e[2]! * ox + e[6]! * oy + e[10]! * oz + e[14]!;
            const t = hitPrim(p, qx, qy, qz, e[0]! * dx + e[4]! * dy + e[8]! * dz, e[1]! * dx + e[5]! * dy + e[9]! * dz, e[2]! * dx + e[6]! * dy + e[10]! * dz);
            if (t < tmin) tmin = t;
          }
          if (tmin < maxD) occ += 1 - smooth(0.35, 1, tmin / maxD);
        }
        ao[i] = 1 - occ / K;
      }
      // 이웃 정점과 두 번 고르게 (광선 잡음 줄이기)
      const idx = g.getIndex();
      if (idx) {
        for (let it = 0; it < 2; it++) {
          const sum = new Float32Array(cnt);
          const num = new Float32Array(cnt);
          for (let k = 0; k < idx.count; k += 3) {
            if ((k & 4095) === 4095 && late()) yield;
            const a = idx.getX(k);
            const b = idx.getX(k + 1);
            const c = idx.getX(k + 2);
            const s3 = ao[a]! + ao[b]! + ao[c]!;
            sum[a] = sum[a]! + s3;
            sum[b] = sum[b]! + s3;
            sum[c] = sum[c]! + s3;
            num[a] = num[a]! + 3;
            num[b] = num[b]! + 3;
            num[c] = num[c]! + 3;
          }
          for (let i = 0; i < cnt; i++) if (num[i]! > 0) ao[i] = sum[i]! / num[i]!;
          if (late()) yield;
        }
      }
      if (list !== objs) return;
      if (late()) yield;
      o.aoArr = ao;
      applyOne(o);
      done += cnt;
    }
    ms = job.spent;
  }
  function applyAO(): void {
    for (const o of objs) applyOne(o);
  }
  function applyOne(o: Obj): void {
    {
      const g = o.mesh.geometry;
      const cnt = o.aoArr.length;
      const col = new Float32Array(cnt * 3);
      for (let i = 0; i < cnt; i++) {
        const a = lerp(1, Math.pow(o.aoArr[i]!, 2.2), strength);
        col[i * 3] = col[i * 3 + 1] = col[i * 3 + 2] = a;
      }
      g.setAttribute('color', new THREE.BufferAttribute(col, 3));
      o.ao.color.copy(aoOnly ? new THREE.Color(0xf4f4f4) : o.col);
      o.ao.wireframe = o.plain.wireframe = wire;
    }
  }
  build();

  const setMat = (useAO: boolean): void => {
    for (const o of objs) o.mesh.material = useAO ? o.ao : o.plain;
  };
  return {
    scene,
    camera,
    update(t) {
      camera.position.set(Math.sin(t * 0.17) * 0.5, 2.65, 5.3);
      camera.lookAt(0, 0.45, -0.1);
      if (auto) frac = 0.5 + 0.36 * Math.sin(t * 0.8);
      if (job.busy) job.step();
    },
    render(r, w, h) {
      tags.set(0, job.busy ? `AO 굽는 중 ${Math.round((done / Math.max(1, verts)) * 100)}% · 나눠서 ${job.spent.toFixed(0)}ms` : `정점 ${fmt(verts)} · 광선 ${rays}개씩 · 굽기 ${ms.toFixed(0)}ms (나눠서)`);
      tags.set(1, '굽기 전');
      tags.set(2, aoOnly ? 'AO 만 보기' : '정점 AO 굽기 후');
      if (warm.tick(r, scene, camera, 0x2c3150))
      shadowed(r, () =>
        slider(
          r,
          w,
          h,
          frac,
          () => {
            setMat(false);
            r.render(scene, camera);
          },
          () => {
            setMat(true);
            r.render(scene, camera);
          },
        ),
      );
      tags.draw(r, w, h, [
        [0.02, 0.06, -1],
        [frac - 0.015, 0.92, 1],
        [frac + 0.015, 0.92, -1],
      ]);
    },
    controls: [
      { type: 'toggle', label: '비교 막대 자동', value: true, on: (v) => (auto = v) },
      { type: 'range', label: '나눔 위치', min: 0, max: 1, step: 0.01, value: 0.5, on: (v) => ((auto = false), (frac = v)) },
      {
        type: 'range',
        label: 'AO 세기',
        min: 0,
        max: 1.5,
        step: 0.05,
        value: 1,
        on: (v) => {
          strength = v;
          applyAO();
        },
      },
      {
        type: 'range',
        label: '광선 거리',
        min: 0.2,
        max: 2,
        step: 0.1,
        value: 0.9,
        on: (v) => {
          maxD = v;
          bake();
        },
      },
      {
        type: 'range',
        label: '광선 수 (다시 굽기)',
        min: 8,
        max: 96,
        step: 8,
        value: 48,
        on: (v) => {
          rays = v;
          bake();
        },
      },
      {
        type: 'toggle',
        label: 'AO 만 보기 (흰 재질)',
        value: false,
        on: (v) => {
          aoOnly = v;
          applyAO();
        },
      },
      {
        type: 'toggle',
        label: '와이어프레임',
        value: false,
        on: (v) => {
          wire = v;
          applyAO();
        },
      },
      {
        type: 'button',
        label: '새로 만들기',
        on: () => {
          seed = (seed * 7919 + 13) % 100000;
          build();
        },
      },
    ] as Control[],
    dispose() {
      for (const o of objs) {
        o.plain.dispose();
        o.ao.dispose();
      }
      freeAll(group);
      bg.dispose();
      tags.dispose();
    },
  };
}
```

### i453 견본 항목 — `src/demos/demosModelB.ts:3140`
```ts
  i453: { kind: '3d', caption: '같은 무대를 반씩 — 정점마다 반구 광선으로 가려진 정도를 미리 구워 구석 · 맞닿은 곳이 어두워짐', make: () => aoDemo() }
```

## 관련 기술
- 먼저 알면 좋은 기술: [복셀 렌더 (덩어리 메시)](https://ai-techstudio.web.app/ai/t/i241.md) `i241` · [구석 그늘 (SSAO / GTAO)](https://ai-techstudio.web.app/ai/t/i02.md) `i02`
- 다음에 해 볼 기술: [바위 · 돌 절차 생성](https://ai-techstudio.web.app/ai/t/i452.md) `i452` · [표면에 디테일 흩뿌리기 (풀 · 꽃 · 자갈)](https://ai-techstudio.web.app/ai/t/i455.md) `i455`
- 참고 문서: [Wikipedia — Ambient occlusion](https://en.wikipedia.org/wiki/Ambient_occlusion)
