# AI 꾸러미 — 디오라마 지도 (3D 섬 판) — Diorama map (voxel terraced terrain)
> 높이 지도를 칸마다 블록 기둥으로 쌓아 계단식 섬 판을 만들고, 옆면에 지층 줄무늬 · 둘레에 물 찬 유리 상자를 둔다.  
> 견본: https://ai-techstudio.web.app/#t/i275

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

## 주문서

### 만들어 줘: 디오라마 지도 (3D 섬 판) — Diorama map (voxel terraced terrain)

#### 1. 목표
월드 고르기 화면 섬을 디오라마 지도로 만들어 줘 — 높이 지도를 계단 블록 기둥으로 쌓고, 옆면은 지층 줄무늬, 둘레는 물 찬 유리 상자, 위에 나무 · 집이 솟아나게. 분위기는 밝은 장난감 블록.

#### 2. 핵심 기술 용어
- **Diorama map (voxel terraced terrain)** — 계단 블록으로 쌓은 작은 섬 판
- **three.js InstancedMesh + setColorAt** — 같은 상자 수천 개를 한 번에 · 블록마다 색
- **Quantized heightmap** — 높이를 몇 단계로 끊기
- **Strata coloring** — 옆면 흙 · 돌 지층 줄무늬

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

#### 4. 조건
- 블록은 InstancedMesh 하나로 (Mesh 수천 개 금지), count 로 실제 개수만 그리기
- 행렬 · 색은 솟는 동안만 다시 쓰고 instanceMatrix · instanceColor.needsUpdate 를 켜기
- 물은 depthWrite: false 반투명 — 블록이 물 아래에서도 보이게
- 나무 · 줄기도 InstancedMesh, 물 높이보다 낮은 칸 · 너무 높은 칸에는 놓지 않기
- 단계 수 · 물 높이 슬라이더와 「새 섬」 단추

#### 5. 완성 기준 (이게 보이면 성공)
- 새 섬이 가운데부터 바깥으로 기둥이 통통 솟으며 생긴다
- 옆에서 보면 흙 · 돌 지층 줄무늬가 보이고, 물 상자 옆면으로 잠긴 블록이 비친다
- 물 높이를 올리면 낮은 땅이 잠기고 모래 칸이 바뀐다
- 폰에서도 60fps (그리기 호출 수십 번 안)

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

## 원리
- 22×22 칸마다 높이 = fbm 잡음 × 1.25 − 가운데에서 거리 × 2.1 + 0.42 를 단계 수(견본 7)로 끊는다 — 가장자리는 낮아져 섬이 된다.
- 칸마다 바닥에서 그 높이까지 상자를 한 층(0.3)씩 쌓는다. 상자는 모두 InstancedMesh 하나에 setMatrixAt · setColorAt 으로.
- 맨 윗층은 높이에 따라 모래 · 풀 · 숲 · 바위 · 눈 색, 아래층은 두 가지 흙색 줄무늬 + 아래로 갈수록 어둡게(0.62~1) — 옆에서 보면 지층.
- 물은 판 크기의 반투명 상자(높이 = 물 높이)와 정점이 sin · cos 로 출렁이는 윗면 판.
- 새 섬이 나올 때 가운데부터 바깥으로 기둥이 easeOutBack 으로 솟고, 그다음 나무 · 집이 커진다.

