# AI 꾸러미 — 블렌더 없이 기계 원리 영상

## 주문서

### 한 장면 만들기: 블렌더 없이 기계 원리 영상

#### 1. 목표
샤프 (노크하면 척이 열리고 심이 나옴)의 작동 원리를 보여 주는 설명 영상을 블렌더 없이 three.js 코드만으로 만들어 줘 — 부품을 코드로 만들고, 반을 잘라 속을 보이고, 움직이는 원리를 이름표와 함께 보여 준 뒤, 경로 추적으로 사진처럼 한 프레임씩 구워 mp4 로. 분위기는 어두운 스튜디오 (남색 배경 · 네모 띠 조명).
아래 기술 13개를 한 장면에 엮어 줘. 한 번에 다 만들지 말고 1번부터 차례로 쌓되, 단계마다 화면에서 확인할 수 있게.

#### 2. 들어갈 기술 (쌓는 순서)
##### 1) 선반 회전체 + 장식 (윤곽선 하나로) — Lathe / surface of revolution
- 역할: 몸통 · 척 · 노즈처럼 둥근 부품 — 윤곽 하나를 돌려서
- 핵심: (x = 축에서 거리, y = 높이) 점 목록을 아래에서 위로 그리면 반쪽 옆모습이 된다.
- 확인: 노란 윤곽선이 먼저 그려지고, 그 선이 축을 따라 돌며 체스 말 · 화분 · 꽃병이 된다
- 확인: 받침 · 홈 · 구슬 테가 윤곽에서 나오고, 금 띠 색이 구간대로 칠해진다
##### 2) 스윕 · 로프트 (곡선 따라 단면 끌기) — Sweep / loft along a curve
- 역할: 스프링 · 곡선 부품 — 나선 곡선을 따라 관을 끌어서
- 핵심: 곡선을 고른 간격 점 n 개로 나누고, 점마다 진행 방향 T 를 앞뒤 점 차이로 구한다.
- 확인: 빛나는 테(단면)가 곡선을 따라가며 뒤에 관이 남는다
- 확인: 별 단면 뿔은 끝으로 갈수록 가늘어지며 비틀리고, 덩굴은 막대를 감아 오르고, 달팽이는 나선으로 말린다
##### 3) 뚜껑 없는 단면 (뒷면 셰이딩)
- 역할: 반을 잘라 속을 보이기 — 뚜껑 모형 없이 셰이더로 단면
- 핵심: 닫힌 입체를 양면으로 그리고 같은 자르기 평면 — 잘린 곳으로 보이는 뒷면을 셰이더가 「평평한 단면」으로 칠하고 평면 깊이를 써서, 뚜껑 모형 없이 쇠를 깎은 듯한 단면 · 칼을 움직여도 즉시 · 쐐기 모양 자르기도
- 확인: 견본처럼 「닫힌 입체를 양면으로 그리고 같은 자르기 평면 — 잘린 곳으로 보이는 뒷면을 셰이더가 「평평한 단면」으로 칠하고 평면 깊이를 써서, 뚜껑 모형 없이 쇠를 깎은 듯한 단면 · 칼을 움직여도 즉시 · 쐐기 모양 자르기도」 이(가) 첫 화면에서 바로 보인다
- 확인: 켬/끔(또는 전/후) 비교 단추로 이 기술이 만드는 차이를 확인할 수 있다
##### 4) 엑스레이 · 반투명 (프레넬) — pow
- 역할: 겉껍질을 투명하게 — 가장자리만 보이는 프레넬 유리
- 핵심: 가장자리만 빛나고 속이 비침 — pow(1 − N·V, p)
- 확인: 견본처럼 「가장자리만 빛나고 속이 비침 — pow(1 − N·V, p)」 이(가) 첫 화면에서 바로 보인다
- 확인: 켬/끔(또는 전/후) 비교 단추로 이 기술이 만드는 차이를 확인할 수 있다
##### 5) 절차 마모 재질 (CG 티 없애기) — Procedural wear / weathering material
- 역할: 플라스틱 같은 CG 티 없애기 — 거칠기 얼룩 · 색 차이
- 핵심: 시작할 때 64³ 크기의 4겹 값 잡음을 Data3DTexture 로 한 번 굽는다 (반복되게, 선형 필터).
- 확인: 오른쪽 금속에는 반사가 고르지 않게 흐려진 얼룩 · 세로 그을음 줄이 보이고, 왼쪽은 고른 거울 같은 금속이다
- 확인: 노즐 끝에서 위로 파랑 → 보라 → 금색 열 변색이 보이고, 경계가 잡음으로 흔들린다
##### 6) 기구학 연결 (크랭크 · 인벌류트 톱니 · 캠) — Involute gear profile (pressure angle 20°)
- 역할: 노크 하나로 척 · 심 · 스프링 위치를 모두 식으로
- 핵심: 톱니: 피치원 r = mz/2, 기초원 rb = r·cos20°, 이끝원 r + m, 이뿌리원 r − 1.25m. 반지름 R 의 면 점은 각 θ(R) = π/2z + inv(20°) − inv(acos(rb/R)) 에, inv(α) = tanα − α.
- 확인: 확대해도 두 톱니가 겹치거나 틈이 벌어지지 않고 맞닿은 채 굴러간다 (멈춤 0 빠르기에서 확인)
- 확인: 「피치원 · 기초원 · 작용선」을 켜면 두 피치원이 한 점에서 닿고, 작용선이 두 기초원에 접한다
##### 7) 3D 를 따라가는 이름표 · 지시선 — 3D-anchored labels (Vector3.project)
- 역할: 부품 이름표와 지시선이 3D 를 따라감
- 핵심: 부품마다 「물체 안 좌표」 한 점을 정해 두고, 매 프레임 matrixWorld 로 세계 좌표 → project(camera) 로 −1 ~ 1 화면 좌표 → 픽셀로 바꾼다.
- 확인: 로켓이 돌아도 이름표 다섯 개가 각 부품을 계속 가리킨다
- 확인: 부품이 뒤로 돌아가면 이름표가 흐려지고 「(뒤)」 가 붙으며 지시선이 점선이 된다
##### 8) 감독 투어 + 프레임 단위 녹화 — Playwright
- 역할: 구도마다 이름 붙인 카메라 대본 — 장면이 바뀔 때 카메라가 부드럽게 옮겨 감
- 핵심: 전시마다 이름 붙은 카메라 구도 · 커서 이동 · 실제 UI 클릭을 대본으로 → 시뮬레이션을 한 프레임씩 진행하며 녹화(Playwright + ffmpeg) — 느린 컴퓨터도 완벽한 60fps 영상
- 확인: 견본처럼 「전시마다 이름 붙은 카메라 구도 · 커서 이동 · 실제 UI 클릭을 대본으로 → 시뮬레이션을 한 프레임씩 진행하며 녹화(Playwright + ffmpeg) — 느린 컴퓨터도 완벽한 60fps 영상」 이(가) 첫 화면에서 바로 보인다
- 확인: 켬/끔(또는 전/후) 비교 단추로 이 기술이 만드는 차이를 확인할 수 있다
##### 9) 내레이션 동기화 (낱말 시각표) — speechSynthesis
- 역할: 내레이션을 낱말 시각표로 — 그림 · 자막 · 이름표가 목소리 박자에 맞춰
- 핵심: 내레이션을 낱말 단위 시각표로 — 그림 · 자막 · 목소리 · 음악이 같은 시계를 따라감 (음성 합성 speechSynthesis 또는 TTS 파일)
- 확인: 견본처럼 「내레이션을 낱말 단위 시각표로 — 그림 · 자막 · 목소리 · 음악이 같은 시계를 따라감 (음성 합성 speechSynthesis 또는 TTS 파일)」 이(가) 첫 화면에서 바로 보인다
- 확인: 켬/끔(또는 전/후) 비교 단추로 이 기술이 만드는 차이를 확인할 수 있다
##### 10) 영상으로 굽기 (헤드리스 브라우저 → mp4) — mp4
- 역할: 빠른 콘티 영상 — 실시간 화면 그대로 한 프레임씩 구워 흐름 · 박자 먼저 확인
- 핵심: 장면 코드를 헤드리스 브라우저로 1/30초씩 시계를 돌려 프레임을 찍고 · 목소리 · 음악과 ffmpeg 로 묶어 mp4 — 홍보 영상 · 설명 영상
- 확인: 견본처럼 「장면 코드를 헤드리스 브라우저로 1/30초씩 시계를 돌려 프레임을 찍고 · 목소리 · 음악과 ffmpeg 로 묶어 mp4 — 홍보 영상 · 설명 영상」 이(가) 첫 화면에서 바로 보인다
- 확인: 켬/끔(또는 전/후) 비교 단추로 이 기술이 만드는 차이를 확인할 수 있다
##### 11) GPU 경로 추적 (사진 같은 렌더 · 샘플 쌓기) — Path tracing (progressive, Monte Carlo)
- 역할: 최종 렌더 — 경로 추적으로 반사 · 굴절 · 그림자를 사진처럼
- 핵심: 실시간 렌더는 빛 하나에서 표면까지 한 번만 계산한다. 경로 추적은 카메라에서 빛줄을 쏘아 부딪힐 때마다 재질대로 방향을 무작위로 골라 다시 튀기고, 하늘 · 조명에 닿으면 지나온 색을 곱해 가져온다.
- 확인: 처음엔 자글자글하다가 샘플이 쌓일수록 매끈해지고, 32 → 128 샘플에서 노이즈가 눈에 띄게 절반쯤으로 준다
- 확인: 튐 횟수를 1 → 5 로 올리면 그림자 속 · 유리 공 안이 밝아지고 빨간 공 빛이 바닥에 묻어난다
##### 12) 노이즈 제거 (적은 샘플 → 깨끗하게 · OIDN) — Denoising (Intel Open Image Denoise, OIDN)
- 역할: 적은 샘플로도 깨끗하게 — 노이즈 제거
- 핵심: 경로 추적 노이즈는 픽셀마다 따로 튀는 「무작위 흔들림」이라, 이웃 픽셀과 평균 내면 준다. 그냥 흐리면 모서리 · 글씨 · 무늬까지 뭉개진다.
- 확인: 픽셀당 2 ~ 4 샘플의 자글자글한 그림이 오른쪽에서 매끈해지고, 물체 윤곽 · 바둑판 칸 경계가 뭉개지지 않는다
- 확인: 「법선 · 깊이 길잡이」를 끄면 물체 가장자리가 바닥으로 번지고, 「바탕색으로 나누기」를 끄면 바둑판이 흐려진다
##### 13) 경로 추적 영상 굽기 (프레임마다 수렴 → mp4) — Offline frame-by-frame rendering (deterministic time)
- 역할: 한 프레임씩 구워 puppeteer + ffmpeg 로 mp4
- 핵심: 실시간 녹화는 컴퓨터가 느리면 프레임이 빠지고 노이즈도 덜 걷힌다. 굽기는 시간을 프레임 번호로 정한다: t = i / 30. 한 장에 몇 초가 걸려도 영상은 정확히 30fps.
- 확인: 느린 컴퓨터에서 구워도 mp4 가 정확히 30fps 로 끊김 없이 재생되고, 길이가 (끝초 − 시작초)와 같다
- 확인: 같은 t 를 두 번 구우면 같은 그림이 나온다 (시간이 실제 시계에 묶이지 않음)

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

