# AI 꾸러미 — 색 보정 LUT · 색수차 · 필름 결 — Color grading LUT (3D lookup table)
> 3D 색 표(LUT)로 화면 색을 한 번에 노을 · 밤 · 옛날 필름으로 바꾸고, 필름 결과 「쿵!」 순간 RGB 어긋남을 얹어 분위기와 충격을 준다.  
> 견본: https://ai-techstudio.web.app/#t/i07

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

## 주문서

### 만들어 줘: 색 보정 LUT · 색수차 · 필름 결 — Color grading LUT (3D lookup table)

#### 1. 목표
작은 마을 장면 전체에 색 보정 LUT 로 분위기를 바꾸고, 필름 결 · 충격 순간 색수차를 넣어 줘. 분위기는 노을.

#### 2. 핵심 기술 용어
- **Color grading LUT (3D lookup table)** — 색 보정 표 — 들어온 색 → 바꿀 색
- **LUTPass · Data3DTexture** — three.js 색 표 후처리 · 3D 텍스처
- **Chromatic aberration (RGBShiftShader)** — 색수차 — 빨 · 초 · 파가 어긋남
- **Film grain (FilmPass)** — 필름 결 — 잔 알갱이

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

#### 4. 조건
- LUT 는 출력(OutputPass) 뒤 sRGB 색에 건다 — 표를 sRGB 기준으로 만들었으면 순서를 맞추기
- 표는 Data3DTexture(RGBA · LinearFilter · ClampToEdge), 크기 24
- 색수차 양은 평소 0.004, 충격 순간만 hit 로 키우고 빠르게(exp) 줄이기
- 왼쪽 원본 · 오른쪽 보정 비교, 3초마다 분위기 바꾸기, 「필름 결」 0~1 슬라이더

#### 5. 완성 기준 (이게 보이면 성공)
- 오른쪽 화면이 3초마다 노을 → 밤 → 옛날 필름으로 바뀌고 왼쪽 원본은 그대로다
- 4.5초마다 「쿵!」 순간 화면이 흔들리며 가장자리 색이 빨 · 파로 갈라졌다가 금방 돌아온다
- 「필름 결」을 올리면 잔 알갱이가 보인다

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

## 원리
- LUT 는 빨 · 초 · 파 각각 24칸인 정육면체 표다 (24³ = 13,824칸). 칸마다 「이 색이 오면 이 색으로」를 적는다.
- 함수 하나(노을 = 빨강 올리고 파랑 낮추고 S 곡선)로 표를 채워 Data3DTexture 로 만든다.
- LUTPass 가 픽셀마다 표를 한 번 읽어(선형 보간) 색을 바꾼다 — 효과가 복잡해도 비용은 같다.
- 색수차: 빨 · 파를 반대쪽으로 조금(0.004) 밀고, 충격 순간 hit 값으로 0.022 더 민다. 필름 결은 잔 잡음을 더한다.
- 충격 값은 exp(−(시간) × 6) 로 빠르게 줄고, 같은 값으로 카메라도 살짝 흔든다.

## 핵심 코드 — 함수로 LUT 만들기 + 사슬
(발췌: demos/demosLight.ts makeLut() · LUTS · i07 make() 를 정리)
```ts
import { LUTPass } from 'three/examples/jsm/postprocessing/LUTPass.js';
import { FilmPass } from 'three/examples/jsm/postprocessing/FilmPass.js';
import { RGBShiftShader } from 'three/examples/jsm/shaders/RGBShiftShader.js';

function makeLut(fn: (r: number, g: number, b: number) => [number, number, number], N = 24) {
  const d = new Uint8Array(N * N * N * 4);
  for (let b = 0; b < N; b++) for (let g = 0; g < N; g++) for (let r = 0; r < N; r++) {
    const [R, G, B] = fn(r / (N - 1), g / (N - 1), b / (N - 1));
    const o = (r + g * N + b * N * N) * 4;
    d[o] = Math.round(Math.max(0, Math.min(1, R)) * 255);
    d[o + 1] = Math.round(Math.max(0, Math.min(1, G)) * 255);
    d[o + 2] = Math.round(Math.max(0, Math.min(1, B)) * 255);
    d[o + 3] = 255;
  }
  const t = new THREE.Data3DTexture(d, N, N, N);
  t.format = THREE.RGBAFormat;
  t.minFilter = t.magFilter = THREE.LinearFilter;
  t.wrapS = t.wrapT = t.wrapR = THREE.ClampToEdgeWrapping;
  t.unpackAlignment = 1;
  t.needsUpdate = true;
  return t;
}
const sCurve = (x: number, k = 0.6) => x + k * (x - 0.5) * (1 - Math.abs(2 * x - 1)) * 0.5 * 2 * 0.5;
const sunset = makeLut((r, g, b) => [sCurve(r * 1.08 + 0.07), sCurve(g * 0.9 + 0.03), sCurve(b * 0.7 + 0.02)]);

const comp = new EffectComposer(renderer);
comp.addPass(new RenderPass(scene, cam));
comp.addPass(new OutputPass());                      // sRGB 로 바꾼 뒤 색 표
const lut = new LUTPass({ lut: sunset, intensity: 1 });
const shift = new ShaderPass(RGBShiftShader);
const film = new FilmPass(0.35, false);
comp.addPass(lut); comp.addPass(shift); comp.addPass(film);

function frame(t: number, dt: number) {
  const ph = t % 4.5;
  const hit = ph > 3.6 ? Math.exp(-(ph - 3.6) * 6) : 0;  // 쿵! 순간 값
  shift.uniforms.amount.value = 0.004 + 0.022 * hit;
  shift.uniforms.angle.value = t * 2;
  comp.render(dt);
}
```

