# AI 꾸러미 — 노멀 맵 스프라이트 조명 — Normal-mapped sprite lighting
> 평평한 그림 한 장에 높이 지도를 더해 화소마다 법선을 구하고, 움직이는 횃불 빛으로 음영 · 반사를 계산해 2D 그림이 입체로 보이게 한다.  
> 견본: https://ai-techstudio.web.app/#t/i234

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

## 주문서

### 만들어 줘: 노멀 맵 스프라이트 조명 — Normal-mapped sprite lighting

#### 1. 목표
돌벽 · 방패 · 금화가 있는 2D 배경에 노멀 맵 조명을 넣어 줘 — 색 그림 + 높이 지도로 화소마다 법선을 구하고, 움직이는 점광원으로 음영 · 반짝임을 계산한다. 분위기는 일렁이는 횃불.

#### 2. 핵심 기술 용어
- **Normal-mapped sprite lighting** — 노멀 맵 스프라이트 조명
- **Height map → normal (central differences)** — 높이 지도 이웃 차이로 법선 만들기
- **Point light attenuation 1/(1 + k·d²)** — 거리 따라 약해지는 점광원
- **Blinn-Phong specular** — 반쯤 벡터로 반짝임

#### 3. 환경
- 플랫폼: three.js r186 (ES 모듈 · TypeScript, `import * as THREE from "three"`), WebGL2, 외부 라이브러리 추가 없이
- 화면: 2D · 브라우저 — PC 와 폰(가로 844×390 · 세로 390×844) 모두, 60fps 목표
- three.js 는 판 하나에 ShaderMaterial 로 (견본 방식). MeshStandardMaterial + normalMap + PointLight 로도 된다.

#### 4. 조건
- 법선은 이웃 높이 차(중앙 차분)로 — 간격은 텍스처 화소 1.5칸
- 높이 지도는 sRGB 변환 없이(선형) 읽기
- 요철 세기 · 빛 높이를 조절 가능하게
- 셰이더 안에 잡음 반복문 금지 — 높이는 미리 그린 텍스처로
- 화면을 나눠 「그림만(평면 법선)」과 비교할 수 있게

#### 5. 완성 기준 (이게 보이면 성공)
- 빛이 움직이면 돌벽 틈 · 방패 테 · 숫자 7 의 빛 받는 쪽과 그늘이 바뀐다
- 나눠 비교: 왼쪽 평면 조명은 납작, 오른쪽은 입체
- 요철 세기 0 이면 납작해지고 16 이면 깊게 파인다
- 금속 부분만 반짝임이 좁고 세다

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

## 원리
- 색 그림(알베도)과 같은 크기의 높이 지도(흰 = 높음)를 그린다. 견본은 둘 다 캔버스로 — 방패 둥근 면은 방사 그러데이션, 숫자 7 은 흐림을 줄여 가며 겹쳐 둥근 턱.
- 화소마다 좌우 · 위아래 이웃 높이 차로 법선 N = normalize((hl − hr)·세기, (hd − hu)·세기, 1). 세기 7.
- 빛 L 은 화소에서 점광원으로 가는 방향. 밝기 = max(N·L, 0) × 감쇠 1/(1 + 3.5·d²).
- 금속(색이 빨강 > 파랑인 곳)은 반짝임을 세게 · 좁게: pow(N·H, 18~60) × 0.15~1.6. 빛 자체 둘레엔 빛무리를 더한다.

