# AI 꾸러미 — 색 사다리 (수명 따라 색) — HDR
> 흰 → 노랑 → 주황 → 빨강 → 검은 연기처럼 입자가 식어 가는 색 — HDR 로 핵은 하얗게 빛나게  
> 견본: https://ai-techstudio.web.app/#t/i177

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

## 주문서

### 만들어 줘: 색 사다리 (수명 따라 색) — HDR

#### 1. 목표
불에 「색 사다리 (수명 따라 색)」 기술을 깔끔한 기본 느낌으로 넣어 줘. — 흰 → 노랑 → 주황 → 빨강 → 검은 연기처럼 입자가 식어 가는 색 — HDR 로 핵은 하얗게 빛나게

#### 2. 핵심 기술 용어
- **HDR**

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

#### 4. 조건
- 한 프레임 16ms 안 (폰 포함) — 무거운 계산은 처음에 한 번 또는 여러 프레임에 나눠서
- 새 재질 · 셰이더는 첫 화면 전에 미리 컴파일 (renderer.compileAsync)
- 화면 가장자리 흐림 · 비네트로 얼버무리지 않기
- 외부 라이브러리 추가 없이 three.js 와 three/examples 만

#### 5. 완성 기준 (이게 보이면 성공)
- 견본처럼 「흰 → 노랑 → 주황 → 빨강 → 검은 연기처럼 입자가 식어 가는 색 — HDR 로 핵은 하얗게 빛나게」 이(가) 첫 화면에서 바로 보인다
- 켬/끔(또는 전/후) 비교 단추로 이 기술이 만드는 차이를 확인할 수 있다
- 중요한 값 1~3개를 슬라이더로 바꿔 볼 수 있다
- 콘솔 오류 0, 폰 폭(390px)에서도 동작한다

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

## 원리
- 흰 → 노랑 → 주황 → 빨강 → 검은 연기처럼 입자가 식어 가는 색 — HDR 로 핵은 하얗게 빛나게


## 흔한 실수 · 확인 목록
- (아직 정리된 것이 없어요)

## 완성 기준 체크리스트
- [ ] 견본처럼 「흰 → 노랑 → 주황 → 빨강 → 검은 연기처럼 입자가 식어 가는 색 — HDR 로 핵은 하얗게 빛나게」 이(가) 첫 화면에서 바로 보인다
- [ ] 켬/끔(또는 전/후) 비교 단추로 이 기술이 만드는 차이를 확인할 수 있다
- [ ] 중요한 값 1~3개를 슬라이더로 바꿔 볼 수 있다
- [ ] 콘솔 오류 0, 폰 폭(390px)에서도 동작한다

## 이 기술 정보
- id: `i177` · 분류: 이펙트 · 연출 › 스킬 VFX (마법 · 미사일) · 3D · 난이도 쉬움 · 폰 부담 보통 (폰 주의)
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i177
- 쓰면 좋을 때: 쓰면 좋을 곳 — 불 · 마법 · 폭발

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### ldr — `src/demos/demosSkillA.ts:443`
```ts
function ldr(r: Float32Array): Float32Array {
  const o = r.slice();
  for (let i = 0; i < RN; i++) {
    const m = Math.max(o[i * 5]!, o[i * 5 + 1]!, o[i * 5 + 2]!);
    if (m > 0.85) for (let q = 0; q < 3; q++) o[i * 5 + q] = (o[i * 5 + q]! / m) * 0.85;
  }
  return o;
}
```