## 흔한 실수 · 확인 목록
- [ ] **LUT 를 선형 색 단계에 걸면 표와 색이 어긋난다** — 표를 sRGB 기준으로 만들었으면 OutputPass 뒤에 둔다 (견본 순서).
- [ ] **표 크기를 너무 작게(8 이하) 하면 그러데이션에 띠가 생긴다** — 24 정도면 선형 보간으로 매끈하다.
- [ ] **색수차 · 필름 결을 늘 켜 두면 글씨 · 숫자가 흐려진다** — 충격 순간에만 키우고 빠르게 줄인다. 평소는 아주 작게.
- [ ] **가장자리를 어둡게 하는 비네트를 섞으면 답답하다** — 사용자 결정으로 비네트 · 가장자리 흐림은 쓰지 않는다.

## 완성 기준 체크리스트
- [ ] 오른쪽 화면이 3초마다 노을 → 밤 → 옛날 필름으로 바뀌고 왼쪽 원본은 그대로다
- [ ] 4.5초마다 「쿵!」 순간 화면이 흔들리며 가장자리 색이 빨 · 파로 갈라졌다가 금방 돌아온다
- [ ] 「필름 결」을 올리면 잔 알갱이가 보인다

## 이 기술 정보
- id: `i07` · 분류: 그래픽 · 셰이더 › 후처리 · 3D · 난이도 보통 · 폰 부담 가벼움 (폰 OK) — LUT 는 픽셀마다 3D 텍스처 한 번 읽기. 색수차 · 결도 가벼운 화면 패스.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i07
- 쓰면 좋을 때: 게임마다 · 시간대마다 분위기를 한 번에 바꾸고 싶을 때 / 홈런 · 충돌처럼 짧은 충격 순간
- 쓰지 말 때: 색수차 · 필름 결을 늘 켜 두기 — 눈이 피곤하고 글씨가 흐려진다. 순간에만 / 색이 정답인 게임(색 맞추기) — LUT 가 색을 바꿔 버린다

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### makeLut — `src/demos/demosLight.ts:542`
```ts
function makeLut(fn: (r: number, g: number, b: number) => [number, number, number], N = 24): THREE.Data3DTexture {
  const d = new Uint8Array(N * N * N * 4);
  for (let b = 0; b < N; b++)
    for (let g = 0; g < N; g++)
      for (let r = 0; r < N; r++) {
        const [R, G, B] = fn(r / (N - 1), g / (N - 1), b / (N - 1));
        const o = (r + g * N + b * N * N) * 4;
        d[o] = Math.round(Math.max(0, Math.min(1, R)) * 255);
        d[o + 1] = Math.round(Math.max(0, Math.min(1, G)) * 255);
        d[o + 2] = Math.round(Math.max(0, Math.min(1, B)) * 255);
        d[o + 3] = 255;
      }
  const t = new THREE.Data3DTexture(d, N, N, N);
  t.format = THREE.RGBAFormat;
  t.type = THREE.UnsignedByteType;
  t.minFilter = THREE.LinearFilter;
  t.magFilter = THREE.LinearFilter;
  t.wrapS = t.wrapT = t.wrapR = THREE.ClampToEdgeWrapping;
  t.unpackAlignment = 1;
  t.needsUpdate = true;
  return t;
}
```