#### 4. 조건 (공통)
- 같은 장면 코드 하나로 두 길: 빠른 미리 보기(래스터 · 실시간 콘티)와 고화질 굽기(경로 추적) — 장면을 짓는 코드는 공유
- 시간은 window.__renderAt(t) 의 t 하나로만 — 부품 위치 · 카메라 · 이름표 · 자막이 모두 t 에서 계산된다 (같은 t = 같은 그림)
- 부품이 움직인 프레임에만 pathTracer.setScene, 카메라만 움직이면 updateCamera
- 굽기 전에 정지 그림 몇 장(stills 모드)으로 구도 · 노이즈 · 재질을 먼저 확인
- 윤곽은 아래 축(x = 0)에서 시작해 위 축(x = 0)으로 끝나야 바닥 · 꼭대기에 구멍이 없다
- x 는 0 아래로 내려가지 않게 (Math.max(0, x))
- 방향 틀은 평행 이동 방식 (이전 N 을 이어받기) — 점마다 위쪽 벡터로 새로 잡으면 곡선이 수직일 때 뒤집힌다
- 곡선 점은 getSpacedPoints 로 고른 간격
- 한 프레임 16ms 안 (폰 포함) — 무거운 계산은 처음에 한 번 또는 여러 프레임에 나눠서
- 새 재질 · 셰이더는 첫 화면 전에 미리 컴파일 (renderer.compileAsync)
- 잡음은 반복문 GLSL 이 아니라 미리 구운 Data3DTexture(RedFormat · LinearFilter · RepeatWrapping) 로
- 좌표는 물체 좌표 — 물체가 돌아도 얼룩이 붙어 있게
- 톱니 윤곽은 인벌류트 식으로(압력각 20°, 모듈 · 잇수 → 기초원 · 이끝원 · 이뿌리원), 양쪽 면은 대칭
- 두 바퀴는 같은 모듈, 중심 거리 = m(z₁ + z₂)/2, 위상 θ₂ = π + π/z₂ + (z₁/z₂)·θ₁ — 이 식을 빼면 첫 장면부터 이가 겹친다

