# AI 꾸러미 — 부서지는 물체 (조각내기) — Voronoi fracture (pre-fractured)
> 물체 안에 씨앗 점을 뿌리고 씨앗마다 이웃 씨앗과의 수직 이등분면으로 잘라 보로노이 조각을 미리 구워 두었다가, 맞으면 조각으로 바꿔 튕겨 흩어지게 한다.  
> 견본: https://ai-techstudio.web.app/#t/i486

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

## 주문서

### 만들어 줘: 부서지는 물체 (조각내기) — Voronoi fracture (pre-fractured)

#### 1. 목표
나무 상자 · 항아리 · 돌기둥을 보로노이로 미리 조각내 두고, 맞으면 조각이 튕겨 흩어지게 해 줘 — 잘린 안쪽 면은 다른 색. 분위기는 돌 던전 · 횃불.

#### 2. 핵심 기술 용어
- **Voronoi fracture (pre-fractured)** — 씨앗마다 가장 가까운 영역으로 미리 조각내기
- **Convex polyhedron clipping by planes** — 볼록 다면체를 평면으로 잘라 한쪽만 남기기
- **Cap faces (inner material)** — 잘린 단면은 다른 재질(그룹 1)로
- **Time-sliced baking** — 조각 굽기를 한 장면 1.5ms 씩 나눠서

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

#### 4. 조건
- 조각은 맞기 전에 미리 굽는다 (맞는 순간 계산하면 멈칫한다) — 한 장면 1.5ms 씩 나눠서 「굽는 중」 표시
- 단면은 그룹을 나눠 다른 재질로 (돌 겉 · 밝은 속)
- 조각을 0.965 배로 줄여 금 틈이 보이게
- 조각 수(4~30) · 부수는 힘 · 안쪽 면 강조 · 다시 부수기
- 조각은 구르다 1초 동안 가라앉아 사라지고 메시는 숨기기만 (다시 쓰기)

#### 5. 완성 기준 (이게 보이면 성공)
- 칼이 지나가면 항아리 · 상자 · 기둥이 조각으로 바뀌어 튕겨 흩어지고 바닥에서 구른다
- 잘린 단면은 겉과 다른 색이다 (항아리 속 주황 · 상자 속 밝은 나무)
- 맞은 쪽 조각이 더 잘다
- 조각 수를 바꿔도 화면이 멈추지 않는다 (나눠 굽기)

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

## 원리
- 물체 겉면을 볼록 다각형 면 목록(Face)으로 둔다 — 상자 · 돌림체(항아리 · 기둥) 모양 함수.
- 물체 안에 씨앗 12개를 뿌린다. 40% 는 맞을 쪽(기사 쪽) 가까이 몰아 그쪽이 잘게 깨진다.
- 씨앗 i 의 조각 = 원래 모양을 「다른 씨앗 j 와의 수직 이등분면(법선 = j − i, 지나는 점 = 가운데)」으로 차례로 잘라 i 쪽만 남긴 것.
- 자를 때 평면 위에 놓인 점들을 모아 각도 순으로 이으면 단면(뚜껑 면)이 된다 — 그룹 1 로 안쪽 재질을 칠한다. 조각은 0.965 배로 줄여 금 틈을 둔다.
- 맞으면 원래 물체를 숨기고 조각을 보인다: 바깥 · 옆 · 위로 무작위 속도와 회전, 중력 9.8, 바닥에 닿으면 0.32 배 튀고 미끄러지며 구르다 가라앉아 사라진다.