## 핵심 코드 — 높이 지도 → 법선 → 점광원 음영 · 반짝임
(발췌: demos/demosLook2.ts makeNormalSprite() 프래그먼트 셰이더를 정리)
```glsl
uniform sampler2D uH, uA;  // 높이 지도 · 색 그림
uniform vec3 uLight;         // 빛 위치 (x 0~1.6, y 0~1, z 높이 0.14)
uniform float uStr;          // 요철 세기 (7)
uniform float uFl;           // 일렁임 0.9 + 0.1·sin(17t)·sin(7.3t)
varying vec2 vUv;
void main() {
  vec2 e = vec2(1.5 / 640.0, 1.5 / 400.0);
  float h  = texture2D(uH, vUv).r;
  float hl = texture2D(uH, vUv - vec2(e.x, 0.0)).r, hr = texture2D(uH, vUv + vec2(e.x, 0.0)).r;
  float hd = texture2D(uH, vUv - vec2(0.0, e.y)).r, hu = texture2D(uH, vUv + vec2(0.0, e.y)).r;
  vec3 N = normalize(vec3((hl - hr) * uStr, (hd - hu) * uStr, 1.0));
  vec3 alb = texture2D(uA, vUv).rgb;
  vec3 P = vec3(vUv.x * 1.6, vUv.y, h * 0.05);
  vec3 Lv = uLight - P; float d = length(Lv); vec3 L = Lv / d;
  float diff = max(dot(N, L), 0.0);
  float metal = smoothstep(0.05, 0.25, alb.r - alb.b);       // 금빛일수록 금속
  vec3 Hh = normalize(L + vec3(0.0, 0.0, 1.0));
  float spec = pow(max(dot(N, Hh), 0.0), mix(18.0, 60.0, metal)) * mix(0.15, 1.6, metal);
  float att = 1.0 / (1.0 + d * d * 3.5);
  vec3 lc = vec3(1.0, 0.66, 0.32) * uFl;                      // 횃불 색
  vec3 col = alb * (vec3(0.025, 0.03, 0.05) + diff * lc * att * 3.2) + lc * spec * att * 2.0;
  float g = length(P.xy - uLight.xy);
  col += lc * (exp(-g * 22.0) * 0.9 + exp(-g * 6.0) * 0.08); // 빛 둘레 빛무리
  gl_FragColor = vec4(col, 1.0);
}
```

## 흔한 실수 · 확인 목록
- [ ] **높이 지도를 sRGB 색 텍스처로 읽으면 요철이 한쪽으로 치우친다** — 높이 텍스처는 색 공간 변환 없이(NoColorSpace) 읽는다. 견본도 canvasTex(hc, false) 로 따로 만든다.
- [ ] **이웃 간격을 1화소로 두면 잡티까지 법선이 튄다** — 1.5화소 간격 + 높이 지도를 살짝 흐리게(blur 2px) 그려 둔다.
- [ ] **그림 비율과 판 비율이 다르면 빛 자리와 음영이 어긋난다** — 빛 좌표와 화소 위치 P 를 같은 비율(가로 1.6 : 세로 1)로 계산한다.
- [ ] **셰이더에서 잡음으로 요철을 만들면 컴파일이 멈춘다** — 요철은 미리 그린 높이 텍스처로. 반복문 잡음 셰이더는 윈도에서 17~20초 멈췄다.

## 완성 기준 체크리스트
- [ ] 빛이 움직이면 돌벽 틈 · 방패 테 · 숫자 7 의 빛 받는 쪽과 그늘이 바뀐다
- [ ] 나눠 비교: 왼쪽 평면 조명은 납작, 오른쪽은 입체
- [ ] 요철 세기 0 이면 납작해지고 16 이면 깊게 파인다
- [ ] 금속 부분만 반짝임이 좁고 세다

