# AI 꾸러미 — 삼면 투영 무늬 (triplanar) — Triplanar mapping
> UV 대신 물체 좌표의 세 평면(yz · xz · xy)에 무늬를 투영하고 법선 방향으로 섞어, 어떤 모양에도 무늬가 늘어나거나 이음새 없이 고르게 덮는다.  
> 견본: https://ai-techstudio.web.app/#t/i229

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

## 주문서

### 만들어 줘: 삼면 투영 무늬 (triplanar) — Triplanar mapping

#### 1. 목표
울퉁불퉁 덩어리 · 긴 기둥에 삼면 투영 무늬를 입혀 줘 — UV 없이 x · y · z 세 방향에서 비춰 법선으로 섞기. 느낌은 알록달록 타일.

#### 2. 핵심 기술 용어
- **Triplanar mapping** — 삼면 투영 — 세 축 방향에서 비춰 섞기
- **Blend weights = |N|^k normalized** — 법선 성분의 k 제곱으로 섞는 비율
- **UV stretching / seams** — UV 늘어남 · 이음새 — 피하려는 문제

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

#### 4. 조건
- 좌표는 물체 좌표(position)와 물체 법선(normal) — 물체가 돌아도 무늬가 붙어 있게
- 섞는 비율은 pow(abs(n), 날카로움) 을 합으로 나누기 (합 1)
- 무늬 텍스처는 RepeatWrapping
- 왼쪽 같은 모양 UV 무늬 · 오른쪽 삼면 투영으로 비교, 축 색 보기 단추(빨 x · 초 y · 파 z)
- 「섞임 날카로움」 1~24 · 「무늬 크기」 슬라이더

#### 5. 완성 기준 (이게 보이면 성공)
- 왼쪽 덩어리 · 긴 상자는 무늬가 늘어나고 이음새가 보이고, 오른쪽은 어디서나 칸 크기가 같다
- 「축 색 보기」를 켜면 면 방향마다 빨강 · 초록 · 파랑이 칠해져 어느 축 그림인지 보인다
- 날카로움을 1 로 내리면 세 그림이 흐릿하게 겹친다

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

## 원리
- UV 무늬는 모양을 펴서 붙이므로 울퉁불퉁한 곳은 늘어나고, 펴는 경계에 이음새가 생긴다.
- 삼면 투영은 물체 좌표 p 로 세 번 읽는다: x 쪽에서 (p.z, p.y), y 쪽에서 (p.x, p.z), z 쪽에서 (p.x, p.y).
- 섞는 비율 b = |N|^6 을 합이 1 이 되게 나눈다 — 면이 바라보는 축의 그림이 거의 다 차지한다.
- 지수(날카로움)를 올리면 경계가 또렷하고, 내리면 부드럽게 겹친다.

## 핵심 코드 — 삼면 투영 조각 셰이더
(발췌: demos/demosLook2.ts makeTriplanar() 의 triMat)
```glsl
// 정점: vO = position; vON = normal; vN = normalize(mat3(modelMatrix) * normal);
uniform sampler2D uMap; uniform float uScale, uSharp, uDebug; uniform vec3 uLight;
varying vec3 vO; varying vec3 vON; varying vec3 vN;
void main(){
  vec3 n = normalize(vON);
  vec3 b = pow(abs(n), vec3(uSharp));          // 바라보는 축일수록 큰 비율
  b /= (b.x + b.y + b.z);                      // 합 = 1
  vec3 p = vO * uScale;
  vec3 cx = texture2D(uMap, p.zy).rgb;         // x 쪽에서 본 그림
  vec3 cy = texture2D(uMap, p.xz).rgb;         // y 쪽 (위)
  vec3 cz = texture2D(uMap, p.xy).rgb;         // z 쪽 (앞)
  vec3 alb = cx * b.x + cy * b.y + cz * b.z;
  if (uDebug > 0.5) alb = mix(alb, b * vec3(1.0, 0.9, 1.2), 0.6); // 축 색 보기
  vec3 N = normalize(vN);
  float diff = max(dot(N, uLight), 0.0);
  vec3 amb = mix(vec3(0.30, 0.24, 0.32), vec3(0.72, 0.78, 1.0), N.y * 0.5 + 0.5) * 0.55;
  gl_FragColor = vec4(alb * (amb + diff * vec3(1.0, 0.95, 0.88) * 1.05), 1.0);
  #include <tonemapping_fragment>
  #include <colorspace_fragment>
}
// JS: uScale 0.9 · uSharp 6, 무늬 텍스처 wrapS = wrapT = RepeatWrapping
```