#### 5. 완성 기준 (전체)
- 미리 보기에서 부품이 원리대로 움직이고 단면 · 이름표가 따라간다
- 같은 시각의 미리 보기와 경로 추적 렌더가 구도 · 부품 위치가 똑같다
- 콘티 영상(래스터)으로 내레이션 박자 · 카메라 흐름을 먼저 맞추고, 같은 대본으로 고화질을 굽는다
- 구운 mp4 가 정확한 30fps 이고, 금속 반사 · 투명 몸통 · 부드러운 그림자가 사진처럼 나온다
- 프레임당 32 샘플 + 노이즈 제거로 자글거림 없이, 프레임당 몇 초 안에 굽힌다
- 기술마다 켬/끔 단추가 있어 하나씩 꺼 보며 그 기술이 맡은 차이를 확인할 수 있다
- 콘솔 오류 0 · 폰에서도 끊기지 않는다

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

## 참고 1. 선반 회전체 + 장식 (윤곽선 하나로) — 둥근 모서리 윤곽 → LatheGeometry + 띠 색
```ts
type PNode = { x: number; y: number; r: number; c: number }; // r = 모서리 둥글기, c = 색 번호

function buildProfile(N: PNode[]): { pts: THREE.Vector2[]; cols: number[] } {
  const pts: THREE.Vector2[] = [], cols: number[] = [];
  const push = (x: number, y: number, c: number) => { pts.push(new THREE.Vector2(Math.max(0, x), y)); cols.push(c); };
  for (let i = 0; i < N.length; i++) {
    const n = N[i]!;
    if (n.r > 0 && i > 0 && i < N.length - 1) {
      const pr = N[i - 1]!, nx = N[i + 1]!;
      const l0 = Math.hypot(pr.x - n.x, pr.y - n.y), l1 = Math.hypot(nx.x - n.x, nx.y - n.y);
      const rr = Math.min(n.r, 0.45 * l0, 0.45 * l1); // 변 길이의 45% 까지만
      const ax = n.x + ((pr.x - n.x) / l0) * rr, ay = n.y + ((pr.y - n.y) / l0) * rr;
      const bx = n.x + ((nx.x - n.x) / l1) * rr, by = n.y + ((nx.y - n.y) / l1) * rr;
      for (let k = 0; k <= 6; k++) { // 2차 곡선으로 모서리 깎기
        const s = k / 6, u = 1 - s;
        push(u * u * ax + 2 * u * s * n.x + s * s * bx, u * u * ay + 2 * u * s * n.y + s * s * by, n.c);
      }
    } else push(n.x, n.y, n.c);
  }
  return { pts, cols };
}

const segs = 64;
const { pts, cols } = buildProfile(nodes);
const geo = new THREE.LatheGeometry(pts, segs);
const pal = [new THREE.Color(0xf1e4cc), new THREE.Color(0xe9b949)]; // 0 바탕 · 1 금 띠
const n = pts.length;
const col = new Float32Array((segs + 1) * n * 3);
for (let i = 0; i <= segs; i++)
  for (let j = 0; j < n; j++) pal[cols[j]!]!.toArray(col, (i * n + j) * 3);
geo.setAttribute('color', new THREE.BufferAttribute(col, 3));
const mesh = new THREE.Mesh(geo, new THREE.MeshPhysicalMaterial({ vertexColors: true, roughness: 0.32, clearcoat: 0.8, side: THREE.DoubleSide }));
```
- [ ] **윤곽이 축에서 시작 · 끝나지 않으면 바닥 · 꼭대기에 구멍이 뚫린다** — 첫 점과 마지막 점의 x 를 0 으로 둔다 (견본 L(0, 0, 0) 로 시작).
- [ ] **모서리 둥글기를 크게 주면 짧은 변에서 곡선이 뒤집혀 꼬인다** — 둥글기를 양쪽 변 길이의 45% 이하로 자른다.
- [ ] **LatheGeometry 는 정점 순서가 (둘레 i, 윤곽 j) 라 색 배열 순서를 틀리기 쉽다** — 색 인덱스는 (i × 윤곽 점 수 + j). 둘레는 segs + 1 줄이다.

