# AI 꾸러미 — 복셀 렌더 (덩어리 메시) — Greedy meshing (voxel)
> 블록 세계에서 보이는 면만 골라 같은 색 · 같은 그늘끼리 큰 네모로 합치고 꼭짓점 구석 그늘을 넣어, 한 메시로 가볍게 그린다.  
> 견본: https://ai-techstudio.web.app/#t/i241

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

## 주문서

### 만들어 줘: 복셀 렌더 (덩어리 메시) — Greedy meshing (voxel)

#### 1. 목표
잡음 지형 블록 섬 (나무 포함)을(를) 한 메시로 만들어 줘 — 보이는 면만, 같은 면끼리 큰 네모로 합치고, 꼭짓점 구석 그늘까지. 분위기는 밝은 낮 하늘.

#### 2. 핵심 기술 용어
- **Greedy meshing (voxel)** — 이웃한 같은 면을 큰 네모로 합치기
- **Face culling (hidden face removal)** — 옆이 막힌 면은 안 만들기
- **Voxel ambient occlusion (per-vertex)** — 꼭짓점마다 이웃 블록 수로 구석 그늘
- **BufferGeometry + vertexColors** — 정점 색으로 색 · 그늘을 한 번에

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

#### 4. 조건
- 옆 칸이 막힌 면은 만들지 않는다 (판 밖은 빈칸으로 본다)
- 합치는 열쇠 = 색 번호 + 네 꼭짓점 그늘 — 그늘이 다르면 합치지 않는다
- 구석 그늘은 정점 색에 곱한다 (후처리 GTAO 쓰지 않기)
- 네모를 나눌 대각선은 그늘 합으로 고른다 (ao0+ao2 < ao1+ao3 이면 뒤집기)
- 합치기 · 그늘 켬/끔 단추와 면 테두리 선 보기로 차이를 보여 준다

#### 5. 완성 기준 (이게 보이면 성공)
- 테두리 선을 켜면 같은 색 면이 큰 네모로 묶여 있는 게 보인다
- 「덩어리 합치기」를 끄면 한 칸 한 칸 네모로 바뀌고, 화면 숫자가 몇 배 차이를 보여 준다
- 「구석 그늘」을 켜면 블록이 맞닿은 안쪽 구석이 어두워진다
- 지형이 흘러가며 매초 다시 지어져도 끊김이 없다

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

## 원리
- 블록마다 상자를 그리면 붙어서 안 보이는 면까지 그린다. 옆 칸이 비어 있는 면만 만든다.
- 세 축(x · y · z) × 앞뒤 두 방향마다 한 장씩 「면 지도(mask)」를 만들고, 같은 열쇠(색 + 그늘)끼리 가로로 늘린 뒤 세로로 늘려 큰 네모 하나로 합친다.
- 꼭짓점 그늘: 면 앞쪽의 옆 둘 + 대각 하나를 보고, 옆 둘이 다 막히면 0, 아니면 3 − (막힌 수). 밝기는 [0.42, 0.62, 0.82, 1].
- 그늘 값이 다른 면은 열쇠가 달라 합쳐지지 않으므로 그늘이 뭉개지지 않는다.
- 네모를 삼각형 둘로 나눌 때 그늘 합이 작은 대각선 쪽으로 나눠야 그늘 무늬가 비뚤어지지 않는다.

## 핵심 코드 — 면 지도를 큰 네모로 합치는 부분 (greedy)
(발췌: demos/demosLook2.ts makeVoxel() mesher 를 정리)
```ts
const AO_CURVE = [0.42, 0.62, 0.82, 1]; // 막힌 정도 0~3 → 밝기

// mask: 이 장(축 d, 방향 side)의 du × dv 칸. 0 = 면 없음, 아니면 1 + 색*256 + 그늘 8비트
let n = 0;
for (let j = 0; j < dv; j++)
  for (let i = 0; i < du; ) {
    const k = mask[n]!;
    if (!k) { i++; n++; continue; }
    let w = 1, h = 1;
    while (i + w < du && mask[n + w] === k) w++;           // 가로로 늘리기
    outer: for (; j + h < dv; h++)                          // 세로로 늘리기 (한 줄 통째로 같아야)
      for (let kk = 0; kk < w; kk++) if (mask[n + kk + h * du] !== k) break outer;

    const ao = (k - 1) & 255;
    const aov = [ao & 3, (ao >> 2) & 3, (ao >> 4) & 3, (ao >> 6) & 3];
    // 네 꼭짓점 (i,j) (i+w,j) (i+w,j+h) (i,j+h) 을 넣고, 색 × AO_CURVE[aov[c]] 를 정점 색으로
    pushQuad(i, j, w, h, ((k - 1) >> 8) & 255, aov);
    const flip = aov[0]! + aov[2]! < aov[1]! + aov[3]!;      // 그늘이 고르게 보이는 대각선
    const tri = flip ? [0, 1, 3, 1, 2, 3] : [0, 1, 2, 0, 2, 3];
    if (side < 0) tri.reverse();
    pushTris(tri);

    for (let jj = 0; jj < h; jj++) for (let ii = 0; ii < w; ii++) mask[n + ii + jj * du] = 0; // 쓴 칸 지우기
    i += w; n += w;
  }

// 꼭짓점 그늘: s1 · s2 = 옆 두 칸, c = 대각 칸 (막혔으면 1)
const corner = (s1: number, s2: number, c: number) => (s1 && s2 ? 0 : 3 - (s1 + s2 + c));
```

