# AI 꾸러미 — 데칼 (글자 · 경고 표시 · 총알 자국) — Decal (projected decal)
> 물체 표면 모양을 따라 잘라 낸 그림 조각(DecalGeometry)을 붙여, 경고 스티커 · 일련번호 · 긁힘 · 그을음 · 탄흔을 덧입힌다.  
> 견본: https://ai-techstudio.web.app/#t/i479

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

## 주문서

### 만들어 줘: 데칼 (글자 · 경고 표시 · 총알 자국) — Decal (projected decal)

#### 1. 목표
상자 · 통 · 벽돌 벽 (창고 장면)에 데칼을 붙여 줘 — DecalGeometry 로 표면 모양을 따라 휘게, 경고 스티커 · 번호 · 긁힘 · 그을음 · 탄흔을 차례로. 분위기는 낡은 산업 창고.

#### 2. 핵심 기술 용어
- **Decal (projected decal)** — 표면에 투영해 붙이는 그림 조각
- **DecalGeometry** — three.js — 메시를 상자 모양으로 잘라 데칼 모양을 만드는 도구
- **polygonOffset** — 깊이 겨루기(z-fighting) 막기 — 표면보다 살짝 앞으로

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

#### 4. 조건
- 데칼 재질: transparent · depthWrite false · polygonOffset (factor · units −4)
- 자리 · 방향은 물체 좌표로 정하고 matrixWorld 로 바꾼다 (만들기 전에 updateMatrixWorld)
- 만든 모양은 원점 가까이로 옮기고(translate −pos) 메시 position 으로 되돌려 정밀도를 지킨다
- 여러 장을 만들 땐 프레임당 3ms 예산으로 나눠, 그동안 「만드는 중」 표시
- 데칼끼리 겹치는 차례는 renderOrder 로 고정

#### 5. 완성 기준 (이게 보이면 성공)
- 둥근 통에 붙인 이름표가 통 곡면을 따라 휘어 보인다
- 데칼이 하나씩 0.14초 동안 커졌다 줄며 붙고, 붙는 순간 하얗게 번쩍했다 빠진다
- 「데칼 보이기」를 끄면 맨 표면으로 돌아간다 — 켬/끔 비교로 디테일 차이가 보인다
- 카메라가 돌아도 데칼이 깜박이거나(z-fighting) 표면 밑으로 묻히지 않는다

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

## 원리
- 데칼 = 작은 투명 상자를 표면에 대고, 그 상자 안에 든 삼각형만 잘라 낸 새 메시다. 그래서 둥근 통에도 휘어 붙는다.
- 붙일 자리(p)와 표면 방향(n)을 물체 좌표로 적어 두고, matrixWorld 로 세계 좌표로 바꿔 쓴다.
- 상자 크기 s 의 z 는 투영 깊이 — 너무 깊으면 뒷면까지 잘려 묻는다.
- 표면과 같은 깊이라 그대로면 깜박이므로 polygonOffset 으로 살짝 앞으로 당긴다.
- 그림은 캔버스로 그린다 (경고 · 번호 · 긁힘 · 그을음 · 구멍 · 스텐실). 금속 긁힘은 metalness 를 높여 반짝이게.

## 핵심 코드 — 자리 · 방향 → DecalGeometry → 데칼 재질 · 붙는 연출
(발췌: demos/demosRender.ts makeDecals() 의 build() · update 를 정리)
```ts
import { DecalGeometry } from 'three/examples/jsm/geometries/DecalGeometry.js';

// d = { p: 물체 좌표 자리, n: 표면 방향, s: 상자 크기(z = 투영 깊이), rot: 돌림, metal }
target.updateMatrixWorld(true);
const pos = new THREE.Vector3(...d.p).applyMatrix4(target.matrixWorld);
const nrm = new THREE.Vector3(...d.n).transformDirection(target.matrixWorld);
const helper = new THREE.Object3D();
helper.position.copy(pos);
helper.lookAt(pos.clone().add(nrm)); // 상자가 표면을 바라보게
helper.rotateZ(d.rot);
const geo = new DecalGeometry(target, pos, helper.rotation.clone(), new THREE.Vector3(...d.s));
geo.translate(-pos.x, -pos.y, -pos.z); // 원점 가까이 — 정밀도

const mat = new THREE.MeshStandardMaterial({
  map, transparent: true, depthWrite: false,
  polygonOffset: true, polygonOffsetFactor: -4, polygonOffsetUnits: -4, // 표면보다 살짝 앞으로
  roughness: d.metal ? 0.32 : 0.6, metalness: d.metal ? 0.85 : 0,      // 금속 긁힘은 반짝
  emissive: 0xffffff, emissiveMap: map, emissiveIntensity: 0,
});
const decal = new THREE.Mesh(geo, mat);
decal.position.copy(pos);
decal.receiveShadow = true;
decal.renderOrder = 2 + i; // 겹치는 차례 고정
root.add(decal);

// 붙는 순간 (a = 붙은 뒤 초): 0.14초 동안 1.35 → 1배, 0.4초 동안 흰 빛이 빠진다
const pop = THREE.MathUtils.clamp(a / 0.14, 0, 1);
mat.opacity = pop;
mat.emissiveIntensity = Math.max(0, 1 - a / 0.4) * 0.9;
decal.scale.setScalar(1 + (1 - pop) * 0.35);
```