## 참고 2. 스윕 · 로프트 (곡선 따라 단면 끌기) — 평행 이동 틀로 단면 끌기
```ts
function buildSweep(pts: THREE.Vector3[], radial: number, radius: (u: number) => number,
  section?: (a: number, u: number) => number, twist?: (u: number) => number): THREE.BufferGeometry {
  const n = pts.length, TAU = Math.PI * 2;
  const pos = new Float32Array(n * radial * 3);
  const idx: number[] = [];
  const T = new THREE.Vector3(), N = new THREE.Vector3(), B = new THREE.Vector3();
  for (let i = 0; i < n; i++) {
    const u = i / (n - 1), p = pts[i]!;
    T.subVectors(pts[Math.min(n - 1, i + 1)]!, pts[Math.max(0, i - 1)]!).normalize();
    if (i === 0) {
      N.set(0, 1, 0).addScaledVector(T, -T.y);
      if (N.lengthSq() < 1e-4) N.set(1, 0, 0).addScaledVector(T, -T.x);
      N.normalize();
    } else N.addScaledVector(T, -N.dot(T)).normalize(); // 이전 N 을 이어받는다 = 뒤집힘 없음
    B.crossVectors(T, N);
    const r = radius(u), tw = twist ? twist(u) : 0;
    for (let j = 0; j < radial; j++) {
      const a = (j / radial) * TAU;
      const rr = r * (section ? section(a - tw, u) : 1);
      const ca = Math.cos(a) * rr, sa = Math.sin(a) * rr, k = (i * radial + j) * 3;
      pos[k] = p.x + N.x * ca + B.x * sa;
      pos[k + 1] = p.y + N.y * ca + B.y * sa;
      pos[k + 2] = p.z + N.z * ca + B.z * sa;
    }
  }
  for (let i = 0; i < n - 1; i++)
    for (let j = 0; j < radial; j++) {
      const a = i * radial + j, a1 = i * radial + ((j + 1) % radial);
      idx.push(a, a1, a + radial, a1, a1 + radial, a + radial);
    }
  const g = new THREE.BufferGeometry();
  g.setAttribute('position', new THREE.BufferAttribute(pos, 3));
  g.setIndex(idx);
  g.computeVertexNormals();
  return g;
}
// 견본 뿔: radius = u => 0.25 * Math.pow(1 - u, 0.9) + 0.003, section = a => 1 + 0.15 * Math.cos(5 * a), twist = u => 3 * Math.PI * 2 * u
// 진행 보기: geo.setDrawRange(0, segs * radial * 6);
```
- [ ] **점마다 위쪽 벡터로 틀을 새로 잡으면 곡선이 서는 곳에서 관이 휙 뒤집힌다** — 첫 점만 위쪽으로 잡고, 다음부터는 이전 N 에서 T 성분을 빼서 이어받는다 (평행 이동).
- [ ] **곡선 점 간격이 고르지 않으면 굵기 변화가 울퉁불퉁하다** — getPoints 대신 getSpacedPoints 로 호 길이가 고른 점을 뽑는다.
- [ ] **끝이 뚫려 안이 보인다** — 시작 끝에 가운데 점 하나 + 부채꼴 삼각형으로 뚜껑을 만든다. 가늘어지는 끝은 굵기를 0 가까이.

## 참고 3. 뚜껑 없는 단면 (뒷면 셰이딩) — 
```ts

```


## 참고 4. 엑스레이 · 반투명 (프레넬) — 
```ts

```