## 흔한 실수 · 확인 목록
- [ ] **그늘을 열쇠에 안 넣고 합치면 구석 그늘이 큰 네모 전체로 번진다** — 색 번호와 네 꼭짓점 그늘을 함께 열쇠로 써서, 그늘이 같은 면끼리만 합친다.
- [ ] **대각선을 늘 같은 쪽으로 나누면 그늘이 비스듬히 얼룩진다** — 그늘 합(ao0+ao2 와 ao1+ao3)을 비교해 나눌 대각선을 고른다.
- [ ] **뒤쪽 면의 삼각형 순서를 안 뒤집으면 면이 안 보인다** — side < 0 이면 삼각형 꼭짓점 순서를 뒤집어 바깥을 보게 한다.

## 완성 기준 체크리스트
- [ ] 테두리 선을 켜면 같은 색 면이 큰 네모로 묶여 있는 게 보인다
- [ ] 「덩어리 합치기」를 끄면 한 칸 한 칸 네모로 바뀌고, 화면 숫자가 몇 배 차이를 보여 준다
- [ ] 「구석 그늘」을 켜면 블록이 맞닿은 안쪽 구석이 어두워진다
- [ ] 지형이 흘러가며 매초 다시 지어져도 끊김이 없다

## 이 기술 정보
- id: `i241` · 분류: 3D 모델 · 캐릭터 › 3D 모델링 · 절차 · 3D · 난이도 보통 · 폰 부담 가벼움 (폰 OK) — 그리기 1번, 면 수가 몇 배 줄어든다(견본은 화면에 「보이는 면 → 덩어리 몇 배 적게」 표시). 22×12×22 판은 다시 짓기가 몇 ms.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i241
- 쓰면 좋을 때: 쌓기나무 · 블록 섬처럼 칸이 수천 개인 판 / 판이 바뀔 때마다 다시 지어도 빨라야 할 때
- 쓰지 말 때: 블록이 수십 개뿐 — 그냥 InstancedMesh(u36) 가 간단하다 / 블록마다 따로 움직이는 판 — 합친 메시는 통째로만 움직인다

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### makeVoxel — `src/demos/demosLook2.ts:3751`
```ts
function makeVoxel(): Scene3D {
  const scene = new THREE.Scene();
  scene.background = gradBg([
    [0, '#6fb8ff'],
    [1, '#d6eeff'],
  ]);
  const cam = new THREE.PerspectiveCamera(38, 1.6, 0.1, 100);
  scene.add(new THREE.HemisphereLight(0xffffff, 0x8090a0, 1.6));
  const dl = new THREE.DirectionalLight(0xfff2dc, 1.6);
  dl.position.set(5, 9, 3);
  scene.add(dl);
  const X = 22;
  const Y = 12;
  const Z = 22;
  const vox = new Uint8Array(X * Y * Z);
  const at = (x: number, y: number, z: number) => (x < 0 || y < 0 || z < 0 || x >= X || y >= Y || z >= Z ? 0 : vox[x + X * (y + Y * z)]!);
  const set = (x: number, y: number, z: number, v: number) => {
    if (x >= 0 && y >= 0 && z >= 0 && x < X && y < Y && z < Z) vox[x + X * (y + Y * z)] = v;
  };
  const gen = (off: number) => {
    vox.fill(0);
    for (let x = 0; x < X; x++)
      for (let z = 0; z < Z; z++) {
        const n = vnoise((x + off) * 0.13, z * 0.13) * 0.7 + vnoise((x + off) * 0.31, z * 0.31) * 0.3;
        const hh = Math.max(1, Math.round(1 + n * 8));
        for (let y = 0; y < hh; y++) set(x, y, z, y === hh - 1 ? (hh <= 2 ? 6 : 1) : y > hh - 3 ? 2 : 3);
      }
    // 나무 (같은 자리는 같은 나무가 되게 세계 좌표로)
    for (let x = 2; x < X - 2; x++)
      for (let z = 2; z < Z - 2; z++) {
        const s = Math.sin((x + off) * 12.9898 + z * 78.233) * 43758.5453;
        if (s - Math.floor(s) > 0.975) {
          let top = 0;
          while (top < Y && at(x, top, z)) top++;
          if (top < 3 || top > Y - 5) continue;
          for (let k = 0; k < 3; k++) set(x, top + k, z, 4);
          for (let dx = -1; dx <= 1; dx++) for (let dz = -1; dz <= 1; dz++) for (let dy = 2; dy <= 3; dy++) if (!at(x + dx, top + dy, z + dz)) set(x + dx, top + dy, z + dz, 5);
          set(x, top + 4, z, 5);
        }
      }
  };
  const geo = new THREE.BufferGeometry();
  const lineGeo = new THREE.BufferGeometry();
  const mesh = new THREE.Mesh(geo, new THREE.MeshLambertMaterial({ vertexColors: true }));
  const lines = new THREE.LineSegments(lineGeo, new THREE.LineBasicMaterial({ color: 0x102040, transparent: true, opacity: 0.55 }));
  const root = new THREE.Group();
  root.add(mesh, lines);
  root.position.set(-X / 2, -3, -Z / 2);
  scene.add(root);
  let greedy = true;
  let aoOn = true;
  let faces = 0;
  let quads = 0;
  const col = new THREE.Color();
  const mesher = () => {
    const P: number[] = [];
    const C: number[] = [];
    const N: number[] = [];
    const I: number[] = [];
    const L: number[] = [];
    const dims = [X, Y, Z];
    faces = 0;
    quads = 0;
    for (let d = 0; d < 3; d++) {
      const u = (d + 1) % 3;
      const v = (d + 2) % 3;
      const du = dims[u]!;
      const dv = dims[v]!;
      const mask = new Int32Array(du * dv);
      for (const side of [1, -1]) {
        const x = [0, 0, 0];
        for (x[d] = 0; x[d]! < dims[d]!; x[d]!++) {
          let n = 0;
          for (x[v] = 0; x[v]! < dv; x[v]!++)
            for (x[u] = 0; x[u]! < du; x[u]!++) {
              const a = at(x[0]!, x[1]!, x[2]!);
              const q = [x[0]!, x[1]!, x[2]!];
              q[d]! += side;
              const b = at(q[0]!, q[1]!, q[2]!);
              let key = 0;
              if (a && !b) {
                faces++;
                let ci = a;
                if (a === 1 && d !== 1) ci = 7;
                if (a === 1 && d === 1 && side < 0) ci = 2;
                let ao = 0;
                if (aoOn) {
                  const s = (du2: number, dv2: number) => {
                    const p = [q[0]!, q[1]!, q[2]!];
                    p[u]! += du2;
                    p[v]! += dv2;
                    return at(p[0]!, p[1]!, p[2]!) ? 1 : 0;
                  };
                  const corner = (cu: number, cv: number) => {
                    const s1 = s(cu, 0);
                    const s2 = s(0, cv);
                    const c = s(cu, cv);
                    return s1 && s2 ? 0 : 3 - (s1 + s2 + c);
                  };
                  ao = corner(-1, -1) | (corner(1, -1) << 2) | (corner(1, 1) << 4) | (corner(-1, 1) << 6);
                } else ao = 255;
                key = 1 + ci * 256 + ao;
              }
              mask[n++] = key;
            }
          // 덩어리 합치기
          n = 0;
          for (let j = 0; j < dv; j++)
            for (let i = 0; i < du; ) {
              const k = mask[n]!;
              if (!k) {
                i++;
                n++;
                continue;
              }
              let w = 1;
              let hgt = 1;
              if (greedy) {
                while (i + w < du && mask[n + w] === k) w++;
                outer: for (; j + hgt < dv; hgt++)
                  for (let kk = 0; kk < w; kk++)
                    if (mask[n + kk + hgt * du] !== k) break outer;
              }
              const base = [0, 0, 0];
              base[d] = x[d]! + (side > 0 ? 1 : 0);
              base[u] = i;
              base[v] = j;
              const corner = (cu: number, cv: number) => {
                const p = [base[0]!, base[1]!, base[2]!];
                p[u]! += cu;
                p[v]! += cv;
                return p;
              };
              const cs = [corner(0, 0), corner(w, 0), corner(w, hgt), corner(0, hgt)];
              const ao = (k - 1) & 255;
              const ci = ((k - 1) >> 8) & 255;
              const aov = [ao & 3, (ao >> 2) & 3, (ao >> 4) & 3, (ao >> 6) & 3];
              const vi = P.length / 3;
              cs.forEach((p, ix) => {
                P.push(p[0]!, p[1]!, p[2]!);
                col.set(VOX_COL[ci]!);
                const f = aoOn ? AO_CURVE[aov[ix]!]! : 1;
                C.push(col.r * f, col.g * f, col.b * f);
                const nn = [0, 0, 0];
                nn[d] = side;
                N.push(nn[0]!, nn[1]!, nn[2]!);
              });
              const flip = aov[0]! + aov[2]! < aov[1]! + aov[3]!;
              const tri = flip ? [0, 1, 3, 1, 2, 3] : [0, 1, 2, 0, 2, 3];
              if (side < 0) tri.reverse();
              for (const t of tri) I.push(vi + t);
              const eps = side * 0.004;
              for (let e = 0; e < 4; e++) {
                const p1 = cs[e]!;
                const p2 = cs[(e + 1) % 4]!;
                const o = [0, 0, 0];
                o[d] = eps;
                L.push(p1[0]! + o[0]!, p1[1]! + o[1]!, p1[2]! + o[2]!, p2[0]! + o[0]!, p2[1]! + o[1]!, p2[2]! + o[2]!);
              }
              quads++;
              for (let jj = 0; jj < hgt; jj++) for (let ii = 0; ii < w; ii++) mask[n + ii + jj * du] = 0;
              i += w;
              n += w;
            }
        }
      }
    }
    geo.setAttribute('position', new THREE.Float32BufferAttribute(P, 3));
    geo.setAttribute('color', new THREE.Float32BufferAttribute(C, 3));
    geo.setAttribute('normal', new THREE.Float32BufferAttribute(N, 3));
    geo.setIndex(I);
    geo.computeBoundingSphere();
    lineGeo.setAttribute('position', new THREE.Float32BufferAttribute(L, 3));
    lineGeo.computeBoundingSphere();
  };
  const hud = new Hud();
  const tag = hud.add('');
  hud.at(tag, 0.03, 0.05, 0, 0);
  let lastOff = -1;
  let dirty = true;
  const refresh = () => {
    mesher();
    hud.set(tag, greedy ? `보이는 면 ${faces} → 덩어리 ${quads} (${(faces / Math.max(1, quads)).toFixed(1)}배 적게)` : `한 면씩 ${quads}개 (덩어리 안 함)`);
  };
  return {
    scene,
    camera: cam,
    update(t) {
      const off = Math.floor(t * 1.0);
      if (off !== lastOff || dirty) {
        if (off !== lastOff) gen(off);
        lastOff = off;
        dirty = false;
        refresh();
      }
      const a = 0.7 + Math.sin(t * 0.2) * 0.4;
      cam.position.set(Math.sin(a) * 30, 22, Math.cos(a) * 30);
      cam.lookAt(0, -1.5, 0);
    },
    render(r, w, h) {
      r.render(scene, cam);
      hud.draw(r, w, h);
    },
    controls: [
      { type: 'toggle', label: '덩어리 합치기 (greedy)', value: true, on: (v) => ((greedy = v), (dirty = true)) },
      { type: 'toggle', label: '구석 그늘 (AO)', value: true, on: (v) => ((aoOn = v), (dirty = true)) },
      { type: 'toggle', label: '면 테두리 보기', value: true, on: (v) => (lines.visible = v) },
    ],
    dispose() {
      geo.dispose();
      lineGeo.dispose();
      (mesh.material as THREE.Material).dispose();
      (lines.material as THREE.Material).dispose();
      hud.dispose();
      (scene.background as THREE.Texture).dispose();
    },
  };
}
```

### i241 견본 항목 — `src/demos/demosLook2.ts:4525`
```ts
  i241: { kind: '3d', caption: '보이는 면만 골라 같은 색 · 같은 그늘끼리 큰 네모로 합침 + 꼭짓점 구석 그늘 — 테두리 선이 합친 덩어리', make: () => makeVoxel() }
```

## 관련 기술
- 먼저 알면 좋은 기술: [인스턴싱 (InstancedMesh)](https://ai-techstudio.web.app/ai/t/u36.md) `u36` · [3D 입체 글씨 (글꼴 윤곽 돌출 · 모서리 깎기)](https://ai-techstudio.web.app/ai/t/i17.md) `i17`
- 다음에 해 볼 기술: [정점 구석 그늘 굽기 (AO 를 미리)](https://ai-techstudio.web.app/ai/t/i453.md) `i453`
- 참고 문서: [0 FPS — Meshing in a Minecraft Game](https://0fps.net/2012/06/30/meshing-in-a-minecraft-game/) · [0 FPS — Ambient occlusion for Minecraft-like worlds](https://0fps.net/2013/07/03/ambient-occlusion-for-minecraft-like-worlds/)