## 흔한 실수 · 확인 목록
- [ ] **polygonOffset 없이 붙이면 표면과 깜박인다** — 데칼은 표면과 같은 깊이에 있다. polygonOffset(−4, −4) + depthWrite false 로 앞으로 당긴다.
- [ ] **matrixWorld 를 갱신하기 전에 만들면 엉뚱한 자리에 붙는다** — 물체를 장면에 넣고 updateMatrixWorld(true) 뒤에 DecalGeometry 를 만든다.
- [ ] **여러 장을 한 프레임에 만들면 화면이 멈칫한다** — 데칼 하나가 원래 메시 삼각형을 다 돈다. 견본은 제너레이터로 프레임당 3ms 씩 나눠 만든다.
- [ ] **투영 깊이(s.z)가 크면 뒷면까지 잘려 나온다** — 얇은 벽 · 통에선 깊이를 0.3 안팎으로. 견본의 그을음처럼 큰 것만 0.4.

## 완성 기준 체크리스트
- [ ] 둥근 통에 붙인 이름표가 통 곡면을 따라 휘어 보인다
- [ ] 데칼이 하나씩 0.14초 동안 커졌다 줄며 붙고, 붙는 순간 하얗게 번쩍했다 빠진다
- [ ] 「데칼 보이기」를 끄면 맨 표면으로 돌아간다 — 켬/끔 비교로 디테일 차이가 보인다
- [ ] 카메라가 돌아도 데칼이 깜박이거나(z-fighting) 표면 밑으로 묻히지 않는다