## 핵심 코드 — 높이 지도 → 계단 블록 InstancedMesh (윗면 색 + 지층)
(발췌: demos/demosMapB.ts i275 genIsland · layout 을 정리)
```ts
import * as THREE from 'three';
const N = 22, S = 9.6 / N, LH = 0.3, half = (N * S) / 2;
const blocks = new THREE.InstancedMesh(new THREE.BoxGeometry(1, 1, 1),
  new THREE.MeshStandardMaterial({ roughness: 0.82, metalness: 0, flatShading: true }), N * N * 13);
scene.add(blocks);
// 높이: 잡음 − 가운데 거리 → 단계로 끊기
const hgt = new Int8Array(N * N);
for (let z = 0; z < N; z++) for (let x = 0; x < N; x++) {
  const d = Math.hypot((x + 0.5) / N - 0.5, ((z + 0.5) / N - 0.5) * 1.1);
  const val = fbm(x * 0.15, z * 0.15, seed * 9, 4) * 1.25 - d * 2.1 + 0.42;
  hgt[z * N + x] = Math.max(0, Math.min(levels, Math.floor(val * levels * 1.15)));
}
const topColor = (L: number) => (L <= waterL ? 0xd8c38c : L <= waterL + 1 ? 0xe9d79e : L <= levels * 0.55 ? 0x74c24e
  : L <= levels * 0.72 ? 0x4f9a3e : L <= levels * 0.86 ? 0x9a948a : 0xf4f7fa);
const m4 = new THREE.Matrix4(), q = new THREE.Quaternion(), v = new THREE.Vector3(), sc = new THREE.Vector3(S, LH, S), col = new THREE.Color();
let n = 0;
for (let z = 0; z < N; z++) for (let x = 0; x < N; x++) {
  const L = hgt[z * N + x];
  for (let y = 0; y <= L; y++) {
    v.set((x + 0.5) * S - half, (y + 0.5) * LH, (z + 0.5) * S - half);
    blocks.setMatrixAt(n, m4.compose(v, q, sc));
    if (y === L) col.setHex(topColor(L));
    else col.setHex(L <= waterL ? 0xc9b07a : y < 1 ? 0x5e5049 : y % 2 ? 0x8a6242 : 0x7a5338)
      .multiplyScalar(0.62 + 0.38 * (y / Math.max(1, L))); // 아래로 갈수록 어둡게
    blocks.setColorAt(n, col);
    n++;
  }
}
blocks.count = n;
blocks.instanceMatrix.needsUpdate = true;
if (blocks.instanceColor) blocks.instanceColor.needsUpdate = true;
// 물: 반투명 상자 (depthWrite 끔)
const water = new THREE.Mesh(new THREE.BoxGeometry(N * S, 1, N * S),
  new THREE.MeshStandardMaterial({ color: 0x2b9be0, transparent: true, opacity: 0.42, roughness: 0.2, depthWrite: false }));
const wh = (waterL + 1) * LH * 0.98;
water.scale.y = wh; water.position.y = wh / 2;
scene.add(water);
```

## 흔한 실수 · 확인 목록
- [ ] **블록마다 Mesh 를 만들면 수천 번 그려 폰이 멈춘다** — InstancedMesh 하나에 setMatrixAt · setColorAt 으로 넣고 count 를 실제 개수로.
- [ ] **setColorAt 을 처음 부르기 전에는 instanceColor 가 없다** — 색을 넣은 뒤 if (blocks.instanceColor) needsUpdate = true 처럼 확인한다.
- [ ] **반투명 물이 깊이를 써서 잠긴 블록이 사라진다** — 물 재질은 depthWrite: false.
- [ ] **다 솟은 뒤에도 매 프레임 행렬 수천 개를 다시 쓰면 CPU 낭비다** — 솟는 동안(1.7초)만 layout 을 돌리고 끝나면 멈춘다.

## 완성 기준 체크리스트
- [ ] 새 섬이 가운데부터 바깥으로 기둥이 통통 솟으며 생긴다
- [ ] 옆에서 보면 흙 · 돌 지층 줄무늬가 보이고, 물 상자 옆면으로 잠긴 블록이 비친다
- [ ] 물 높이를 올리면 낮은 땅이 잠기고 모래 칸이 바뀐다
- [ ] 폰에서도 60fps (그리기 호출 수십 번 안)