## 참고 5. 절차 마모 재질 (CG 티 없애기) — 구운 3D 잡음으로 거칠기 · 색 · 그을음 · 열 변색
```glsl
// JS: NOISE3D.value = makeNoise3D(64)  — 64³ 4겹 값 잡음 Data3DTexture (RedFormat, Linear, Repeat)
// uDetail = (detail, roughVar, colorVar, bump) · uTintRange = (y0, y1, strength)
uniform highp sampler3D uNoise3D;
uniform vec4 uDetail; uniform float uStreaks;
uniform vec3 uTintA; uniform vec3 uTintB; uniform vec3 uTintC; uniform vec3 uTintRange;
varying vec3 vObj;                                   // 물체 좌표 (정점에서 넘김)
float n3(vec3 p) { return texture(uNoise3D, p).r; }
float fbm2(vec3 p) { return n3(p) * 0.65 + n3(p * 2.7 + 0.31) * 0.35; }

// ① clipping_planes_fragment 뒤: 잡음 값 둘
vec3 dp = vObj * uDetail.x;
float surfN1 = fbm2(dp);
float surfN2 = n3(dp * 3.3 + vec3(7.3, 1.1, 3.7));
float st = n3(vec3(vObj.x * uDetail.x * 2.2, vObj.y * uDetail.x * 0.18, vObj.z * uDetail.x * 2.2)); // 세로로 늘인 잡음
surfN1 = mix(surfN1, st, 0.55);

// ② color_fragment 뒤: 색 차이 · 열 변색 · 그을음
diffuseColor.rgb *= 1.0 + (surfN1 - 0.5) * uDetail.z * 2.0;
float ty = clamp((vObj.y - uTintRange.x) / (uTintRange.y - uTintRange.x), 0.0, 1.0);
ty = clamp(ty + (surfN2 - 0.5) * 0.18, 0.0, 1.0);
vec3 tc = ty < 0.5 ? mix(uTintA, uTintB, ty * 2.0) : mix(uTintB, uTintC, ty * 2.0 - 1.0);
diffuseColor.rgb = mix(diffuseColor.rgb, tc, uTintRange.z);
diffuseColor.rgb *= 1.0 - uStreaks * smoothstep(0.45, 0.8, surfN1);

// ③ metalnessmap_fragment 뒤: 거칠기 얼룩
roughnessFactor = clamp(roughnessFactor * (1.0 + (surfN2 - 0.5) * uDetail.y * 2.0), 0.03, 1.0);
// 견본 노즐 값: detail 1.3 · roughVar 0.28 · colorVar 0.05 · streaks 0.28 · tint 0x34477e → 0x6b5a78 → 0xb58a4a, 세기 0.6
```
- [ ] **반복문 fbm 잡음을 셰이더에서 여러 번 부르면 윈도 컴파일이 17~20초 멈춘다** — 보물 동굴 게임에서 실제로 겪었다. 잡음은 미리 구운 3D 텍스처로 읽기만 하고, 값은 uniform 으로 돌려 셰이더 종류를 하나로.
- [ ] **세계 좌표로 잡음을 읽으면 물체가 움직일 때 얼룩이 흐른다** — 정점에서 물체 좌표(vObj)를 넘긴다.
- [ ] **환경 반사가 없으면 거칠기 얼룩이 안 보인다** — 금속은 반사로 드러난다. PMREM 스튜디오 환경을 넣는다.
- [ ] **마모를 세게 하면 「더러운」 그림이 된다** — 견본 색 차이는 0.05 처럼 작게. 거칠기 얼룩이 먼저, 색 얼룩은 아주 조금.

## 참고 6. 기구학 연결 (크랭크 · 인벌류트 톱니 · 캠) — 인벌류트 톱니 윤곽 + 맞물림 위상 + 캠 밀대
```ts
const PA = (20 * Math.PI) / 180;
const inv = (a) => Math.tan(a) - a;
function involutePts(z, m) {
  const rp = (m * z) / 2, rb = rp * Math.cos(PA), ra = rp + m, rf = rp - 1.25 * m;
  const half = Math.PI / (2 * z) + inv(PA);
  const th = (r) => half - inv(Math.acos(Math.min(1, rb / Math.max(r, rb))));
  const rs = [rf, ...(rf < rb ? [rb] : [])];
  for (let i = 1; i <= 6; i++) rs.push(Math.max(rf, rb) + (ra - Math.max(rf, rb)) * (i / 6));
  const P = (r, a) => new THREE.Vector2(r * Math.cos(a), r * Math.sin(a));
  const pts = [];
  for (let i = 0; i < z; i++) {
    const c = (i * 2 * Math.PI) / z;
    for (const r of rs) pts.push(P(r, c - th(r)));                   // 한쪽 면 (아래 → 이끝)
    for (const r of [...rs].reverse()) pts.push(P(r, c + th(r)));   // 거울 면 (이끝 → 아래)
    const a0 = c + th(rf), a1 = c + (2 * Math.PI) / z - th(rf);
    for (let k = 1; k <= 3; k++) pts.push(P(rf, a0 + ((a1 - a0) * k) / 4)); // 이뿌리 호
  }
  return pts;
}
const bev = m * 0.16;
const geo = new THREE.ExtrudeGeometry(new THREE.Shape(involutePts(16, 0.07)), {
  depth: 0.2 - 2 * bev, bevelEnabled: true, bevelThickness: bev, bevelSize: bev, bevelOffset: -bev, bevelSegments: 2,
});
// 매 프레임: 각 θ 하나로 전부
g1.rotation.z = -th;
g2.rotation.z = Math.PI + Math.PI / 32 + (16 / 32) * th;   // 중심 거리 0.07·(16+32)/2
let top = 0;                                               // 납작 밀대 = 돌린 캠 윤곽의 가장 큰 y
for (const p of camPts) top = Math.max(top, p.clone().rotateAround(O, g2.rotation.z).y);
tappet.position.y = g2.position.y + top;
```
- [ ] **사다리꼴 이는 돌 때 서로 파고든다** — 이 면이 인벌류트가 아니면 맞닿는 점의 속도가 안 맞아 겹치거나 벌어진다. 인벌류트 식으로 윤곽을 만든다.
- [ ] **위상 식을 빼면 첫 장면부터 이가 겹친다** — 큰 바퀴를 π + π/z₂ 만큼 돌려 놓아야 이 0 번 앞에 홈이 온다. 견본 검사기로 반 피치 틀리면 겹친 점 239개, 맞으면 0.
- [ ] **모서리를 바깥으로 깎으면 이가 부풀어 겹친다** — ExtrudeGeometry 의 bevelSize 는 윤곽을 그만큼 키운다. bevelOffset = −bevelSize 로 옆벽을 원래 윤곽에 둔다.
- [ ] **잇수가 너무 적으면 이끝이 상대 뿌리를 판다** — 압력각 20° 는 17개 아래에서 언더컷. 12 : 24 는 간섭이 있어 16 : 32 로 바꿨다 (이끝 ≤ √(rb² + (C·sin20°)²)).