## 이 기술 정보
- id: `i479` · 분류: 3D 모델 · 캐릭터 › 하드서피스 · 실사 렌더링 · 3D · 난이도 보통 · 폰 부담 보통 (폰 주의) — 데칼 하나 만들 때 원래 메시의 삼각형을 모두 검사한다 (원기둥 72조각이면 수천 개). 만든 뒤 그리기는 투명 메시 하나. 견본 10장은 프레임당 3ms 로 나눠 만든다.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i479
- 쓰면 좋을 때: 큰 텍스처를 새로 그리지 않고 표면 디테일을 더할 때 / 총알 자국 · 발자국처럼 게임 중에 생기는 흔적
- 쓰지 말 때: 움직이고 휘는 캐릭터 몸 — 대신 텍스처에 직접 그리기 / 수백 개가 계속 쌓이는 흔적 — 개수 한도를 두고 오래된 것부터 지운다 (발자국 데칼 참고)

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### makeDecals — `src/demos/demosRender.ts:2155`
```ts
function makeDecals(T: typeof THREE): Scene3D {
  const scene = new T.Scene();
  scene.background = new T.Color(0x0e1017);
  const cam = new T.PerspectiveCamera(34, 1.6, 0.1, 60);
  const hud = new Hud();
  const msg = hud.label('재질 받는 중 …', 0.5, 0.5, 0.5, 0.5, 'tag', 0.8);
  const count = hud.label('', 0.5, 0.965, 0.5, 1, 'tag', 0.62);
  count.maxW = 0.9;
  count.visible = false;

  scene.add(new T.HemisphereLight(0xd8e4ff, 0x30281e, 0.45));
  const sun = new T.DirectionalLight(0xfff0e0, 2.0);
  sun.position.set(-3, 5, 4);
  sun.castShadow = true;
  sun.shadow.mapSize.set(1024, 1024);
  Object.assign(sun.shadow.camera, { left: -4, right: 4, top: 3.5, bottom: -1, near: 1, far: 16 });
  sun.shadow.bias = -0.0007;
  sun.shadow.normalBias = 0.02;
  scene.add(sun);

  const root = new T.Group();
  root.visible = false;
  scene.add(root);
  const mk = (g: THREE.BufferGeometry, x: number, y: number, z: number, ry = 0): THREE.Mesh => {
    const m = new T.Mesh(g);
    m.position.set(x, y, z);
    m.rotation.y = ry;
    m.castShadow = m.receiveShadow = true;
    root.add(m);
    return m;
  };
  const floor = mk(new T.PlaneGeometry(9, 5), 0, 0, 0.6);
  floor.rotation.x = -Math.PI / 2;
  floor.castShadow = false;
  const wall = mk(new T.BoxGeometry(6.4, 3.2, 0.3), 0, 1.6, -1.1);
  const crate = mk(new T.BoxGeometry(1.1, 1.1, 1.1), -1.05, 0.55, 0.25, 0.55);
  const barrel = mk(new T.CylinderGeometry(0.42, 0.42, 1.15, 72, 6), 1.05, 0.575, 0.1, -0.2);
  const ringG = new T.TorusGeometry(0.425, 0.022, 8, 72);
  for (const y of [-0.42, 0.42]) {
    const r = new T.Mesh(ringG);
    r.rotation.x = Math.PI / 2;
    r.position.y = y;
    r.castShadow = true;
    barrel.add(r);
  }
  const blobs = [blob(2.0, 0.6), blob(1.4, 0.6)];
  blobs[0]!.position.set(-1.05, 0.004, 0.25);
  blobs[1]!.position.set(1.05, 0.004, 0.1);
  root.add(...blobs);
  root.updateMatrixWorld(true);
  const target: Record<DecalSpec['on'], THREE.Mesh> = { crate, barrel, wall };

  const decals: { mesh: THREE.Mesh; mat: THREE.MeshStandardMaterial; t0: number }[] = [];
  const job = new Job();
  function* build(): Gen {
    const helper = new T.Object3D();
    const pos = new T.Vector3();
    const nrm = new T.Vector3();
    for (let i = 0; i < DECALS.length; i++) {
      const d = DECALS[i]!;
      const m = target[d.on];
      pos.set(...d.p).applyMatrix4(m.matrixWorld);
      nrm.set(...d.n).transformDirection(m.matrixWorld);
      helper.position.copy(pos);
      helper.lookAt(pos.clone().add(nrm));
      helper.rotateZ(d.rot);
      const geo = new DecalGeometry(m, pos, helper.rotation.clone(), new T.Vector3(...d.s));
      geo.translate(-pos.x, -pos.y, -pos.z);
      if (late()) yield;
      const map = decalMap(d.tex);
      const mat = new T.MeshStandardMaterial({
        map,
        transparent: true,
        depthWrite: false,
        polygonOffset: true,
        polygonOffsetFactor: -4,
        polygonOffsetUnits: -4,
        roughness: d.tex === 'soot' ? 1 : d.metal ? 0.32 : 0.6,
        metalness: d.metal ? 0.85 : 0,
        emissive: 0xffffff,
        emissiveMap: map,
        emissiveIntensity: 0,
      });
      const mesh = new T.Mesh(geo, mat);
      mesh.position.copy(pos);
      mesh.receiveShadow = true;
      mesh.renderOrder = 2 + i;
      mesh.visible = false;
      root.add(mesh);
      decals.push({ mesh, mat, t0: 0.5 + i * 0.62 });
      if (late()) yield;
    }
  }
  job.start(build());

  let kit: Kit | null = null;
  let dead = false;
  let mats: THREE.MeshStandardMaterial[] = [];
  let ringM: THREE.MeshStandardMaterial | null = null;
  loadKit().then((k) => {
    if (dead) return;
    kit = k;
    const p = k.pbr;
    const crateM = pbrMat(p.metal, 1, 1, { color: 0xc8d89a, metalness: 0.3 }); // 칠한 쇠 — 금속감은 줄이고 색을 살린다
    const barrelM = pbrMat(p.metal, 2, 1, { color: 0x9cbcf0, metalness: 0.3 });
    const wallM = pbrMat(p.brick, 2.4, 1.2);
    const floorM = pbrMat(p.rock, 3, 1.7);
    ringM = pbrMat(p.metal, 3, 0.2, { color: 0x6a6a70 });
    mats = [crateM, barrelM, wallM, floorM, ringM];
    crate.material = crateM;
    barrel.material = barrelM;
    wall.material = wallM;
    floor.material = floorM;
    for (const c of barrel.children) (c as THREE.Mesh).material = ringM;
    root.visible = true;
  });
  let clock = 0;
  let base = 0;
  let loop = true;
  let show = true;
  let orbit = 0;
  const warm = new Warm();
  const tgt = new T.Vector3(0, 0.95, 0);
  const CYC = 9.4;

  return {
    scene,
    camera: cam,
    update(t) {
      if (job.busy) job.step(3);
      orbit = t;
      let lt = t - base;
      if (loop) lt %= CYC;
      clock = lt;
      let n = 0;
      for (const d of decals) {
        const a = lt - d.t0;
        const fade = loop ? clamp((CYC - 0.6 - lt) / 0.5, 0, 1) : 1;
        const vis = show && a > 0 && fade > 0;
        d.mesh.visible = vis;
        if (!vis) continue;
        n++;
        const pop = clamp(a / 0.14, 0, 1);
        d.mat.opacity = pop * fade;
        d.mat.emissiveIntensity = Math.max(0, 1 - a / 0.4) * 0.9;
        d.mesh.scale.setScalar(1 + (1 - pop) * 0.35);
      }
      count.set(show ? `데칼 ${n} / ${DECALS.length}장 붙음` : '데칼 끔 — 맨 표면');
    },
    render(r, w, h) {
      if (!kit || job.busy) return waiting(r, w, h, hud, msg, '재질 받는 중 …');
      if (!pump(r, [...sceneTex(root), ...decals.map((d) => d.mat.map!)])) return waiting(r, w, h, hud, msg, 'GPU 에 올리는 중 …');
      if (kit.hdr) {
        const env = envFor(r, kit.hdr);
        if (!env) return waiting(r, w, h, hud, msg, 'HDRI 빛 굽는 중 …');
        scene.environment = env;
        scene.environmentIntensity = 1.0;
      }
      const a = Math.sin(orbit * 0.3) * 0.38;
      const c = fit(cam, w / h, tgt, 2.25, new T.Vector3(Math.sin(a), 0.32, Math.cos(a)));
      const ok = warm.ready(r, (rr) =>
        withShadows(rr, true, () => {
          for (const d of decals) d.mesh.visible = true;
          const pr = rr.compileAsync(scene, c);
          return pr;
        }),
      );
      if (!ok) return waiting(r, w, h, hud, msg, '셰이더 굽는 중 …');
      msg.visible = false;
      count.visible = true;
      void clock;
      withShadows(r, true, () => r.render(scene, c));
      hud.draw(r, w, h);
    },
    controls: [
      { type: 'button', label: '처음부터 다시 붙이기', on: () => ((base = orbit), (loop = true)) },
      { type: 'toggle', label: '데칼 보이기', value: true, on: (v) => (show = v) },
      { type: 'toggle', label: '반복', value: true, on: (v) => ((loop = v), (base = v ? base : orbit - 7)) },
    ],
    dispose() {
      dead = true;
      job.start((function* (): Gen {})());
      disposeTree(root);
      ringG.dispose();
      for (const m of mats) m.dispose();
      hud.dispose();
    },
  };
}
```

## 관련 기술
- 같은 방법으로 만든 효과 예시: [물감 · 잉크 튀김 (철퍽 데칼 · 흘러내림)](https://ai-techstudio.web.app/ai/t/i506.md) `i506` · [발자국 데칼](https://ai-techstudio.web.app/ai/t/i215.md) `i215`
- 먼저 알면 좋은 기술: [실사 PBR 텍스처 (무료 CC0 스캔 재질)](https://ai-techstudio.web.app/ai/t/i474.md) `i474` · [캔버스로 그린 무늬 (CanvasTexture)](https://ai-techstudio.web.app/ai/t/u09.md) `u09`
- 다음에 해 볼 기술: [발자국 데칼](https://ai-techstudio.web.app/ai/t/i215.md) `i215` · [물감 · 잉크 튀김 (철퍽 데칼 · 흘러내림)](https://ai-techstudio.web.app/ai/t/i506.md) `i506`
- 참고 문서: [three.js 예제 — webgl_decals](https://threejs.org/examples/#webgl_decals) · [three.js 소스 — DecalGeometry](https://github.com/mrdoob/three.js/blob/dev/examples/jsm/geometries/DecalGeometry.js)
