# AI 꾸러미 — 셰이더 미리 데우기 — Shader warm-up (pre-compile)
> 처음 보이는 효과의 셰이더가 그 순간 컴파일되며 화면이 멈칫하는 것을, 로딩 중에 미리 컴파일해 없앤다.  
> 견본: https://ai-techstudio.web.app/#t/i385

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

## 주문서

### 만들어 줘: 셰이더 미리 데우기 — Shader warm-up (pre-compile)

#### 1. 목표
첫 폭발 · 마법 효과가 처음 나올 때 멈칫하지 않도록, 판을 여는 동안 셰이더를 미리 컴파일해 줘. 화면은 로딩 바가 있는 시작 화면.

#### 2. 핵심 기술 용어
- **Shader warm-up (pre-compile)** — 셰이더 미리 데우기
- **WebGLRenderer.compileAsync** — three.js 비동기 컴파일 (r158+)
- **KHR_parallel_shader_compile** — 메인 스레드를 막지 않는 병렬 컴파일 확장
- **Frame hitch / stutter** — 프레임 멈칫

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

#### 4. 조건
- 데울 때의 조명 수 · 그림자 켬/끔 · 안개가 실제 게임 때와 같아야 한다 (다르면 다른 셰이더가 데워진다)
- compileAsync 는 보이는(visible) 물체만 컴파일하니, 숨겨 둔 효과는 데우는 동안만 visible = true
- 데우는 동안에도 화면(로딩 바)이 멈추지 않게 — await 로 기다리기
- 실제로 효과가 있는지 프레임 시간 그래프(첫 등장 프레임 ms)로 전/후를 재서 보여 주기

#### 5. 완성 기준 (이게 보이면 성공)
- 「미리 데우기 끔」에서는 첫 효과 순간 프레임 그래프에 큰 막대(수십 ms 이상)가 보인다
- 「켬」에서는 그 막대가 로딩 구간으로 옮겨 가고, 게임 중엔 모두 16ms 근처다
- 「진짜로 재기」 단추가 이 기기의 첫 그리기 · 두 번째 그리기 · 데운 뒤 첫 그리기 ms 를 보여 준다

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

## 원리
- 재질 종류 · 조명 수 · 그림자 · 안개 조합마다 GPU 프로그램(셰이더)이 하나씩 만들어진다.
- 처음 그 조합이 화면에 나오는 프레임에 컴파일이 일어나 수십~수백 ms 멈춘다 (윈도는 D3D 변환까지 더해져 더 길다).
- renderer.compileAsync(scene, camera) 는 장면의 보이는 물체 셰이더를 병렬로 컴파일하고 끝나면 Promise 가 풀린다.
- 로딩 화면 동안 앞으로 쓸 효과를 장면에 넣고 데운 뒤 숨기면, 게임 중에는 컴파일이 일어나지 않는다.

## 핵심 코드 — 로딩 중에 효과를 데우고, 전/후를 실제로 재기
(발췌: demos/demosPerf.ts measureShader() 를 정리)
```ts
/** 판을 여는 동안 — 앞으로 쓸 효과 메시를 잠깐 보이게 두고 비동기 컴파일 */
async function prewarm(renderer: THREE.WebGLRenderer, scene: THREE.Scene, camera: THREE.Camera, fx: THREE.Object3D[]) {
  const was = fx.map((o) => o.visible);
  fx.forEach((o) => { o.visible = true; scene.add(o); }); // compileAsync 는 보이는 것만 본다
  await renderer.compileAsync(scene, camera);              // 병렬 컴파일 — 로딩 바는 계속 움직인다
  fx.forEach((o, i) => (o.visible = was[i]!));
}

/** 정말 효과가 있는지 재기 — readPixels 로 GPU 를 기다려야 진짜 시간이 나온다 */
function timeRender(r: THREE.WebGLRenderer, scene: THREE.Scene, cam: THREE.Camera): number {
  const gl = r.getContext();
  const px = new Uint8Array(4);
  const t0 = performance.now();
  r.render(scene, cam);
  gl.readPixels(0, 0, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, px);
  return performance.now() - t0;
}

const cold = timeRender(renderer, scene, cam);  // 새 재질 첫 그리기: 수십~수백 ms
const warm = timeRender(renderer, scene, cam);  // 두 번째: 1ms 안팎
// 다른 새 재질로: await renderer.compileAsync(scene, cam) 뒤 첫 그리기도 1ms 안팎이면 성공
```