## 흔한 실수 · 확인 목록
- [ ] **세계 좌표로 투영하면 물체가 움직일 때 무늬가 흐른다** — 물체 좌표 · 물체 법선으로. 지형처럼 고정된 것만 세계 좌표가 편하다.
- [ ] **45° 기운 면은 두 그림이 반씩 겹쳐 흐릿하다** — 날카로움(지수)을 올리면 겹치는 띠가 좁아진다 (견본 6).
- [ ] **ShaderMaterial 에 색공간 · 톤 매핑을 안 붙이면 다른 재질과 색이 다르다** — 끝에 tonemapping_fragment · colorspace_fragment 를 include 한다.

## 완성 기준 체크리스트
- [ ] 왼쪽 덩어리 · 긴 상자는 무늬가 늘어나고 이음새가 보이고, 오른쪽은 어디서나 칸 크기가 같다
- [ ] 「축 색 보기」를 켜면 면 방향마다 빨강 · 초록 · 파랑이 칠해져 어느 축 그림인지 보인다
- [ ] 날카로움을 1 로 내리면 세 그림이 흐릿하게 겹친다

## 이 기술 정보
- id: `i229` · 분류: 그래픽 · 셰이더 › 재질 · 그림체 · 3D · 난이도 보통 · 폰 부담 가벼움 (폰 OK) — 조각마다 텍스처 3번 읽기. 보통 재질보다 조금 무겁지만 폰 OK.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i229
- 쓰면 좋을 때: UV 를 펴기 어려운 생성 모양 · 지형 · SDF 메시 / 크기가 제각각인 블록에 같은 칸 크기 무늬
- 쓰지 말 때: 얼굴 · 글씨처럼 정해진 자리에 붙어야 하는 그림 — 대신 UV + CanvasTexture(u09) / 아주 많은 물체를 폰에서 — 텍스처를 세 번 읽는다

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### makeTriplanar — `src/demos/demosLook2.ts:1631`
```ts
function makeTriplanar(): Scene3D {
  const scene = new THREE.Scene();
  scene.background = gradBg([
    [0, '#1b2140'],
    [1, '#2d2a4a'],
  ]);
  const cam = new THREE.PerspectiveCamera(36, 1.6, 0.1, 50);
  cam.position.set(0, 1.4, 7.4);
  cam.lookAt(0, 0.15, 0);
  scene.add(new THREE.HemisphereLight(0xb8c8ff, 0x4a3a50, 1.1));
  const dl = new THREE.DirectionalLight(0xfff2e0, 2.2);
  dl.position.set(3, 4, 5);
  scene.add(dl);
  const tex = tileTex();
  const uvTex = tex.clone();
  uvTex.repeat.set(2, 2);
  const uni = { uMap: { value: tex }, uScale: { value: 0.9 }, uSharp: { value: 6 }, uDebug: { value: 0 }, uLight: { value: dl.position.clone().normalize() } };
  const triMat = new THREE.ShaderMaterial({
    uniforms: uni,
    vertexShader: /* glsl */ `varying vec3 vO; varying vec3 vON; varying vec3 vN;
      void main(){ vO = position; vON = normal; vN = normalize(mat3(modelMatrix) * normal); gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }`,
    fragmentShader: /* glsl */ `uniform sampler2D uMap; uniform float uScale, uSharp, uDebug; uniform vec3 uLight;
      varying vec3 vO; varying vec3 vON; varying vec3 vN;
      void main(){
        vec3 n = normalize(vON);
        vec3 b = pow(abs(n), vec3(uSharp)); b /= (b.x + b.y + b.z);
        vec3 p = vO * uScale;
        vec3 cx = texture2D(uMap, p.zy).rgb, cy = texture2D(uMap, p.xz).rgb, cz = texture2D(uMap, p.xy).rgb;
        vec3 alb = cx * b.x + cy * b.y + cz * b.z;
        if (uDebug > 0.5) alb = mix(alb, b * vec3(1.0, 0.9, 1.2), 0.6);
        vec3 N = normalize(vN);
        float diff = max(dot(N, uLight), 0.0);
        vec3 amb = mix(vec3(0.30, 0.24, 0.32), vec3(0.72, 0.78, 1.0), N.y * 0.5 + 0.5) * 0.55;
        gl_FragColor = vec4(alb * (amb + diff * vec3(1.0, 0.95, 0.88) * 1.05), 1.0);
        ${OUT}
      }`,
  });
  const uvMat = new THREE.MeshLambertMaterial({ map: uvTex });
  const root = new THREE.Group();
  scene.add(root);
  const blob = blobGeo();
  const tall = new THREE.BoxGeometry(1, 1, 1, 1, 1, 1);
  tall.scale(0.7, 2.0, 0.7);
  const ring = new THREE.TorusKnotGeometry(0.42, 0.16, 160, 20);
  const make = (geo: THREE.BufferGeometry, mat: THREE.Material, x: number, y: number) => {
    const m = new THREE.Mesh(geo, mat);
    m.position.set(x, y, 0);
    root.add(m);
    return m;
  };
  const L = [make(blob, uvMat, -2.75, 0.1), make(tall, uvMat, -1.25, 0.1)];
  const R = [make(blob, triMat, 1.25, 0.1), make(tall, triMat, 2.75, 0.1)];
  const knotL = make(ring, uvMat, -2.0, -1.55);
  const knotR = make(ring, triMat, 2.0, -1.55);
  knotL.scale.setScalar(0.75);
  knotR.scale.setScalar(0.75);
  const hud = new Hud();
  const a = hud.add('UV 무늬 — 늘어남 · 이음새');
  const b = hud.add('삼면 투영 — 고른 무늬', 'rgba(30,110,100,0.8)');
  hud.at(a, 0.03, 0.05, 0, 0);
  hud.at(b, 0.97, 0.05, 1, 0);
  return {
    scene,
    camera: cam,
    update(t) {
      for (const m of [...L, ...R]) {
        m.rotation.y = t * 0.45;
        m.rotation.x = Math.sin(t * 0.3) * 0.35;
      }
      knotL.rotation.y = knotR.rotation.y = t * 0.6;
    },
    render(r, w, h) {
      r.render(scene, cam);
      drawDivider(r, w / 2, h, w);
      hud.draw(r, w, h);
    },
    controls: [
      { type: 'range', label: '섞임 날카로움', min: 1, max: 24, step: 1, value: 6, on: (v) => (uni.uSharp.value = v) },
      { type: 'range', label: '무늬 크기', min: 0.3, max: 2.5, step: 0.05, value: 0.9, on: (v) => (uni.uScale.value = v) },
      { type: 'toggle', label: '축 색 보기 (빨 x · 초 y · 파 z)', value: false, on: (v) => (uni.uDebug.value = v ? 1 : 0) },
    ],
    dispose() {
      blob.dispose();
      tall.dispose();
      ring.dispose();
      triMat.dispose();
      uvMat.dispose();
      tex.dispose();
      uvTex.dispose();
      hud.dispose();
      (scene.background as THREE.Texture).dispose();
    },
  };
}
```

### i229 견본 항목 — `src/demos/demosLook2.ts:4513`
```ts
  i229: { kind: '3d', caption: '왼쪽 UV 무늬는 늘어나고 이음새, 오른쪽 삼면 투영은 어떤 모양에도 고른 칸 (축 셋을 법선으로 섞기)', make: () => makeTriplanar() }
```

## 관련 기술
- 먼저 알면 좋은 기술: [캔버스로 그린 무늬 (CanvasTexture)](https://ai-techstudio.web.app/ai/t/u09.md) `u09`
- 다음에 해 볼 기술: [시차 차폐 매핑 (POM)](https://ai-techstudio.web.app/ai/t/i230.md) `i230` · [절차 마모 재질 (CG 티 없애기)](https://ai-techstudio.web.app/ai/t/i84.md) `i84`