## 이 기술 정보
- id: `i234` · 분류: 2D · 화면 › 2D 그림 효과 · 2D · 난이도 보통 · 폰 부담 가벼움 (폰 OK) — 화소마다 텍스처 5번 읽기 + 몇 줄 계산. 화면 하나 크기 판은 폰도 가볍다.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i234
- 쓰면 좋을 때: 2D 그림에 움직이는 빛을 비춰 입체감을 줄 때 / 어둠 속 횃불로 벽의 요철 · 새긴 숫자를 드러낼 때
- 쓰지 말 때: 빛이 움직이지 않는 장면 — 음영을 그림에 미리 그려 두는 게 낫다 / 높이 지도를 만들 수 없는 그림 — 대신 2D 빛 지도 곱하기(u19)

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### makeNormalSprite — `src/demos/demosLook2.ts:2671`
```ts
function makeNormalSprite(): Scene3D {
  const { h: hTex, a: aTex } = paintRelief();
  const uni = {
    uH: { value: hTex },
    uA: { value: aTex },
    uRes: { value: new THREE.Vector2(1, 1) },
    uLight: { value: new THREE.Vector3(0.8, 0.5, 0.14) },
    uStr: { value: 7 },
    uSplit: { value: 0 },
    uFl: { value: 1 },
  };
  const q = quad(
    /* glsl */ `
    uniform sampler2D uH, uA; uniform vec2 uRes; uniform vec3 uLight; uniform float uStr, uSplit, uFl;
    varying vec2 vUv;
    void main(){
      float sa = uRes.x / uRes.y; vec2 uv = vUv;
      if (sa > 1.6) uv.y = (uv.y - 0.5) * 1.6 / sa + 0.5; else uv.x = (uv.x - 0.5) * sa / 1.6 + 0.5;
      vec2 e = vec2(1.5 / 640.0, 1.5 / 400.0);
      float h = texture2D(uH, uv).r;
      float hl = texture2D(uH, uv - vec2(e.x, 0.0)).r, hr = texture2D(uH, uv + vec2(e.x, 0.0)).r;
      float hd = texture2D(uH, uv - vec2(0.0, e.y)).r, hu = texture2D(uH, uv + vec2(0.0, e.y)).r;
      vec3 N = normalize(vec3((hl - hr) * uStr, (hd - hu) * uStr, 1.0));
      bool flat_ = gl_FragCoord.x < uSplit;
      if (flat_) N = vec3(0.0, 0.0, 1.0);
      vec3 alb = texture2D(uA, uv).rgb;
      vec3 P = vec3(uv.x * 1.6, uv.y, h * 0.05);
      vec3 Lv = uLight - P; float d = length(Lv); vec3 L = Lv / d;
      float diff = max(dot(N, L), 0.0);
      float metal = smoothstep(0.05, 0.25, alb.r - alb.b);
      vec3 Hh = normalize(L + vec3(0.0, 0.0, 1.0));
      float spec = pow(max(dot(N, Hh), 0.0), mix(18.0, 60.0, metal)) * mix(0.15, 1.6, metal);
      float att = 1.0 / (1.0 + d * d * 3.5);
      vec3 lc = vec3(1.0, 0.66, 0.32) * uFl;
      vec3 col = alb * (vec3(0.025, 0.03, 0.05) + diff * lc * att * 3.2) + lc * spec * att * 2.0;
      float g = length(P.xy - uLight.xy);
      col += lc * (exp(-g * 22.0) * 0.9 + exp(-g * 6.0) * 0.08);
      gl_FragColor = vec4(col, 1.0);
      ${OUT}
    }`,
    uni,
  );
  const hud = new Hud();
  const a = hud.add('그림만 (평면 조명)');
  const b = hud.add('노멀 맵 조명', 'rgba(150,80,20,0.8)');
  hud.at(a, 0.03, 0.05, 0, 0);
  hud.at(b, 0.97, 0.05, 1, 0);
  let compare = true;
  let lz = 0.14;
  let time = 0;
  return {
    scene: q.scene,
    camera: q.cam,
    update(t) {
      time = t;
      uni.uLight.value.set(0.8 + Math.sin(t * 0.6) * 0.62, 0.5 + Math.sin(t * 1.05 + 1) * 0.36, lz);
      uni.uFl.value = 0.9 + 0.1 * Math.sin(t * 17) * Math.sin(t * 7.3);
    },
    render(r, w, h) {
      uni.uRes.value.set(w, h);
      const s = compare ? (0.5 + Math.sin(time * 0.35) * 0.15) * w : -1;
      uni.uSplit.value = s;
      r.render(q.scene, q.cam);
      if (compare) {
        drawDivider(r, s, h, w);
        hud.at(a, 0.03, 0.05, 0, 0);
      } else hud.hide(a);
      hud.draw(r, w, h);
    },
    controls: [
      { type: 'range', label: '요철 세기', min: 0, max: 16, step: 0.5, value: 7, on: (v) => (uni.uStr.value = v) },
      { type: 'range', label: '횃불 높이', min: 0.04, max: 0.5, step: 0.01, value: 0.14, on: (v) => (lz = v) },
      { type: 'toggle', label: '나눠 비교', value: true, on: (v) => (compare = v) },
    ],
    dispose() {
      q.dispose();
      hTex.dispose();
      aTex.dispose();
      hud.dispose();
    },
  };
}
```

### i234 견본 항목 — `src/demos/demosLook2.ts:4518`
```ts
  i234: { kind: '3d', caption: '그림 한 장 + 높이 지도 — 횃불이 움직이면 돌벽 · 방패 · 숫자 7에 입체 음영 (왼쪽은 그림만)', make: () => makeNormalSprite() }
```

## 관련 기술
- 먼저 알면 좋은 기술: [2D 조명 흉내 (Light2D)](https://ai-techstudio.web.app/ai/t/u19.md) `u19` · [그린 요철 · 노멀 맵](https://ai-techstudio.web.app/ai/t/u10.md) `u10`
- 다음에 해 볼 기술: [2D 시야 다각형 (빛 그림자)](https://ai-techstudio.web.app/ai/t/i235.md) `i235`
- 참고 문서: [Wikipedia — Normal mapping](https://en.wikipedia.org/wiki/Normal_mapping) · [Wikipedia — Blinn–Phong reflection model](https://en.wikipedia.org/wiki/Blinn%E2%80%93Phong_reflection_model)