## 참고 7. 3D 를 따라가는 이름표 · 지시선 — 3D 점 → 화면 이름표 · 가려짐 검사
```ts
import * as THREE from 'three';

interface Anchor { name: string; obj: THREE.Object3D; local: THREE.Vector3; side: number; el: HTMLDivElement; line: SVGLineElement }
const ray = new THREE.Raycaster();
const clamp = (v: number, a: number, b: number) => Math.max(a, Math.min(b, v));

function placeLabels(anchors: Anchor[], cam: THREE.PerspectiveCamera, scene: THREE.Scene, hitList: THREE.Object3D[], w: number, h: number, fs: number) {
  scene.updateMatrixWorld();
  for (const a of anchors) {
    const wp = a.local.clone().applyMatrix4(a.obj.matrixWorld);      // 물체 안 점 → 세계
    const sp = wp.clone().project(cam);                              // 세계 → -1 ~ 1
    const x = ((sp.x + 1) / 2) * w;
    const y = ((1 - sp.y) / 2) * h;
    // 가려졌나? 카메라 → 점 사이에 다른 면이 먼저 닿으면 뒤
    const dir = wp.clone().sub(cam.position);
    const dist = dir.length();
    ray.set(cam.position, dir.normalize());
    const hit = ray.intersectObjects(hitList, false)[0];
    const hidden = !!hit && hit.distance < dist - 0.07;
    const lx = clamp(x + a.side * w * 0.2, fs * 3.5, w - fs * 3.5); // 옆으로 비켜, 화면 안으로
    const ly = y - fs * 0.8;
    a.el.style.transform = 'translate(' + lx + 'px, ' + ly + 'px) translate(-50%, -50%)';
    a.el.style.opacity = hidden ? '0.35' : '1';
    a.el.textContent = hidden ? a.name + ' (뒤)' : a.name;
    a.line.setAttribute('x1', String(x)); a.line.setAttribute('y1', String(y));
    a.line.setAttribute('x2', String(lx)); a.line.setAttribute('y2', String(ly));
    a.line.setAttribute('stroke-dasharray', hidden ? '4 4' : '');
  }
}
```
- [ ] **이름표가 한 프레임 늦게 따라온다** — 투영 전에 updateMatrixWorld 를 불러 이번 프레임의 자리로 계산한다.
- [ ] **점이 물체 표면 바로 위라 자기 면에 맞아 늘 「가려짐」이 된다** — 맞은 거리가 점까지 거리보다 조금(0.07) 더 가까울 때만 가려진 것으로 친다.
- [ ] **이름표를 left · top 으로 옮기면 매 프레임 레이아웃을 다시 계산해 버벅인다** — transform: translate 로만 옮기고 will-change: transform 을 준다.
- [ ] **카메라 뒤로 간 점도 화면에 투영돼 엉뚱한 곳에 이름표가 뜬다** — project 결과 z 가 1 보다 크면(카메라 뒤) 이름표를 숨긴다.

## 참고 8. 감독 투어 + 프레임 단위 녹화 — 
```ts

```


## 참고 9. 내레이션 동기화 (낱말 시각표) — 
```ts

```


## 참고 10. 영상으로 굽기 (헤드리스 브라우저 → mp4) — 
```ts

```