## 흔한 실수 · 확인 목록
- [ ] **반복문으로 잡음을 여러 번 부르는 셰이더는 윈도에서 컴파일이 17~20초** — 보물 동굴 게임에서 실제로 겪었다. 잡음은 미리 구운 텍스처로 읽고, 값은 uniform 으로 돌려 셰이더 종류를 하나로.
- [ ] **데울 때와 놀 때의 그림자 · 조명 설정이 다르면 헛일** — 그림자 켬/끔 · 빛 개수 · 안개가 셰이더 열쇠에 들어간다. 이 사이트 견본은 같은 설정으로 감싸서(withShadows) 데운다.
- [ ] **숨겨 둔(visible=false) 효과는 데워지지 않는다** — compile 은 traverseVisible 로 돈다. 데우는 동안만 보이게 했다가 되돌린다.
- [ ] **customProgramCacheKey 를 매번 다르게 주면 늘 새로 컴파일** — 같은 셰이더는 같은 열쇠를. 무작위 열쇠는 재기 실험에서만.
- [ ] **데우는 동안 오류 검사가 GPU 를 기다리게 한다** — 미리 굽는 동안만 renderer.debug.checkShaderErrors = false 로 두면 병렬 컴파일이 제대로 병렬이 된다.

## 완성 기준 체크리스트
- [ ] 「미리 데우기 끔」에서는 첫 효과 순간 프레임 그래프에 큰 막대(수십 ms 이상)가 보인다
- [ ] 「켬」에서는 그 막대가 로딩 구간으로 옮겨 가고, 게임 중엔 모두 16ms 근처다
- [ ] 「진짜로 재기」 단추가 이 기기의 첫 그리기 · 두 번째 그리기 · 데운 뒤 첫 그리기 ms 를 보여 준다