### i177 견본 항목 — `src/demos/demosSkillA.ts:2154`
```ts
  i177: {
    kind: '3d',
    caption: '불꽃 입자가 수명 따라 흰 → 노랑 → 주황 → 빨강 → 연기로 식는다 (아래 막대 = 사다리)',
    make(): Scene3D {
      const S = makeStage({ cam: [0, 4.4, 7.2], look: [0, 1.5, 0] });
      const P = new Pool(1800, S.puff);
      P.mesh.renderOrder = 3;
      P.curlScale = 1.1;
      S.scene.add(P.mesh);
      // 화로
      const bowl = new THREE.Mesh(
        new THREE.LatheGeometry([new THREE.Vector2(0.0, 0.0), new THREE.Vector2(0.45, 0.0), new THREE.Vector2(0.5, 0.35), new THREE.Vector2(0.75, 0.55), new THREE.Vector2(0.9, 0.85), new THREE.Vector2(0.82, 0.88), new THREE.Vector2(0.0, 0.7)], 20),
        stoneMat(0x4a4e58),
      );
      S.scene.add(bowl);
      const coal = glowDisc(0xff7020, 3, 1.6);
      coal.mesh.position.y = 0.72;
      S.scene.add(coal.mesh);
      const gd = glowDisc(0xff8030, 1.4, 6);
      S.scene.add(gd.mesh);
      const pl = new THREE.PointLight(0xff8030, 16, 9, 1.4);
      pl.position.set(0, 1.4, 0);
      S.scene.add(pl);
      const LADDERS = [
        { name: '불: 흰 → 노랑 → 주황 → 빨강 → 연기', r: RAMP.fire, light: 0xff8030 },
        { name: '마법: 흰 → 연보라 → 보라 → 짙은 연기', r: RAMP.arcane, light: 0xa060ff },
        { name: '독: 흰 → 연두 → 초록 → 보랏빛 연기', r: RAMP.poison, light: 0x70ff40 },
        { name: '얼음: 흰 → 하늘 → 파랑 → 서리 김', r: RAMP.frostLadder, light: 0x60c0ff },
      ];
      const LDR = LADDERS.map((l) => ldr(l.r));
      const lab = S.hud.label('tl');
      const barC = document.createElement('canvas');
      barC.width = 512;
      barC.height = 72;
      const barTex = texFrom(barC, true);
      S.hud.picture(barTex, 512 / 72, 'bc', 0.13);
      const drawBar = (r: Float32Array): void => {
        const g = barC.getContext('2d')!;
        g.clearRect(0, 0, 512, 72);
        g.fillStyle = 'rgba(8,10,18,0.7)';
        g.beginPath();
        g.roundRect(0, 0, 512, 72, 14);
        g.fill();
        const x0 = 20;
        const w = 472;
        // 체크 무늬 (투명도가 보이게)
        for (let i = 0; i < 59; i++) {
          g.fillStyle = i % 2 ? '#2a2c34' : '#1a1c22';
          g.fillRect(x0 + i * 8, 10, 8, 14);
          g.fillStyle = i % 2 ? '#1a1c22' : '#2a2c34';
          g.fillRect(x0 + i * 8, 24, 8, 14);
        }
        for (let i = 0; i < w; i++) {
          const k = i / (w - 1);
          const f = k * (RN - 1);
          const j = Math.min(RN - 2, f | 0);
          const u = f - j;
          const o = j * 5;
          const c = [0, 1, 2].map((q) => lerp(r[o + q]!, r[o + 5 + q]!, u));
          const a = lerp(r[o + 3]!, r[o + 8]!, u);
          // HDR → 밝은 값은 하얗게 (네이티브 톤 매핑 흉내)
          const mx = Math.max(c[0]!, c[1]!, c[2]!);
          const wht = clamp((mx - 1) / 5, 0, 1);
          const col = c.map((v) => Math.pow(clamp(lerp(v / Math.max(mx, 1), 1, wht), 0, 1), 1 / 2.2) * 255);
          g.fillStyle = `rgba(${col[0]!|0},${col[1]!|0},${col[2]!|0},${clamp(a * 1.2, 0.08, 1)})`;
          g.fillRect(x0 + i, 10, 1.2, 28);
        }
        g.fillStyle = '#d8d4cc';
        g.font = `700 22px ${FONT}`;
        g.textBaseline = 'middle';
        g.textAlign = 'left';
        g.fillText('태어남', x0, 56);
        g.textAlign = 'right';
        g.fillText('사라짐', x0 + w, 56);
        g.textAlign = 'center';
        g.fillText('수명 →', x0 + w / 2, 56);
        barTex.needsUpdate = true;
      };
      let sel = -1;
      let hdr = true;
      let rate = 1;
      let shown = -2;
      let acc = 0;
      let accS = 0;
      const controls: Control[] = [
        ctrlRange('사다리 (-1 자동 · 0 불 · 1 마법 · 2 독 · 3 얼음)', -1, 3, 1, -1, (v) => (sel = v)),
        ctrlTog('HDR 핵 (하얗게 빛남)', true, (v) => {
          hdr = v;
          shown = -2;
        }),
        ctrlRange('입자 수 (배)', 0.3, 2, 0.1, 1, (v) => (rate = v)),
      ];
      return {
        scene: S.scene,
        camera: S.cam,
        controls,
        update(t, dt0) {
          const dt = Math.min(dt0, 0.05);
          const li = sel < 0 ? Math.floor(t / 4) % 4 : sel;
          const L = LADDERS[li]!;
          const rp = hdr ? L.r : LDR[li]!;
          if (shown !== li) {
            shown = li;
            drawBar(rp);
            pl.color.setHex(L.light);
            coal.set(3, L.light);
            gd.set(1.4, L.light);
          }
          lab.set(L.name + (hdr ? '' : ' (HDR 끔)'), '#' + L.light.toString(16).padStart(6, '0'));
          acc += dt * 90 * rate;
          while (acc >= 1) {
            acc--;
            const a = Math.random() * TAU;
            const r = Math.sqrt(Math.random()) * 0.55;
            P.spawn({ x: Math.cos(a) * r, y: 0.8, z: Math.sin(a) * r, vx: -Math.cos(a) * r * 0.6, vy: R(1.8, 2.8), vz: -Math.sin(a) * r * 0.6, life: R(1.1, 1.8), s0: R(0.22, 0.32), s1: R(0.55, 0.85), ramp: rp, br: 0.3, shape: 1, rot: R(0, TAU), rv: R(-1.5, 1.5), drag: 0.6, curl: 0.45 });
          }
          accS += dt * 22 * rate;
          while (accS >= 1) {
            accS--;
            const d = coneDir(0.5);
            const sp = R(2, 4);
            P.spawn({ x: R(-0.3, 0.3), y: 0.85, z: R(-0.3, 0.3), vx: d.x * sp, vy: d.y * sp, vz: d.z * sp, life: R(0.8, 1.4), s0: 0.06, s1: 0.02, ramp: rp, st: 0.04, shape: 2, drag: 0.6, curl: 0.8 });
          }
          pl.intensity = (hdr ? 16 : 9) * (0.85 + 0.15 * Math.sin(t * 19) * Math.sin(t * 7));
          P.step(dt);
          P.upload();
          S.tick(dt);
        },
        resize: (w, h) => S.resize(w, h),
        dispose: () => S.dispose(),
      };
    },
  }
```

## 관련 기술
- 이 효과를 만든 기술: [입자 방출기 (수명 곡선)](https://ai-techstudio.web.app/ai/t/i170.md) `i170`