## 참고 11. GPU 경로 추적 (사진 같은 렌더 · 샘플 쌓기) — three-gpu-pathtracer 로 한 장 렌더 (원하는 spp 까지)
```js
import { WebGLPathTracer, PhysicalCamera, GradientEquirectTexture } from 'three-gpu-pathtracer';

const renderer = new THREE.WebGLRenderer({ antialias: false, preserveDrawingBuffer: true });
renderer.toneMapping = THREE.ACESFilmicToneMapping;
const camera = new PhysicalCamera(30, W / H, 0.1, 100);
camera.fStop = 5.6;                       // 작을수록 배경이 흐림
camera.apertureBlades = 6;                // 흐린 빛 방울이 육각형

const bg = new GradientEquirectTexture();
bg.topColor.set(0xd9d2c6); bg.bottomColor.set(0x6d6052); bg.update();
scene.background = bg; scene.environment = bg;

const pt = new WebGLPathTracer(renderer);
pt.bounces = 5; pt.transmissiveBounces = 4; pt.filterGlossyFactor = 1.0;
pt.renderDelay = 0; pt.fadeDuration = 0; pt.minSamples = 1;
pt.setScene(scene, camera);               // 모형이 바뀔 때만 (BVH 다시 짓기)

async function renderStill(spp) {
  camera.focusDistance = camera.position.distanceTo(target);
  pt.updateCamera();
  while (pt.samples < spp) {
    pt.renderSample();                    // 픽셀마다 1 샘플 더 쌓기
    if (Math.floor(pt.samples) % 8 === 0) await new Promise((r) => requestAnimationFrame(r));
  }
}
```
- [ ] **작은 점 조명 하나면 수천 샘플에도 자글거린다** — 빛줄이 그 작은 점에 우연히 닿을 확률이 낮아서다. 넓은 면 조명 · 띠 조명 · 환경 지도로 빛을 넓게 준다. 견본도 띠 조명을 넓히자 같은 샘플에서 바닥 노이즈가 크게 줄었다.
- [ ] **매 프레임 setScene 을 부르면 몇 초씩 멈춘다** — setScene 은 BVH 를 새로 짓는다. 카메라만 바뀌면 updateCamera(), 부품이 움직인 프레임에만 setScene — 「도구와 기계의 원리」 렌더도 부품 위치 문자열을 비교해 바뀔 때만 다시 지었다.
- [ ] **반쪽 실수(HalfFloat) 평균은 수천 샘플에서 멈춘다** — mix(전, 새, 1/(n+1)) 의 더하는 양이 반쪽 실수 정밀도 아래로 내려간다. 상한(견본 1024)을 두거나 FloatType 타깃에 합을 쌓고 나눠서 보인다.
- [ ] **셰이더 한 줄 주석이 같은 줄 코드를 삼킨다** — GLSL 에서 // 뒤는 줄 끝까지 주석이다. 견본에서 any = true 가 주석에 먹혀 바닥이 통째로 사라졌다 — 주석은 코드 위 줄에.
- [ ] **smoothstep(큰 값, 작은 값, x) 는 D3D 에서 0 이 나온다** — 경계가 거꾸로면 GLSL 명세상 결과가 정해지지 않았다. 1.0 - smoothstep(작은 값, 큰 값, x) 로 쓴다 (견본 바닥이 윈도 크롬에서만 검게 나왔다).

## 참고 12. 노이즈 제거 (적은 샘플 → 깨끗하게 · OIDN) — à-trous 한 단계 — 법선 · 깊이 · 밝기로 무게 (uStep = 1, 2, 4, 8, 16)
```glsl
const float K[3] = float[3](0.375, 0.25, 0.0625);
vec3 c0 = fetch(vUv);                       // fetch = 색 / 바탕색 (첫 단계)
vec4 nd0 = texture2D(uNd, vUv);             // 법선 xyz + 깊이 w
vec3 sum = vec3(0.0); float wsum = 0.0;
for (int y = -2; y <= 2; y++) for (int x = -2; x <= 2; x++) {
  vec2 uv = vUv + vec2(float(x), float(y)) * uStep * uTexel;
  vec3 c = fetch(uv);
  float w = K[abs(x)] * K[abs(y)];
  float l = dot(c, vec3(0.2126, 0.7152, 0.0722)), l0 = dot(c0, vec3(0.2126, 0.7152, 0.0722));
  float dl = l / (1.0 + l) - l0 / (1.0 + l0);          // 톤을 눌러 비교
  w *= exp(-dl * dl / (uSigC * uSigC));
  vec4 nd = texture2D(uNd, uv);
  bool m0 = dot(nd0.xyz, nd0.xyz) < 0.25, m = dot(nd.xyz, nd.xyz) < 0.25;   // 배경
  w *= m0 || m ? float(m0 == m)
       : pow(max(dot(nd.xyz, nd0.xyz), 0.0), 32.0) * exp(-abs(nd.w - nd0.w) / (0.06 * uStep + 0.02));
  sum += c * w; wsum += w;
}
vec3 o = sum / max(wsum, 1e-6);
if (lastPass) o *= texture2D(uAlb, vUv).rgb;  // 마지막 단계에서 바탕색 다시 곱하기
gl_FragColor = vec4(o, 1.0);
```
- [ ] **색(RGB) 차이로 무게를 주면 반짝이는 점이 그대로 남는다** — 적은 샘플에서는 노이즈 자체가 큰 색 차이라 이웃 무게가 0 이 된다. 톤을 누른 밝기 차이로 바꾸고 기준을 넉넉히(첫 단계 0.9) — 견본이 이렇게 고쳐서야 걸러졌다.
- [ ] **배경 픽셀이 까맣게 빠진다** — 하늘은 법선이 0 이라 dot(n, n0) 무게가 모두 0, 나누기가 0/0 이 된다. 법선 0 끼리만 무게 1.
- [ ] **바탕색으로 안 나누면 무늬가 뭉개진다** — 무늬도 「색 차이」라 체가 지운다. 색 / 바탕색으로 빛만 거르고 다시 곱한다 (OIDN 에 albedo 를 넣는 까닭도 같다).
- [ ] **OIDN 이 「navigator.gpu 없음」으로 안 돈다** — oidn-web 은 WebGPU 가 필요하다. 헤드리스 크롬은 --enable-unsafe-webgpu 와 GPU 옵션이 있어야 하고, 안 되면 견본 같은 à-trous 로 대신한다.