## 핵심 코드 — 씨앗 사이 수직 이등분면으로 잘라 조각 하나 굽기
(발췌: demos/demosGame3dB.ts clipPoly() · bakeOne() 을 정리)
```ts
interface Face { p: THREE.Vector3[]; n: THREE.Vector3; inner: boolean }

/** 볼록 다면체를 평면 n·x = d 로 잘라 n·x ≤ d 쪽만 남김 (+ 단면) */
function clipPoly(faces: Face[], n: THREE.Vector3, d: number): Face[] {
  const out: Face[] = [], cap: THREE.Vector3[] = [], E = 1e-7;
  for (const f of faces) {
    const np: THREE.Vector3[] = [];
    for (let i = 0; i < f.p.length; i++) {
      const a = f.p[i]!, b = f.p[(i + 1) % f.p.length]!;
      const da = n.dot(a) - d, db = n.dot(b) - d;
      if (da <= E) np.push(a);
      if (Math.abs(da) <= E) cap.push(a);
      if ((da < -E && db > E) || (da > E && db < -E)) { const q = a.clone().lerp(b, da / (da - db)); np.push(q); cap.push(q); }
    }
    if (np.length >= 3) out.push({ p: np, n: f.n, inner: f.inner });
  }
  // 단면: 평면 위 점을 가운데 기준 각도 순으로 이으면 뚜껑 면
  const uniq = cap.filter((q, i) => cap.findIndex((u) => u.distanceToSquared(q) < 1e-10) === i);
  if (uniq.length >= 3) {
    const c = uniq.reduce((s, q) => s.add(q), new THREE.Vector3()).divideScalar(uniq.length);
    const u = (Math.abs(n.x) < 0.9 ? new THREE.Vector3(1, 0, 0) : new THREE.Vector3(0, 1, 0)).cross(n).normalize();
    const w = new THREE.Vector3().crossVectors(n, u);
    const ang = (q: THREE.Vector3): number => { const t = q.clone().sub(c); return Math.atan2(t.dot(w), t.dot(u)); };
    uniq.sort((a, b) => ang(a) - ang(b));
    out.push({ p: uniq, n: n.clone(), inner: true });         // inner = 안쪽 재질
  }
  return out;
}

/** 씨앗 i 의 보로노이 조각 = 다른 씨앗마다 이등분면으로 잘라 i 쪽만 */
function bakeOne(faces: Face[], seeds: THREE.Vector3[], i: number): Face[] {
  const si = seeds[i]!;
  for (let j = 0; j < seeds.length && faces.length; j++) {
    if (j === i) continue;
    const n = seeds[j]!.clone().sub(si).normalize();
    faces = clipPoly(faces, n, n.dot(si.clone().add(seeds[j]!).multiplyScalar(0.5)));
  }
  return faces; // facesToGeo(faces, 가운데, 0.965) 로 메시 — 그룹 0 겉면 · 1 단면
}
```

## 흔한 실수 · 확인 목록
- [ ] **맞는 순간 조각을 계산하면 화면이 멈칫한다** — 미리 굽되 한 장면 1.5ms 씩 나눠 굽고 「굽는 중」을 보여 준다.
- [ ] **단면을 겉과 같은 재질로 칠하면 조각이 속 빈 껍데기처럼 보인다** — 잘린 면은 그룹 1 로 모아 다른 재질(밝은 속 색)을 준다.
- [ ] **씨앗을 고르게만 뿌리면 맞은 쪽도 큰 덩어리로 깨진다** — 씨앗 40% 를 맞는 쪽 가까이 몰아 그쪽을 잘게.
- [ ] **오목한 모양(항아리 입구 안쪽 등)을 그대로 자르면 구멍이 생긴다** — 평면 자르기는 볼록 다면체 전용이다 — 견본 항아리는 볼록한 돌림 모양으로 만들고 입구는 어두운 원판을 얹어 표현했다.

## 완성 기준 체크리스트
- [ ] 칼이 지나가면 항아리 · 상자 · 기둥이 조각으로 바뀌어 튕겨 흩어지고 바닥에서 구른다
- [ ] 잘린 단면은 겉과 다른 색이다 (항아리 속 주황 · 상자 속 밝은 나무)
- [ ] 맞은 쪽 조각이 더 잘다
- [ ] 조각 수를 바꿔도 화면이 멈추지 않는다 (나눠 굽기)