### i07 견본 항목 — `src/demos/demosLight.ts:2148`
```ts
  i07: {
    kind: '3d',
    caption: '왼쪽 원본 · 오른쪽 색 보정 표(LUT)로 노을 / 밤 / 옛날 필름 + 필름 결, 「쿵!」 순간엔 RGB 가 어긋나요',
    make() {
      const scene = new THREE.Scene();
      scene.background = gradTex('#8fd0ff', '#e6f6ff');
      basicLights(scene, 1.0, 1.8, V3(-3, 5, 3));
      const ground = mesh(new THREE.CylinderGeometry(2.0, 2.0, 0.15, 48), std(0x8fd46a, 0.9), 0, -0.075, 0);
      const house = new THREE.Group();
      house.add(mesh(new THREE.BoxGeometry(0.8, 0.7, 0.7), std(0xfff1d6, 0.7), 0, 0.35, 0));
      const roof = mesh(new THREE.ConeGeometry(0.66, 0.5, 4), std(0xe8453c, 0.6), 0, 0.95, 0);
      roof.rotation.y = Math.PI / 4;
      house.add(roof);
      house.add(mesh(new THREE.BoxGeometry(0.2, 0.32, 0.02), std(0x8a5530), 0, 0.16, 0.36));
      house.position.set(-0.85, 0, -0.5);
      const tree = new THREE.Group();
      tree.add(mesh(new THREE.CylinderGeometry(0.07, 0.09, 0.5, 10), std(0x8a5530), 0, 0.25, 0));
      tree.add(mesh(new THREE.SphereGeometry(0.38, 24, 16), std(0x3ec46d, 0.7), 0, 0.75, 0));
      tree.position.set(1.0, 0, -0.6);
      const ch = cutie(0x4aa3ff);
      ch.scale.setScalar(0.7);
      ch.position.set(0.2, 0, 0.5);
      const ball = mesh(new THREE.SphereGeometry(0.16, 24, 16), std(0xffd23f, 0.4), 0.9, 0.16, 0.6);
      scene.add(ground, house, tree, ch, ball);
      const cam = new THREE.PerspectiveCamera(36, 1, 0.1, 30);
      const luts = LUTS.map((l) => makeLut(l.fn));
      let grain = 0.35;
      const kit = postKit((r) => {
        const comp = new EffectComposer(r);
        comp.setPixelRatio(1);
        comp.addPass(new RenderPass(scene, cam));
        comp.addPass(new OutputPass());
        const save = new SavePass();
        comp.addPass(save);
        const lut = new LUTPass({ lut: luts[0]!, intensity: 1 });
        const shift = new ShaderPass(RGBShiftShader);
        const film = new FilmPass(grain, false);
        comp.addPass(lut);
        comp.addPass(shift);
        comp.addPass(film);
        comp.addPass(splitPass(save.renderTarget.texture));
        return { comp, lut, shift, film };
      });
      const hud = new Hud();
      hud.vline(0.5);
      hud.label('원본', 0.25, 0.95, 0.5, 1);
      const mood = hud.label(LUTS[0]!.name, 0.75, 0.95, 0.5, 1, 'on');
      const pop = hud.label('쿵!', 0.75, 0.55, 0.5, 0.5, 'pop', 1.6);
      let dt = 0.016;
      let mi = 0;
      let hit = 0;
      let now = 0;
      return {
        scene,
        camera: cam,
        update(t, d) {
          dt = d;
          now = t;
          mi = Math.floor(t / 3) % LUTS.length;
          mood.set(LUTS[mi]!.name);
          const ph = t % 4.5;
          hit = ph > 3.6 ? Math.exp(-(ph - 3.6) * 6) : 0;
          pop.visible = hit > 0.3;
          ch.position.y = Math.abs(Math.sin(t * 2.4)) * 0.18;
          ch.rotation.y = Math.sin(t * 0.8) * 0.8;
          ball.position.x = 0.9 + Math.sin(t * 1.3) * 0.25;
        },
        render(r, w, h) {
          const a = Math.sin(now * 0.3) * 0.4;
          fit(cam, w / h, V3(0, 0.45, 0), 2.1, V3(Math.sin(a), 0.6, Math.cos(a)));
          cam.position.x += (Math.random() - 0.5) * 0.12 * hit;
          cam.position.y += (Math.random() - 0.5) * 0.12 * hit;
          const k = kit.get(r, w, h);
          k.lut.lut = luts[mi]!;
          (k.shift.uniforms['amount']!.value as number) = 0.004 + 0.022 * hit;
          k.shift.uniforms['angle']!.value = now * 2;
          (k.film.uniforms as Record<string, { value: number }>)['intensity']!.value = grain;
          k.comp.render(dt);
          hud.draw(r, w, h);
        },
        controls: [{ type: 'range', label: '필름 결', min: 0, max: 1, step: 0.05, value: 0.35, on: (v) => (grain = v) }],
        dispose() {
          kit.dispose();
          for (const l of luts) l.dispose();
          hud.dispose();
          disposeTree(scene);
        },
      };
    },
  }
```

## 관련 기술
- 같은 방법으로 만든 효과 예시: [맞는 순간 색수차 맥박](https://ai-techstudio.web.app/ai/t/i203.md) `i203`
- 먼저 알면 좋은 기술: [후처리 사슬 (EffectComposer → OutputPass)](https://ai-techstudio.web.app/ai/t/u21.md) `u21`
- 다음에 해 볼 기술: [잔상 · 움직임 흐림 (Afterimage)](https://ai-techstudio.web.app/ai/t/i05.md) `i05` · [빛살 (God rays)](https://ai-techstudio.web.app/ai/t/i06.md) `i06`
- 참고 문서: [three.js 예제 — webgl_postprocessing_3dlut](https://threejs.org/examples/#webgl_postprocessing_3dlut) · [three.js 소스 — LUTPass](https://github.com/mrdoob/three.js/blob/dev/examples/jsm/postprocessing/LUTPass.js)