## 참고 13. 경로 추적 영상 굽기 (프레임마다 수렴 → mp4) — puppeteer 로 __renderAt(t) 를 1/30초씩 → ffmpeg 로 mp4
```js
import puppeteer from 'puppeteer-core';
import { spawn } from 'node:child_process';

const FPS = 30;
const browser = await puppeteer.launch({
  executablePath: CHROME, headless: 'new', protocolTimeout: 0,
  args: ['--use-angle=d3d11', '--enable-gpu', '--ignore-gpu-blocklist', '--enable-unsafe-webgpu'],
  defaultViewport: { width: 1920, height: 1080, deviceScaleFactor: 1 },
});
const page = await browser.newPage();
await page.goto(URL + '?spp=32', { waitUntil: 'networkidle0', timeout: 180000 });
await page.waitForFunction('window.__ready === true', { timeout: 180000 });

const ff = spawn(FFMPEG, ['-y', '-f', 'image2pipe', '-framerate', String(FPS), '-c:v', 'png', '-i', '-',
  '-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-crf', '16', '-preset', 'slow', '-movflags', '+faststart', OUT]);
for (let i = Math.round(T0 * FPS); i < Math.round(T1 * FPS); i++) {
  await page.evaluate((t) => window.__renderAt(t), i / FPS);   // 그 시각 장면 · 샘플 다 쌓기
  const png = await page.screenshot({ type: 'png' });
  if (!ff.stdin.write(png)) await new Promise((r) => ff.stdin.once('drain', r));
}
ff.stdin.end();
await new Promise((r) => ff.on('close', r));
await browser.close();
```
- [ ] **헤드리스 크롬이 소프트웨어로 그려 한 장에 몇 분** — 기본 헤드리스는 GPU 를 안 쓸 수 있다. GPU 옵션을 주고 WEBGL_debug_renderer_info 로 실제 그래픽 카드 이름을 찍어 확인한다 (「도구와 기계의 원리」 렌더 스크립트도 시작할 때 GPU 이름을 찍는다).
- [ ] **그리기 직후 찍으면 지난 프레임이 찍힌다** — 화면 합성이 한 박자 늦다. __renderAt 끝에 requestAnimationFrame 두 번을 기다린다.
- [ ] **탭이 뒤에 있으면 requestAnimationFrame 이 멈춘다** — 보통 크롬에서 뒤로 간 탭은 rAF 가 거의 멈춰 굽기가 수십 배 느려진다(이 견본을 자동 시험할 때 겪음). 헤드리스로 굽거나 굽는 동안 탭을 앞에 둔다.
- [ ] **프레임마다 노이즈 무늬가 달라 일렁인다** — 노이즈 제거 후에도 남는 저주파 얼룩이 프레임마다 다르다. 샘플을 조금 더 쌓거나, 프레임 사이 시드를 고정 · 시간 방향으로 살짝 섞는다.

## 들어간 기술 (하나씩 자세히)
- [선반 회전체 + 장식 (윤곽선 하나로)](https://ai-techstudio.web.app/ai/t/i450.md) `i450`
- [스윕 · 로프트 (곡선 따라 단면 끌기)](https://ai-techstudio.web.app/ai/t/i449.md) `i449`
- [뚜껑 없는 단면 (뒷면 셰이딩)](https://ai-techstudio.web.app/ai/t/i85.md) `i85`
- [엑스레이 · 반투명 (프레넬)](https://ai-techstudio.web.app/ai/t/i42.md) `i42`
- [절차 마모 재질 (CG 티 없애기)](https://ai-techstudio.web.app/ai/t/i84.md) `i84`
- [기구학 연결 (크랭크 · 인벌류트 톱니 · 캠)](https://ai-techstudio.web.app/ai/t/i37.md) `i37`
- [3D 를 따라가는 이름표 · 지시선](https://ai-techstudio.web.app/ai/t/i38.md) `i38`
- [감독 투어 + 프레임 단위 녹화](https://ai-techstudio.web.app/ai/t/i89.md) `i89`
- [내레이션 동기화 (낱말 시각표)](https://ai-techstudio.web.app/ai/t/i81.md) `i81`
- [영상으로 굽기 (헤드리스 브라우저 → mp4)](https://ai-techstudio.web.app/ai/t/i82.md) `i82`
- [GPU 경로 추적 (사진 같은 렌더 · 샘플 쌓기)](https://ai-techstudio.web.app/ai/t/i545.md) `i545`
- [노이즈 제거 (적은 샘플 → 깨끗하게 · OIDN)](https://ai-techstudio.web.app/ai/t/i546.md) `i546`
- [경로 추적 영상 굽기 (프레임마다 수렴 → mp4)](https://ai-techstudio.web.app/ai/t/i547.md) `i547`