## 이 기술 정보
- id: `i486` · 분류: 게임 시스템 · AI › 3D 게임 기본기 · 3D · 난이도 어려움 · 폰 부담 보통 (폰 주의) — 조각 굽기는 씨앗 n 개 × 평면 n 번 자르기 = n² — 12조각이면 가볍고 30조각이면 무겁다. 그래서 한 장면 1.5ms 씩 나눠 굽는다. 터진 뒤엔 조각 수만큼 그리기 호출.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i486
- 쓰면 좋을 때: 상자 · 항아리 · 벽이 맞아서 부서지는 손맛 / 자르기 · 단면 같은 도형 설명
- 쓰지 말 때: 아주 많은 물체가 동시에 — 조각 수 × 물체 수만큼 메시가 늘어난다. 작은 것은 입자 파편(i170)으로 / 다시 붙거나 정확한 물리 쌓기가 필요한 장면 — 물리 엔진 강체로

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### demoFracture — `src/demos/demosGame3dB.ts:1815`
```ts
function demoFracture(): Scene3D {
  const st = makeStage({ w: 18, d: 9, wallH: 2.4, torches: [[-3.2, 9], [1.4, 9]], cam: [2.2, 4.7, 5.6], look: [-0.25, 0.55, 0.05], fov: 38, shadow: 5 });
  const { scene, cam, bin } = st;
  const painter = new Painter(0x0b090e);
  const knight = makeKnight(bin, 0x7a3fb0, 0xe8b13a);
  knight.g.position.set(-1.15, 0, 0.25);
  scene.add(knight.g);
  const ribbon = new Ribbon(bin, new THREE.Color(1, 0.97, 0.88), new THREE.Color(1.0, 0.55, 0.2));
  scene.add(ribbon.mesh);
  const glowTex = bin.add(texOf('glow', 64, 64, paintGlow));
  const sparks = new Sparks(bin, glowTex);
  scene.add(sparks.pts);
  const chip = bin.add(texOf('chip', 256, 256, paintChip));
  const innerMats: THREE.MeshStandardMaterial[] = [];
  const inner = (c: number): THREE.MeshStandardMaterial => {
    const m = bin.add(new THREE.MeshStandardMaterial({ color: c, map: chip, roughness: 0.95 }));
    innerMats.push(m);
    return m;
  };
  const opt = { pieces: 12, power: 1, glowInner: false };
  const objs: Breakable[] = [];
  const pivotW = knight.g.position.clone().add(PIVOT);
  const mk = (angle: number, faces: Face[], uv: UvFn, outer: THREE.MeshStandardMaterial, inMat: THREE.MeshStandardMaterial, rad: number, rotY: number): Breakable => {
    const g = new THREE.Group();
    g.position.set(pivotW.x + Math.cos(angle) * 1.55, 0, pivotW.z - Math.sin(angle) * 1.55);
    g.rotation.y = rotY;
    scene.add(g);
    const intact = new THREE.Group();
    const { geo } = facesToGeo(faces, new THREE.Vector3(), 1, uv);
    bin.add(geo);
    const m = new THREE.Mesh(geo, [outer, inMat]);
    m.castShadow = m.receiveShadow = true;
    intact.add(m);
    g.add(intact);
    return { g, intact, faces, uv, mats: [outer, inMat], angle, rad, seeds: [], frags: [], broken: false, next: 0 };
  };
  // 돌기둥 (뒤) · 항아리 (가운데) · 나무 상자 (앞)
  const stoneM = bin.add(new THREE.MeshStandardMaterial({ map: bin.add(texOf('pillar', 256, 256, paintPillar, 1.5, 2)), roughness: 0.85 }));
  const pillarFaces = lathePoly(
    [
      [0.29, 0.18],
      [0.29, 1.85],
    ],
    10,
  );
  const cylUv =
    (rep: number, h0: number, h: number): UvFn =>
    (p, n, fc) => {
      if (Math.abs(n.y) > 0.6) return [p.x * 0.8 + 0.5, p.z * 0.8 + 0.5];
      const a0 = Math.atan2(fc.z, fc.x);
      let a = Math.atan2(p.z, p.x);
      if (a - a0 > Math.PI) a -= TAU;
      if (a0 - a > Math.PI) a += TAU;
      return [(a / TAU) * rep, (p.y - h0) / h];
    };
  const pillar = mk(0.95, pillarFaces, cylUv(1, 0.18, 1.67), stoneM, inner(0xb9b4aa), 0.32, 0.2);
  const plinth = new THREE.Mesh(bin.add(new THREE.BoxGeometry(0.82, 0.18, 0.82)), bin.add(new THREE.MeshStandardMaterial({ color: 0x5c5d63, roughness: 0.85 })));
  plinth.position.y = 0.09;
  plinth.castShadow = plinth.receiveShadow = true;
  pillar.g.add(plinth);
  const potM = bin.add(new THREE.MeshStandardMaterial({ map: bin.add(texOf('pot', 512, 256, paintPot)), roughness: 0.6 }));
  const potFaces = lathePoly(
    [
      [0.2, 0],
      [0.36, 0.18],
      [0.42, 0.42],
      [0.37, 0.7],
      [0.26, 0.86],
      [0.2, 0.92],
    ],
    14,
  );
  const pot = mk(0, potFaces, cylUv(2, 0, 0.92), potM, inner(0xe39a68), 0.42, 0);
  const mouth = new THREE.Mesh(bin.add(new THREE.CircleGeometry(0.15, 20)), bin.add(new THREE.MeshStandardMaterial({ color: 0x1a0d08, roughness: 1 })));
  mouth.rotation.x = -Math.PI / 2;
  mouth.position.y = 0.925;
  pot.intact.add(mouth);
  const crateM = bin.add(new THREE.MeshStandardMaterial({ map: bin.add(texOf('crate', 256, 256, paintCrate)), roughness: 0.78 }));
  const S = 0.82;
  const boxUv: UvFn = (p, n) => {
    const ax = Math.abs(n.x);
    const ay = Math.abs(n.y);
    const az = Math.abs(n.z);
    if (ax >= ay && ax >= az) return [p.z / S + 0.5, p.y / S];
    if (ay >= az) return [p.x / S + 0.5, p.z / S + 0.5];
    return [p.x / S + 0.5, p.y / S];
  };
  const crate = mk(-0.95, boxPoly(S), boxUv, crateM, inner(0xe6c38c), 0.5, 0.35);
  objs.push(pillar, pot, crate);

  // 조각 굽기 — 한 프레임에 1.5ms 까지만 (make 는 가볍게)
  const R = rng(486);
  let bakeQ: { o: Breakable; i: number }[] = [];
  const seedAll = (): void => {
    bakeQ = [];
    for (const o of objs) {
      for (const f of o.frags) {
        scene.remove(f.mesh);
        f.mesh.geometry.dispose();
      }
      o.frags = [];
      o.seeds = [];
      let ymax = 0;
      let rmax = 0;
      for (const f of o.faces)
        for (const q of f.p) {
          ymax = Math.max(ymax, q.y);
          rmax = Math.max(rmax, Math.hypot(q.x, q.z));
        }
      // 맞는 쪽(기사 쪽) 가까이에 잘게 — 진짜로 깨지는 모양
      const hitDir = new THREE.Vector3(pivotW.x - o.g.position.x, 0, pivotW.z - o.g.position.z).normalize().applyAxisAngle(new THREE.Vector3(0, 1, 0), -o.g.rotation.y);
      let guard = 0;
      while (o.seeds.length < opt.pieces && guard++ < 4000) {
        const near = o.seeds.length < opt.pieces * 0.4;
        const v = near
          ? new THREE.Vector3(hitDir.x * rmax * 0.7 + (R() - 0.5) * rmax * 0.7, Math.min(ymax * 0.9, 0.95) + (R() - 0.5) * 0.35, hitDir.z * rmax * 0.7 + (R() - 0.5) * rmax * 0.7)
          : new THREE.Vector3((R() * 2 - 1) * rmax, R() * ymax, (R() * 2 - 1) * rmax);
        if (insidePoly(o.faces, v)) o.seeds.push(v);
      }
      o.seeds.forEach((_s, i) => bakeQ.push({ o, i }));
    }
  };
  const bakeOne = (o: Breakable, i: number): void => {
    const si = o.seeds[i]!;
    let faces = o.faces;
    const n = new THREE.Vector3();
    for (let j = 0; j < o.seeds.length && faces.length; j++) {
      if (j === i) continue;
      const sj = o.seeds[j]!;
      n.subVectors(sj, si).normalize();
      faces = clipPoly(faces, n.clone(), n.dot(TV.addVectors(si, sj).multiplyScalar(0.5)));
    }
    if (faces.length < 4) return;
    const c = polyCenter(faces);
    const { geo, verts } = facesToGeo(faces, c, 0.965, o.uv);
    let r = 0;
    for (let k = 0; k < verts.length; k += 3) r = Math.max(r, Math.hypot(verts[k]!, verts[k + 1]!, verts[k + 2]!));
    const mesh = new THREE.Mesh(geo, o.mats);
    mesh.castShadow = mesh.receiveShadow = true;
    mesh.visible = false;
    scene.add(mesh);
    o.frags.push({ mesh, c0: c, verts, r, pos: new THREE.Vector3(), vel: new THREE.Vector3(), q: new THREE.Quaternion(), w: new THREE.Vector3() });
  };
  seedAll();
  let L = 0;
  let stop = 0;
  let prevYaw = REST;
  const pose: Pose = { yaw: REST, tilt: -0.7, lean: 0 };
  const CYC = 5.2;
  const reset = (): void => {
    L = 0;
    prevYaw = REST;
    for (const o of objs) {
      o.broken = false;
      o.intact.visible = true;
      for (const f of o.frags) f.mesh.visible = false;
    }
  };
  const shatter = (o: Breakable): void => {
    o.broken = true;
    o.intact.visible = false;
    const a = o.angle;
    const radial = new THREE.Vector3(Math.cos(a), 0, -Math.sin(a));
    const tang = new THREE.Vector3(Math.sin(a), 0, Math.cos(a));
    const ctr = o.g.localToWorld(polyCenter(o.faces));
    for (const f of o.frags) {
      o.g.localToWorld(f.pos.copy(f.c0));
      f.q.copy(o.g.quaternion);
      const out = TV.subVectors(f.pos, ctr);
      const p = opt.power;
      f.vel
        .copy(radial)
        .multiplyScalar((0.9 + R() * 0.9) * p)
        .addScaledVector(tang, (0.7 + R() * 1.0) * p)
        .addScaledVector(out, 2.4 * p);
      f.vel.y = (1.2 + R() * 2.0) * p + out.y * 1.5;
      f.w.set(R() - 0.5, R() - 0.5, R() - 0.5).normalize().multiplyScalar((5 + R() * 9) * p);
      f.mesh.visible = true;
      f.mesh.position.copy(f.pos);
      f.mesh.quaternion.copy(f.q);
      f.mesh.scale.setScalar(1);
    }
    sparks.burst(ctr.x - radial.x * 0.3, Math.min(1, ctr.y + 0.1), ctr.z - radial.z * 0.3, R);
    stop = 0.05;
  };
  const dq = new THREE.Quaternion();
  const ax = new THREE.Vector3();
  const vv = new THREE.Vector3();
  const stepFrag = (f: Frag, dt: number, sink: number): void => {
    f.vel.y -= 9.8 * dt;
    f.pos.addScaledVector(f.vel, dt);
    const wl = f.w.length();
    if (wl > 1e-4) {
      dq.setFromAxisAngle(ax.copy(f.w).divideScalar(wl), wl * dt);
      f.q.premultiply(dq).normalize();
    }
    if (sink <= 0) {
      let minY = 1e9;
      for (let k = 0; k < f.verts.length; k += 3) {
        vv.set(f.verts[k]!, f.verts[k + 1]!, f.verts[k + 2]!).applyQuaternion(f.q);
        if (vv.y < minY) minY = vv.y;
      }
      const pen = f.pos.y + minY;
      if (pen < 0) {
        f.pos.y -= pen;
        if (f.vel.y < 0) f.vel.y = f.vel.y < -0.8 ? -f.vel.y * 0.32 : 0;
        const fr = Math.max(0, 1 - 5 * dt);
        f.vel.x *= fr;
        f.vel.z *= fr;
        // 구르기: 바닥에 닿은 조각은 미끄러지는 방향으로 돈다
        const r = Math.max(0.05, f.r * 0.6);
        f.w.lerp(ax.set(f.vel.z / r, 0, -f.vel.x / r), 0.25);
        f.w.multiplyScalar(Math.max(0, 1 - 2.5 * dt));
      }
    } else {
      f.vel.set(0, 0, 0);
      f.w.multiplyScalar(0.9);
      f.pos.y -= dt * 0.35;
    }
    f.mesh.position.copy(f.pos);
    f.mesh.quaternion.copy(f.q);
  };
  const hud = new Hud((g, w, _h, u) => {
    tag(g, `보로노이 조각 ${opt.pieces}개 · 안쪽 면은 다른 색`, 8 * u, 14 * u, 9.5 * u, '#ffd9a0');
    const left = bakeQ.length;
    if (left) tag(g, `조각 미리 굽는 중… ${left}개 남음`, w - 8 * u, 14 * u, 9 * u, '#bfe3ff', 'right');
  });
  const update = (t: number, dt: number): void => {
    st.tick(t);
    // 나눠 굽기
    if (bakeQ.length) {
      const t0 = performance.now();
      while (bakeQ.length && performance.now() - t0 < 1.5) {
        const j = bakeQ.shift()!;
        bakeOne(j.o, j.i);
      }
      hud.dirty = true;
      if (!bakeQ.length) reset();
    }
    const ready = !bakeQ.length;
    const dtl = stop > 0 ? 0 : dt;
    if (stop > 0) stop -= dt;
    if (ready) L += dtl;
    if (L >= CYC) reset();
    // 물체 다시 나타나기 (톡 솟음)
    for (const o of objs) {
      if (!o.broken) {
        const k = clamp(L / 0.45, 0, 1);
        const s = Math.max(0.02, easeOut(k) + Math.sin(k * Math.PI) * 0.14);
        const sx = 1 + (1 - Math.min(1, s)) * 0.25;
        o.intact.scale.set(sx, s, sx);
      }
    }
    // 기사 휘두르기 (자세는 i484 와 같은 함수)
    const c = clamp(L - 0.6, 0, 2.39);
    swingPose(c, pose);
    applyPose(knight, pose);
    knight.g.updateMatrixWorld(true);
    const swinging = c >= SW_T0 && c <= SW_T1 + 0.01;
    if (swinging && dtl > 0) {
      for (const o of objs) {
        if (o.broken) continue;
        const dx = o.g.position.x - pivotW.x;
        const dz = o.g.position.z - pivotW.z;
        if (sweepHits(pose.yaw, prevYaw, Math.hypot(dx, dz), Math.atan2(-dz, dx), o.rad)) shatter(o);
      }
    }
    if (dtl > 0) prevYaw = pose.yaw;
    if (c >= SW_T0 && c < SW_T1 + TRAIL) {
      const tail = swingPose(Math.max(SW_T0, Math.min(c, SW_T1) - TRAIL), { yaw: 0, tilt: 0, lean: 0 }).yaw;
      const k = c > SW_T1 ? clamp((c - SW_T1) / TRAIL, 0, 1) : 0;
      ribbon.sweep(knight.body, pose.yaw, lerp(tail, pose.yaw, k), 1 - k);
    } else ribbon.mat.uniforms.uA!.value = 0;
    // 조각 움직임
    const sink = L > CYC - 1.0 ? L - (CYC - 1.0) : 0;
    for (const o of objs) if (o.broken) for (const f of o.frags) stepFrag(f, dtl, sink);
    sparks.tick(dt);
    for (const m of innerMats) m.emissive.setHex(opt.glowInner ? 0x8a3a10 : 0x000000);
  };
  const controls: Control[] = [
    { type: 'range', label: '조각 수', min: 4, max: 30, step: 1, value: 12, on: (v) => ((opt.pieces = v), seedAll(), reset(), (hud.dirty = true)) },
    { type: 'range', label: '부수는 힘', min: 0.4, max: 2, step: 0.1, value: 1, on: (v) => (opt.power = v) },
    { type: 'toggle', label: '안쪽 면 강조', value: false, on: (v) => (opt.glowInner = v) },
    { type: 'button', label: '다시 부수기', on: () => reset() },
  ];
  return {
    scene,
    camera: cam,
    update,
    render: (r) => painter.draw(r, scene, cam, hud),
    controls,
    dispose: () => {
      for (const o of objs) for (const f of o.frags) f.mesh.geometry.dispose();
      bin.dispose();
      hud.dispose();
    },
  };
}
```

## 관련 기술
- 먼저 알면 좋은 기술: [전투 판정 (휘두르기 · 히트스톱 · 넉백)](https://ai-techstudio.web.app/ai/t/i484.md) `i484`
- 다음에 해 볼 기술: [입자 방출기 (수명 곡선)](https://ai-techstudio.web.app/ai/t/i170.md) `i170` · [데칼 (글자 · 경고 표시 · 총알 자국)](https://ai-techstudio.web.app/ai/t/i479.md) `i479`
- 참고 문서: [Wikipedia — Voronoi diagram](https://en.wikipedia.org/wiki/Voronoi_diagram)