## 이 기술 정보
- id: `i385` · 분류: 게임 시스템 · AI › 속도 기법 · 3D · 난이도 보통 · 폰 부담 가벼움 (폰 OK) — 게임 중 비용은 없다. 로딩 시간만 조금 늘어난다 (병렬이라 화면은 멈추지 않음).
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i385
- 쓰면 좋을 때: 첫 폭발 · 첫 피격 · 첫 장면 전환에서 화면이 한 번 멈칫할 때 / 물리 재질(clearcoat · transmission)처럼 셰이더가 긴 재질을 쓸 때
- 쓰지 말 때: 재질이 몇 개뿐이고 첫 화면에 모두 보이는 단순한 장면 — 이미 첫 프레임에 컴파일된다

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### measureShader — `src/demos/demosPerf.ts:435`
```ts
async function measureShader(): Promise<ShaderMeasure> {
  const canvas = document.createElement('canvas');
  canvas.width = canvas.height = 128;
  const r = new THREE.WebGLRenderer({ canvas, antialias: false });
  r.setSize(128, 128, false);
  const gl = r.getContext();
  const px = new Uint8Array(4);
  const sync = (): void => gl.readPixels(0, 0, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, px);
  const scene = new THREE.Scene();
  scene.add(new THREE.HemisphereLight(0xffffff, 0x334455, 1));
  const sun = new THREE.DirectionalLight(0xffffff, 2);
  sun.position.set(2, 3, 2);
  scene.add(sun);
  const cam = new THREE.PerspectiveCamera(50, 1, 0.1, 10);
  cam.position.z = 3;
  const geo = new THREE.IcosahedronGeometry(1, 3);
  const mats: THREE.Material[] = [];
  const mk = (): THREE.MeshPhysicalMaterial => {
    const key = Math.random().toString(36).slice(2);
    const m = new THREE.MeshPhysicalMaterial({ color: 0xff8844, roughness: 0.3, clearcoat: 1, sheen: 1, iridescence: 1, transmission: 0.4, thickness: 0.5 });
    m.onBeforeCompile = (sh) => {
      sh.fragmentShader = `#define FX_${key} 1\n` + sh.fragmentShader;
    };
    m.customProgramCacheKey = () => key;
    mats.push(m);
    return m;
  };
  const base = new THREE.Mesh(geo, new THREE.MeshBasicMaterial());
  mats.push(base.material);
  scene.add(base);
  r.render(scene, cam);
  sync();
  scene.remove(base);
  const m1 = new THREE.Mesh(geo, mk());
  scene.add(m1);
  let t0 = now();
  r.render(scene, cam);
  sync();
  const cold = now() - t0;
  t0 = now();
  r.render(scene, cam);
  sync();
  const warm = now() - t0;
  scene.remove(m1);
  const m2 = new THREE.Mesh(geo, mk());
  scene.add(m2);
  t0 = now();
  await r.compileAsync(scene, cam);
  const pre = now() - t0;
  t0 = now();
  r.render(scene, cam);
  sync();
  const after = now() - t0;
  geo.dispose();
  for (const m of mats) m.dispose();
  r.dispose();
  r.forceContextLoss();
  return { cold, warm, pre, after };
}
```

### demo385 — `src/demos/demosPerf.ts:494`
```ts
function demo385() {
  let rec: ShaderMeasure = { cold: 180, warm: 0.6, pre: 190, after: 0.7 };
  let measured = false;
  let busyMsg = '';
  const CYC = 3.4;
  const LOAD = 0.75;
  const FX = 1.7;
  const lane = (g: G, y: number, kind: Kind, t: number): void => {
    panel(g, 6, y, 268, 66, kind);
    const bad = kind === 'bad';
    tag(g, 11, y + 8, bad ? '끔' : '켬', kind);
    txt(g, bad ? '효과가 처음 보이는 순간 컴파일' : '판을 열 때 화면 밖에서 한 번 그려 둠', 25, y + 8.3, 6.4, C.text, 'left', 700);
    const freeze = rec.cold / 1000;
    // 미니 장면
    const sx = 12;
    const sy = y + 16;
    const sw = 78;
    const sh = 45;
    rr(g, sx, sy, sw, sh, 4);
    g.fillStyle = '#0a0e1c';
    g.fill();
    g.save();
    rr(g, sx, sy, sw, sh, 4);
    g.clip();
    if (t < LOAD) {
      txt(g, '불러오는 중', sx + sw / 2, sy + sh / 2 - 6, 6, C.dim, 'center', 700);
      rr(g, sx + 12, sy + sh / 2 + 2, sw - 24, 4, 2);
      g.fillStyle = C.line;
      g.fill();
      rr(g, sx + 12, sy + sh / 2 + 2, (sw - 24) * clamp01(t / LOAD), 4, 2);
      g.fillStyle = bad ? C.blue : C.violet;
      g.fill();
      if (!bad) txt(g, '+ 효과 미리 데우기', sx + sw / 2, sy + sh / 2 + 13, 5.4, C.violet, 'center', 700);
    } else {
      // 멈춘 시간(옛 방식)만큼 장면 시계가 서 있다
      let st = t;
      const stalled = bad && t >= FX && t < FX + freeze;
      if (bad && t >= FX) st = t < FX + freeze ? FX : t - freeze;
      const k = clamp01((st - LOAD) / (FX - LOAD));
      g.fillStyle = '#1b2440';
      g.fillRect(sx, sy + sh - 8, sw, 8);
      if (st < FX) {
        const bx = sx + 10 + k * (sw - 26);
        const by = sy + sh - 12 - Math.sin(k * Math.PI) * 22;
        glowBall(g, bx, by, 3.4, C.amber);
      } else {
        const e = clamp01((st - FX) / 0.8);
        const cx = sx + sw - 16;
        const cy = sy + sh - 12;
        for (let i = 0; i < 14; i++) {
          const a = (i / 14) * Math.PI * 2;
          const rr2 = 3 + e * 16;
          g.globalAlpha = 1 - e;
          glowBall(g, cx + Math.cos(a) * rr2, cy + Math.sin(a) * rr2 * 0.7, 1.6, i % 2 ? '#ff9a4d' : '#ffe27a');
        }
        g.globalAlpha = 1;
        if (e < 0.5) glowBall(g, cx, cy, 6 * (1 - e * 2), '#fff3c4');
      }
      if (stalled || (bad && t >= FX && t < FX + freeze + 0.5)) {
        g.fillStyle = 'rgba(255,93,108,0.18)';
        g.fillRect(sx, sy, sw, sh);
        txt(g, '멈칫!', sx + sw / 2, sy + 10, 8, C.red, 'center', 900);
      }
    }
    g.restore();
    // 프레임 그래프 (1/30초 칸)
    const gx = 98;
    const gw = 170;
    const slot = gw / (CYC * 30);
    const ms: number[] = [];
    const n = Math.floor(t * 30);
    for (let i = 0; i <= n; i++) {
      const ti = i / 30;
      let v = 14 + ((i * 37) % 5);
      if (!bad && Math.abs(ti - LOAD * 0.5) < 1 / 60) v = rec.pre;
      if (bad && Math.abs(ti - FX) < 1 / 60) v = rec.cold + 16;
      ms.push(v);
    }
    frameBars(g, gx, y + 17, gw, 34, ms, 200, slot);
    if (!bad && t >= LOAD * 0.5) {
      const i = Math.round(LOAD * 0.5 * 30);
      const bh = Math.min(33, (34 * rec.pre) / 200);
      g.fillStyle = C.violet;
      g.fillRect(gx + i * slot + 0.3, y + 51 - bh, Math.max(0.6, slot - 0.6), bh);
    }
    // 불러오는 구간 그늘
    g.fillStyle = 'rgba(169,139,255,0.08)';
    g.fillRect(gx, y + 17, LOAD * 30 * slot, 34);
    txt(g, '불러오기', gx + 2, y + 21, 4.8, C.violet, 'left', 700);
    const spikeX = gx + (bad ? FX : LOAD * 0.5) * 30 * slot;
    if (t >= (bad ? FX : LOAD * 0.5)) {
      const v = bad ? rec.cold : rec.pre;
      txt(g, `${v.toFixed(0)}ms`, spikeX + 3, y + 22, 5.6, bad ? C.red : C.violet, 'left', 800);
    }
    txt(g, '프레임 시간', gx, y + 58, 5.2, C.dim, 'left', 600);
    txt(g, bad ? '놀이 중에 큰 막대 = 화면 멈춤' : '놀이 중엔 모두 16ms 근처 — 큰 막대는 로딩 화면 뒤에 숨음', 268, y + 58, 5.2, bad ? '#ffb0b8' : '#a9f2d2', 'right', 700);
    playhead(g, gx + t * 30 * slot, y + 17, y + 51);
  };
  return {
    draw(g: G, w: number, h: number, tt: number) {
      const t = tt % CYC;
      view(g, w, h, isBig(w) ? 194 : 175);
      txt(g, '첫 폭발 효과 = 새 재질 = 새 셰이더', 8, 10, 7.2, C.text, 'left', 800);
      pill(g, measured ? `실측 ${rec.cold.toFixed(0)}ms` : `기록 ${rec.cold.toFixed(0)}ms`, 272, 10, 6, measured ? '#1d6b52' : '#2b3560', measured ? '#bdf7df' : C.cyan, 'right');
      lane(g, 21, 'bad', t);
      lane(g, 103, 'good', t);
      if (busyMsg) {
        rr(g, 70, 78, 140, 22, 6);
        g.fillStyle = 'rgba(10,14,28,0.92)';
        g.fill();
        txt(g, busyMsg, 140, 89, 6.6, C.cyan, 'center', 800);
      }
      if (isBig(w)) resultStrip(g, measured ? `실측 — 첫 그리기 ${rec.cold.toFixed(1)}ms · 두 번째 ${rec.warm.toFixed(1)}ms · 미리 데우기(비동기, 화면 안 멈춤) ${rec.pre.toFixed(0)}ms 뒤 첫 그리기 ${rec.after.toFixed(1)}ms` : '「진짜로 재기」 단추 → 이 기기에서 실제로 잰 값이 여기에 나와요', measured);
      g.restore();
    },
    controls: [
      {
        type: 'button', label: '진짜로 재기 (새 셰이더 컴파일)', on: () => {
          if (busyMsg) return;
          busyMsg = '재는 중… (새 물리 재질)';
          setTimeout(() => {
            measureShader().then((m) => { rec = m; measured = true; busyMsg = ''; }).catch(() => { busyMsg = '잴 수 없음 (WebGL)'; setTimeout(() => (busyMsg = ''), 1500); });
          }, 60);
        },
      },
    ] as Control[],
  };
}
```

## 관련 기술
- 먼저 알면 좋은 기술: [후처리 사슬 (EffectComposer → OutputPass)](https://ai-techstudio.web.app/ai/t/u21.md) `u21`
- 다음에 해 볼 기술: [SDF 조각 모델링 (부드럽게 합치기)](https://ai-techstudio.web.app/ai/t/i446.md) `i446` · [쿼터뷰 던전 조명 (횃불 · 그림자 · 안개)](https://ai-techstudio.web.app/ai/t/i477.md) `i477`
- 참고 문서: [three.js 소스 — WebGLRenderer (compileAsync)](https://github.com/mrdoob/three.js/blob/dev/src/renderers/WebGLRenderer.js) · [Khronos — KHR_parallel_shader_compile](https://registry.khronos.org/webgl/extensions/KHR_parallel_shader_compile/)