## 이 기술 정보
- id: `i275` · 분류: 지도 · 길찾기 › 지도 보기 · 표현 · 3D · 난이도 보통 · 폰 부담 가벼움 (폰 OK) — 22×22 칸 × 최대 13층 = 상자 6,292개지만 InstancedMesh 하나라 그리기 호출 1번. 솟는 동안(1.7초)만 행렬을 다시 쓰고, 다 솟으면 멈춘다.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i275
- 쓰면 좋을 때: 단계 · 월드 고르기 화면의 귀여운 섬 / 높이 지도를 입체로 보여 줄 때 / 블록 장난감 느낌의 판
- 쓰지 말 때: 매끈한 실제 산 — 대신 등고선 3D 지형(i279) / 칸이 수백 × 수백인 큰 땅 — 상자 수가 너무 많아 덩어리 메시로 합치기

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### i275 견본 항목 — `src/demos/demosMapB.ts:4814`
```ts
  i275: {
    kind: '3d',
    caption: '높이 지도를 계단 블록으로 쌓은 섬 판 — 옆면에 흙 · 돌 지층, 판 둘레는 물이 차 있는 유리 상자',
    make(T) {
      const scene = new T.Scene();
      scene.background = gradTex(T, '#8fd0ff', '#eaf7ff');
      const cam = new T.PerspectiveCamera(30, 1.6, 0.1, 200);
      scene.add(new T.HemisphereLight(0xfdfbff, 0x7a6a50, 1.5));
      const sun = new T.DirectionalLight(0xfff0d8, 2.4);
      sun.position.set(7, 12, 5);
      scene.add(sun);
      const N = 22;
      const S = 9.6 / N;
      const LH = 0.3;
      let levels = 7;
      let waterL = 1.6;
      let seed = 1;
      let spin = true;
      let riseStart = 0;
      let lastNew = 0;
      let now = 0;
      let hgt = new Int8Array(N * N);
      const MAXI = N * N * 13;
      const root = new T.Group();
      scene.add(root);
      const box = new T.BoxGeometry(1, 1, 1);
      const blocks = new T.InstancedMesh(box, new T.MeshStandardMaterial({ roughness: 0.82, metalness: 0, flatShading: true }), MAXI);
      blocks.instanceMatrix.setUsage(T.DynamicDrawUsage);
      root.add(blocks);
      // 판
      const half = (N * S) / 2;
      const base = new T.Mesh(new T.BoxGeometry(N * S + 0.5, 0.45, N * S + 0.5), new T.MeshStandardMaterial({ color: 0x6a4630, roughness: 0.7 }));
      base.position.y = -0.225;
      root.add(base);
      const trim = new T.Mesh(new T.BoxGeometry(N * S + 0.8, 0.18, N * S + 0.8), new T.MeshStandardMaterial({ color: 0x3e2a1d, roughness: 0.6 }));
      trim.position.y = -0.5;
      root.add(trim);
      // 물 (옆면이 보이는 유리 상자 + 일렁이는 윗면)
      const waterBox = new T.Mesh(new T.BoxGeometry(N * S, 1, N * S), new T.MeshStandardMaterial({ color: 0x2b9be0, transparent: true, opacity: 0.42, roughness: 0.2, depthWrite: false }));
      root.add(waterBox);
      const wTopG = new T.PlaneGeometry(N * S, N * S, 44, 44);
      wTopG.rotateX(-Math.PI / 2);
      const wTop = new T.Mesh(wTopG, new T.MeshStandardMaterial({ color: 0x7fd6ff, transparent: true, opacity: 0.55, roughness: 0.1, metalness: 0.1, depthWrite: false, flatShading: true }));
      root.add(wTop);
      const wBase = Float32Array.from(wTopG.attributes.position!.array as Float32Array);
      // 나무 · 집 · 구름
      const TREE_MAX = 70;
      const trees = new T.InstancedMesh(new T.ConeGeometry(0.2, 0.55, 7), new T.MeshStandardMaterial({ color: 0x2f8f46, roughness: 0.8, flatShading: true }), TREE_MAX);
      const trunks = new T.InstancedMesh(new T.CylinderGeometry(0.05, 0.06, 0.2, 6), new T.MeshStandardMaterial({ color: 0x6b4428 }), TREE_MAX);
      root.add(trees, trunks);
      const houses = new T.Group();
      root.add(houses);
      const houseBody = new T.BoxGeometry(0.34, 0.26, 0.3);
      const houseRoof = new T.ConeGeometry(0.29, 0.22, 4);
      houseRoof.rotateY(Math.PI / 4);
      const houseMats = [new T.MeshStandardMaterial({ color: 0xfaf3e6 }), new T.MeshStandardMaterial({ color: 0xe0533f, flatShading: true }), new T.MeshStandardMaterial({ color: 0x3f7fd0, flatShading: true })];
      const clouds = new T.Group();
      const cloudMat = new T.MeshStandardMaterial({ color: 0xffffff, roughness: 1, flatShading: true });
      const cloudG = new T.IcosahedronGeometry(0.5, 1);
      for (let c = 0; c < 3; c++) {
        const cg = new T.Group();
        for (let k = 0; k < 4; k++) {
          const m = new T.Mesh(cloudG, cloudMat);
          m.position.set(k * 0.45 - 0.7, Math.sin(k * 2) * 0.12, (k % 2) * 0.2);
          m.scale.setScalar(0.7 + (k % 3) * 0.22);
          cg.add(m);
        }
        cg.position.set(-5 + c * 4.5, 4 + c * 0.4, -3 + c * 2);
        clouds.add(cg);
      }
      root.add(clouds);
      const m4 = new T.Matrix4();
      const col = new T.Color();
      const q = new T.Quaternion();
      const v = new T.Vector3();
      const sc = new T.Vector3();
      const treeSpots: [number, number][] = [];
      const genIsland = (): void => {
        hgt = new Int8Array(N * N);
        for (let z = 0; z < N; z++)
          for (let x = 0; x < N; x++) {
            const nx = (x + 0.5) / N - 0.5;
            const nz = (z + 0.5) / N - 0.5;
            const d = Math.hypot(nx, nz * 1.1);
            const val = fbm(x * 0.15, z * 0.15, seed * 9, 4) * 1.25 - d * 2.1 + 0.42;
            hgt[z * N + x] = clamp(Math.floor(val * levels * 1.15), 0, levels);
          }
        const r = rng(seed * 5);
        treeSpots.length = 0;
        for (let i = 0; i < N * N && treeSpots.length < TREE_MAX; i++) {
          const L = hgt[i]!;
          if (L > waterL + 0.5 && L <= levels - 2 && r() < 0.22) treeSpots.push([i, 0.75 + r() * 0.5]);
        }
        houses.clear();
        let placed = 0;
        for (let k = 0; k < 300 && placed < 3; k++) {
          const i = Math.floor(r() * N * N);
          const L = hgt[i]!;
          if (L <= waterL + 0.5 || L > levels - 3 || treeSpots.some(([j]) => j === i)) continue;
          const hg = new T.Group();
          const b = new T.Mesh(houseBody, houseMats[0]);
          b.position.y = 0.13;
          const rf = new T.Mesh(houseRoof, houseMats[placed % 2 ? 2 : 1]);
          rf.position.y = 0.37;
          hg.add(b, rf);
          hg.userData.cell = i;
          hg.rotation.y = r() * 3;
          houses.add(hg);
          placed++;
        }
        riseStart = now;
      };
      const topColor = (L: number): number => (L <= waterL ? 0xd8c38c : L <= waterL + 1 ? 0xe9d79e : L <= levels * 0.55 ? 0x74c24e : L <= levels * 0.72 ? 0x4f9a3e : L <= levels * 0.86 ? 0x9a948a : 0xf4f7fa);
      const layout = (): void => {
        const rk = clamp01((now - riseStart) / 1.6);
        let n = 0;
        for (let z = 0; z < N; z++)
          for (let x = 0; x < N; x++) {
            const i = z * N + x;
            const L = hgt[i]!;
            const dc = Math.hypot(x - N / 2, z - N / 2) / (N * 0.7);
            const k = easeOutBack(clamp01(rk * 1.6 - dc * 0.6));
            const shown = Math.max(0, Math.round(L * k));
            for (let y = 0; y <= shown; y++) {
              v.set((x + 0.5) * S - half, (y + 0.5) * LH, (z + 0.5) * S - half);
              sc.set(S, LH, S);
              m4.compose(v, q, sc);
              blocks.setMatrixAt(n, m4);
              if (y === shown) col.setHex(topColor(L));
              else {
                const strata = y < 1 ? 0x5e5049 : y % 2 ? 0x8a6242 : 0x7a5338;
                col.setHex(L <= waterL ? 0xc9b07a : strata);
                const ao = 0.62 + 0.38 * (y / Math.max(1, shown));
                col.multiplyScalar(ao);
              }
              blocks.setColorAt(n, col);
              n++;
            }
          }
        blocks.count = n;
        blocks.instanceMatrix.needsUpdate = true;
        if (blocks.instanceColor) blocks.instanceColor.needsUpdate = true;
        treeSpots.forEach(([i, s], t) => {
          const x = i % N;
          const z = (i / N) | 0;
          const L = hgt[i]!;
          const grow = clamp01((rk - 0.6) / 0.4) * s;
          const y = (L + 1) * LH;
          v.set((x + 0.5) * S - half, y + 0.38 * grow, (z + 0.5) * S - half);
          sc.set(grow, grow, grow);
          m4.compose(v, q, sc);
          trees.setMatrixAt(t, m4);
          v.y = y + 0.1 * grow;
          m4.compose(v, q, sc);
          trunks.setMatrixAt(t, m4);
        });
        trees.count = treeSpots.length;
        trunks.count = treeSpots.length;
        trees.instanceMatrix.needsUpdate = true;
        trunks.instanceMatrix.needsUpdate = true;
        houses.children.forEach((hg) => {
          const i = hg.userData.cell as number;
          hg.position.set(((i % N) + 0.5) * S - half, (hgt[i]! + 1) * LH, (((i / N) | 0) + 0.5) * S - half);
          hg.scale.setScalar(Math.max(0.001, clamp01((rk - 0.7) / 0.3)));
        });
      };
      function easeOutBack(k: number): number {
        const c1 = 1.4;
        return k <= 0 ? 0 : 1 + (c1 + 1) * Math.pow(k - 1, 3) + c1 * Math.pow(k - 1, 2);
      }
      genIsland();
      let laidDone = false;
      let angle = 0.6;
      return {
        scene,
        camera: cam,
        update(t, dt) {
          now = t;
          if (t - lastNew > 9 || t < lastNew) {
            lastNew = t;
            seed++;
            genIsland();
            laidDone = false;
          }
          if (!laidDone) {
            layout();
            if (t - riseStart > 1.7) laidDone = true;
          }
          const wh = (waterL + 1) * LH * 0.98;
          waterBox.scale.y = wh;
          waterBox.position.y = wh / 2;
          const pa = wTopG.attributes.position!;
          const arr = pa.array as Float32Array;
          for (let i = 0; i < arr.length; i += 3) arr[i + 1] = wh + Math.sin(wBase[i]! * 2.2 + t * 1.8) * 0.025 + Math.cos(wBase[i + 2]! * 1.9 + t * 1.4) * 0.025;
          pa.needsUpdate = true;
          wTopG.computeVertexNormals();
          clouds.children.forEach((c, i) => {
            c.position.x = ((c.position.x + dt * (0.25 + i * 0.08) + 8) % 16) - 8;
          });
          if (spin) angle += dt * 0.25;
          const R = 17;
          cam.position.set(Math.cos(angle) * R, 10.5, Math.sin(angle) * R);
          cam.lookAt(0, 0.6, 0);
        },
        controls: [
          { type: 'range', label: '계단 높이 단계 수', min: 3, max: 12, step: 1, value: 7, on: (x) => ((levels = x), genIsland(), (laidDone = false), (lastNew = now)) },
          { type: 'range', label: '물 높이', min: 0, max: 4, step: 0.5, value: 1.6, on: (x) => ((waterL = x), genIsland(), (laidDone = false), (lastNew = now)) },
          { type: 'toggle', label: '빙글빙글 돌기', value: true, on: (x) => (spin = x) },
          { type: 'button', label: '새 섬', on: () => ((seed++), genIsland(), (laidDone = false), (lastNew = now)) },
        ] as Control[],
        dispose() {
          disposeAll(scene);
        },
      };
    },
  }
```

## 관련 기술
- 먼저 알면 좋은 기술: [잡음 섬 지도 (높이 → 생물군)](https://ai-techstudio.web.app/ai/t/i245.md) `i245` · [아이소메트릭 (등각) 타일](https://ai-techstudio.web.app/ai/t/i257.md) `i257`
- 다음에 해 볼 기술: [등고선 3D 지형 (높이 띠)](https://ai-techstudio.web.app/ai/t/i279.md) `i279` · [육각 타일 행성 (골드버그 다면체)](https://ai-techstudio.web.app/ai/t/i539.md) `i539`
- 참고 문서: [three.js 문서 — InstancedMesh](https://threejs.org/docs/#api/en/objects/InstancedMesh)
