# AI 꾸러미 — 쿼터뷰 던전 조명 (횃불 · 그림자 · 안개) — Isometric / three-quarter view camera
> 내려다보는 카메라와 아주 어두운 바탕 위에 일렁이는 횃불 점광원 · 그림자 하나 · 같은 색 안개로, 어둠 속 빛이 주인공인 던전을 만든다.  
> 견본: https://ai-techstudio.web.app/#t/i477

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

## 주문서

### 만들어 줘: 쿼터뷰 던전 조명 (횃불 · 그림자 · 안개) — Isometric / three-quarter view camera

#### 1. 목표
돌바닥 · 벽돌 벽 던전 방을 쿼터뷰 던전 조명으로 꾸며 줘 — 어둠은 아주 어둡게, 횃불 점광원만 따뜻하게 일렁이고, 그림자는 주인공 횃불 하나. 분위기는 어둡고 묵직한 실사풍.

#### 2. 핵심 기술 용어
- **Isometric / three-quarter view camera** — 쿼터뷰 — 비스듬히 내려다보는 좁은 화각 카메라
- **Point light shadows (cube shadow map)** — 점광원 그림자 — 6방향으로 그리는 그림자 지도
- **Light flicker** — 횃불 일렁임 — 사인 몇 개를 섞은 세기 변화
- **Distance fog · PBR textures** — 배경색과 같은 안개 · 실사 재질(색 · 법선 · AO/거칠기/금속)

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

#### 4. 조건
- 그림자를 켜는 점광원은 하나만 (나머지는 castShadow = false)
- 안개 색 = 배경색, 반구광은 0.2 안팎으로 어둡게
- 첫 화면 전에 renderer.compileAsync 로 셰이더를 미리 굽고, 그동안 「준비 중」 표시
- 실사 텍스처는 1k, 색 지도는 SRGBColorSpace, 법선 지도는 OpenGL(_gl) 방식
- 가장자리 흐림 · 비네트로 어둠을 흉내 내지 않기 — 빛 배치로 만든다

#### 5. 완성 기준 (이게 보이면 성공)
- 횃불 주변만 따뜻하게 밝고 일렁이며, 그 밖은 거의 검게 묻힌다
- 주인공이 걸으면 주인공 횃불 그림자가 벽 · 기둥에 움직인다
- 「그림자」 켬/끔 · 「횃불 세기」 · 「안개 거리」 조절로 차이를 볼 수 있다
- 처음 열 때 멈칫 없이 「준비 중」 → 장면으로 넘어간다

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

## 원리
- 바탕 빛(반구광)은 0.2 정도로 아주 어둡게 두고, 따뜻한 점광원(횃불)이 화면의 밝은 곳을 정한다.
- 점광원 그림자는 큐브 6면을 그리므로 비싸다 — 그림자는 주인공 횃불 하나만, 나머지 횃불은 그림자 없이.
- 횃불 세기에 빠르기가 다른 사인 3개를 섞으면 불규칙하게 일렁인다.
- 안개 색을 배경색과 같게 하면 먼 곳이 어둠에 자연스럽게 묻힌다.
- 좁은 화각(30°) 카메라를 고정 오프셋으로 주인공 뒤에 두면 쿼터뷰가 된다.

## 핵심 코드 — 어두운 바탕 + 일렁이는 횃불 3개 (그림자는 하나) + 안개 + 쿼터뷰 카메라
(발췌: demos/demosRender.ts makeDungeon() 의 빛 · 카메라 부분)
```ts
const BG = 0x040305;
scene.background = new THREE.Color(BG);
scene.fog = new THREE.Fog(BG, 20, 40);                     // 안개 색 = 배경색 → 먼 곳이 어둠에 묻힘
scene.add(new THREE.HemisphereLight(0x33405e, 0x0a0605, 0.2)); // 바탕 빛은 아주 어둡게
const moon = new THREE.DirectionalLight(0x6c88c8, 0.42);
moon.position.set(-4, 10, 3);
scene.add(moon);

const BASE = [16, 16, 30];                                  // 벽 횃불 둘 · 주인공 횃불
const torches = BASE.map((b, i) => {
  const l = new THREE.PointLight(i === 2 ? 0xffa64e : 0xff8a3a, b, i === 2 ? 14 : 12, 2);
  scene.add(l);
  return l;
});
const hero = torches[2]!;
hero.castShadow = true;                                     // 그림자는 이 하나만 (점광원 그림자 = 6면)
hero.shadow.mapSize.set(512, 512);
hero.shadow.camera.near = 0.1;
hero.shadow.camera.far = 14;
hero.shadow.bias = -0.004;
hero.shadow.normalBias = 0.03;
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFShadowMap;

const flick = (t: number, ph: number) => 0.84 + 0.08 * Math.sin(t * 11 + ph) + 0.05 * Math.sin(t * 23.7 + ph * 1.7) + 0.03 * Math.sin(t * 5.3 + ph * 0.3);
const cam = new THREE.PerspectiveCamera(30, innerWidth / innerHeight, 0.5, 80); // 좁은 화각 = 쿼터뷰
const OFF = new THREE.Vector3(8.6, 12.4, 9.8);

await renderer.compileAsync(scene, cam);                    // 첫 그리기 멈칫 막기
function update(t: number, heroPos: THREE.Vector3) {
  torches.forEach((l, i) => (l.intensity = BASE[i]! * flick(t, i * 2.1)));
  cam.position.copy(heroPos).add(OFF);
  cam.lookAt(heroPos);
}
```

## 흔한 실수 · 확인 목록
- [ ] **그림자 켠 점광원을 여러 개 두면 폰에서 프레임이 무너진다** — 점광원 그림자 하나 = 장면을 6번 더 그린다. 그림자는 주인공 횃불 하나, 나머지는 빛만.
- [ ] **첫 장면에서 몇 초 멈칫** — 그림자 · 실사 재질 셰이더가 처음 그릴 때 컴파일된다. compileAsync 로 미리 굽고 그동안 「셰이더 굽는 중」을 보여 준다.
- [ ] **실사 텍스처 여러 장을 한 프레임에 올리면 멈칫** — GPU 업로드(renderer.initTexture)를 프레임당 3ms 예산 안에서 나눠 올린다. 무거운 장면 짓기도 같은 3ms 예산으로.
- [ ] **바닥에 줄무늬 그림자 얼룩 (shadow acne)** — shadow.bias 를 조금 음수로, normalBias 0.02~0.04.
- [ ] **어둠을 비네트 · 가장자리 흐림으로 만들면 답답하고 흐려 보인다** — 이 사이트에서 금지한 효과. 어둠은 빛 배치 · 안개로 만든다.

## 완성 기준 체크리스트
- [ ] 횃불 주변만 따뜻하게 밝고 일렁이며, 그 밖은 거의 검게 묻힌다
- [ ] 주인공이 걸으면 주인공 횃불 그림자가 벽 · 기둥에 움직인다
- [ ] 「그림자」 켬/끔 · 「횃불 세기」 · 「안개 거리」 조절로 차이를 볼 수 있다
- [ ] 처음 열 때 멈칫 없이 「준비 중」 → 장면으로 넘어간다

## 이 기술 정보
- id: `i477` · 분류: 3D 모델 · 캐릭터 › 하드서피스 · 실사 렌더링 · 3D · 난이도 어려움 · 폰 부담 무거움 — 그림자 켠 점광원 하나 = 그림자 지도 6장. 실사 텍스처는 1k 로 · GPU 업로드는 나눠서. 폰에선 그림자 지도 512.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i477
- 쓰면 좋을 때: 던전 · 동굴 · 밤 장면처럼 어둠과 빛의 대비가 분위기의 핵심일 때 / 쿼터뷰 액션 · 탐험 게임
- 쓰지 말 때: 밝은 낮 장면 — 반구광 · 해 · 환경 반사 틀이 맞다 / 점광원 그림자를 여러 개 켜야 하는 장면 (폰에서 무겁다)

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### makeDungeon — `src/demos/demosRender.ts:1320`
```ts
function makeDungeon(T: typeof THREE): Scene3D {
  const BG = 0x040305;
  const scene = new T.Scene();
  scene.background = new T.Color(BG);
  const fog = new T.Fog(BG, 20, 40);
  scene.fog = fog;
  const cam = new T.PerspectiveCamera(30, 1.6, 0.5, 80);
  const hud = new Hud();
  const msg = hud.label('던전 재질 받는 중 …', 0.5, 0.5, 0.5, 0.5, 'tag', 0.8);
  const note = hud.label('횃불 3개 · 그림자는 영웅 횃불 하나', 0.02, 0.03, 0, 0, 'off', 0.55);
  note.maxW = 0.7;
  note.visible = false;
  const world = new T.Group();
  scene.add(world);
  const geos: THREE.BufferGeometry[] = [];
  const G = <X extends THREE.BufferGeometry>(x: X): X => (geos.push(x), x);

  // 빛 — 어둠은 아주 어둡게, 횃불만 따뜻하게
  scene.add(new T.HemisphereLight(0x33405e, 0x0a0605, 0.2));
  const moon = new T.DirectionalLight(0x6c88c8, 0.42);
  moon.position.set(-4, 10, 3);
  scene.add(moon);
  const BASE = [16, 16, 30];
  const lights = BASE.map((b, i) => {
    const l = new T.PointLight(i === 2 ? 0xffa64e : 0xff8a3a, b, i === 2 ? 14 : 12, 2);
    scene.add(l);
    return l;
  });
  const hero = lights[2]!;
  hero.castShadow = true;
  hero.shadow.mapSize.set(512, 512);
  hero.shadow.camera.near = 0.1;
  hero.shadow.camera.far = 14;
  hero.shadow.bias = -0.004;
  hero.shadow.normalBias = 0.03;

  // 불꽃 · 불티 · 먼지 · 빛 무리
  const fp = [new T.Vector3(), new T.Vector3(), new T.Vector3()];
  const lp = [new T.Vector3(), new T.Vector3(), new T.Vector3()];
  const lit = new T.Vector3(1, 1, 1);
  const uScale = { value: 400 };
  const uTime = { value: 0 };
  const pts = (n: number, perTorch: boolean, seedK: number, spread?: (i: number, p: Float32Array) => void): THREE.BufferGeometry => {
    const g = G(new T.BufferGeometry());
    const pos = new Float32Array(n * 3);
    const seed = new Float32Array(n);
    const tid = new Float32Array(n);
    const rnd = rng(seedK);
    for (let i = 0; i < n; i++) {
      seed[i] = rnd();
      tid[i] = perTorch ? i % 3 : 0;
      spread?.(i, pos);
    }
    g.setAttribute('position', new T.BufferAttribute(pos, 3));
    g.setAttribute('seed', new T.BufferAttribute(seed, 1));
    g.setAttribute('tid', new T.BufferAttribute(tid, 1));
    return g;
  };
  const flameMat = pointsMat(FIRE_VS, FIRE_FS, { uTime, uScale, uO: { value: fp }, uMode: { value: 0 } });
  const emberMat = pointsMat(FIRE_VS, FIRE_FS, { uTime, uScale, uO: { value: fp }, uMode: { value: 1 } });
  const drnd = rng(77);
  const dustMat = pointsMat(DUST_VS, DUST_FS, { uTime, uScale, uL: { value: lp }, uI: { value: lit } });
  const parts = [
    new T.Points(pts(3 * 40, true, 11), flameMat),
    new T.Points(pts(3 * 16, true, 23), emberMat),
    new T.Points(
      pts(320, false, 37, (i, p) => {
        p[i * 3] = (drnd() - 0.5) * 13;
        p[i * 3 + 1] = 0.2 + drnd() * 2.8;
        p[i * 3 + 2] = (drnd() - 0.5) * 13;
      }),
      dustMat,
    ),
  ];
  for (const p of parts) {
    p.frustumCulled = false;
    p.renderOrder = 3;
    p.visible = false;
    scene.add(p);
  }
  const halos = [0, 1, 2].map(() => {
    const a = glowSprite(0xffb060, 0.5, 0.9);
    const b = glowSprite(0xff7a20, 2.2, 0.22);
    a.visible = b.visible = false;
    scene.add(a, b);
    return [a, b] as const;
  });

  let K: Knight | null = null;
  let heroBlob: THREE.Mesh | null = null;
  let built = false;
  let dead = false;
  let hdrTex: THREE.DataTexture | null = null;
  let texs: THREE.Texture[] = [];
  const job = new Job();
  const P4: [number, number][] = [
    [-3.3, -3.3],
    [3.3, -3.3],
    [-3.3, 3.0],
    [3.3, 3.0],
  ];
  const inst = (geo: THREE.BufferGeometry, mat: THREE.Material, list: { p: [number, number, number]; r?: [number, number, number]; s?: number | [number, number, number] }[], cast = true): THREE.InstancedMesh => {
    const im = new T.InstancedMesh(G(geo), mat, list.length);
    const o = new T.Object3D();
    list.forEach((it, i) => {
      o.position.set(...it.p);
      o.rotation.set(...(it.r ?? [0, 0, 0]));
      const s = it.s ?? 1;
      if (typeof s === 'number') o.scale.setScalar(s);
      else o.scale.set(...s);
      o.updateMatrix();
      im.setMatrixAt(i, o.matrix);
    });
    im.castShadow = cast;
    im.receiveShadow = true;
    im.computeBoundingSphere();
    world.add(im);
    return im;
  };
  const plain = (geo: THREE.BufferGeometry, mat: THREE.Material, x: number, y: number, z: number, cast = true): THREE.Mesh => {
    const o = new T.Mesh(G(geo), mat);
    o.position.set(x, y, z);
    o.castShadow = cast;
    o.receiveShadow = true;
    world.add(o);
    return o;
  };
  const wallTorch: THREE.Object3D[] = [];

  function* build(k: Kit): Gen {
    const m = dungeonMats(T, k);
    if (late()) yield;
    // 바닥
    const fl = plain(new T.PlaneGeometry(15.4, 15.4), m.floor, 0, 0, 0, false);
    fl.rotation.x = -Math.PI / 2;
    // 북쪽 벽 (문 아치를 파낸 모양을 그대로 돌출 — 앞면 UV 가 세계 크기라 벽돌 크기가 고르다)
    const ns = new T.Shape();
    ns.moveTo(-7.7, 0);
    ns.lineTo(0.6, 0);
    ns.lineTo(0.6, 1.9);
    ns.absarc(1.5, 1.9, 0.9, Math.PI, 0, true);
    ns.lineTo(2.4, 0);
    ns.lineTo(7.7, 0);
    ns.lineTo(7.7, 3.4);
    ns.lineTo(-7.7, 3.4);
    ns.lineTo(-7.7, 0);
    plain(new T.ExtrudeGeometry(ns, { depth: 0.7, bevelEnabled: false, curveSegments: 28 }), m.wall, 0, 0, -7.7, false);
    if (late()) yield;
    const ws = new T.Shape();
    ws.moveTo(-7, 0);
    ws.lineTo(7.7, 0);
    ws.lineTo(7.7, 3.4);
    ws.lineTo(-7, 3.4);
    ws.lineTo(-7, 0);
    plain(new T.ExtrudeGeometry(ws, { depth: 0.7, bevelEnabled: false }), m.wall, -7.7, 0, 0, false).rotation.y = Math.PI / 2;
    // 앞쪽 무너진 낮은 벽 (들쭉날쭉한 윗면)
    const low = (seed: number): THREE.ExtrudeGeometry => {
      const rnd = rng(seed);
      const s = new T.Shape();
      s.moveTo(-7.7, 0);
      s.lineTo(7.7, 0);
      for (let x = 7.7; x >= -7.7; x -= 0.55) s.lineTo(x, 0.32 + rnd() * 0.5);
      s.lineTo(-7.7, 0);
      return new T.ExtrudeGeometry(s, { depth: 0.7, bevelEnabled: false });
    };
    plain(low(5), m.wall, 0, 0, 7.0, false);
    plain(low(9), m.wall, 7.0, 0, 0, false).rotation.y = Math.PI / 2;
    if (late()) yield;
    // 돌기둥 넷 (받침 · 몸통 · 머리)
    const col = columnProfile(2.5, 0.34, 10);
    inst(new T.LatheGeometry(col, 28), m.pillar, P4.map(([x, z]) => ({ p: [x, 0.3, z] })));
    inst(new T.BoxGeometry(1.06, 0.3, 1.06), m.stone, P4.map(([x, z]) => ({ p: [x, 0.15, z] })));
    if (late()) yield;
    // 문 + 쇠 띠 + 손잡이 + 아치 돌
    const ds = new T.Shape();
    ds.moveTo(0.66, 0);
    ds.lineTo(0.66, 1.9);
    ds.absarc(1.5, 1.9, 0.84, Math.PI, 0, true);
    ds.lineTo(2.34, 0);
    ds.lineTo(0.66, 0);
    plain(new T.ExtrudeGeometry(ds, { depth: 0.1, bevelEnabled: false, curveSegments: 24 }), m.door, 0, 0, -7.42, false);
    for (const y of [0.5, 1.45]) plain(new T.BoxGeometry(1.66, 0.09, 0.05), m.iron, 1.5, y, -7.3, false);
    const ring = plain(new T.TorusGeometry(0.09, 0.016, 8, 20), m.iron, 2.05, 1.0, -7.29, false);
    void ring;
    const blocks: { p: [number, number, number]; r?: [number, number, number]; s?: [number, number, number] }[] = [];
    for (let i = 0; i <= 8; i++) {
      const a = Math.PI - (i / 8) * Math.PI;
      blocks.push({ p: [1.5 + Math.cos(a) * 1.02, 1.9 + Math.sin(a) * 1.02, -6.96], r: [0, 0, a - Math.PI / 2], s: [0.3, 0.34, 0.14] });
    }
    for (const sx of [0.48, 2.52]) for (let j = 0; j < 4; j++) blocks.push({ p: [sx, 0.24 + j * 0.47, -6.96], s: [j % 2 ? 0.3 : 0.36, 0.44, 0.14] });
    inst(new T.BoxGeometry(1, 1, 1), m.stone, blocks, false);
    if (late()) yield;
    // 벽 횃불 둘
    const t1 = torchBracket(T, m, geos);
    t1.g.position.set(-1.6, 1.95, -7.0);
    const t2 = torchBracket(T, m, geos);
    t2.g.position.set(-7.0, 1.95, 1.0);
    t2.g.rotation.y = Math.PI / 2;
    world.add(t1.g, t2.g);
    wallTorch.push(t1.flame, t2.flame);
    // 상자 · 통 · 보물 상자 · 금화 · 돌 부스러기
    inst(new T.BoxGeometry(0.9, 0.9, 0.9), m.wood, [
      { p: [5.55, 0.45, -6.3], r: [0, 0.12, 0] },
      { p: [4.55, 0.45, -6.4], r: [0, -0.1, 0] },
      { p: [5.1, 1.35, -6.3], r: [0, 0.32, 0] },
      { p: [6.25, 0.45, -5.15], r: [0, 0.55, 0] },
      { p: [5.4, 0.36, 5.6], r: [0, 0.3, 0], s: 0.8 },
    ]);
    const barrel = new T.LatheGeometry(
      [
        [0.001, 0],
        [0.3, 0],
        [0.355, 0.22],
        [0.375, 0.45],
        [0.355, 0.68],
        [0.3, 0.9],
        [0.001, 0.9],
      ].map(([x, y]) => new T.Vector2(x, y)),
      28,
    );
    const BR: [number, number][] = [
      [-6.3, 3.5],
      [-6.25, 4.42],
      [-5.45, 4.0],
    ];
    inst(barrel, m.barrel, BR.map(([x, z], i) => ({ p: [x, 0, z], r: [0, i * 1.3, 0] })));
    const hoops: { p: [number, number, number]; r: [number, number, number]; s: [number, number, number] }[] = [];
    for (const [x, z] of BR)
      for (const [y, rr] of [
        [0.2, 0.36],
        [0.7, 0.35],
      ] as const)
        hoops.push({ p: [x, y, z], r: [Math.PI / 2, 0, 0], s: [rr / 0.36, rr / 0.36, 1] });
    inst(new T.TorusGeometry(0.36, 0.018, 6, 32), m.iron, hoops);
    if (late()) yield;
    const chest = new T.Group();
    chest.position.set(-6.0, 0, -1.1);
    chest.rotation.y = Math.PI / 2 - 0.1;
    world.add(chest);
    const cm = (geo: THREE.BufferGeometry, mat: THREE.Material, x: number, y: number, z: number): THREE.Mesh => {
      const o = new T.Mesh(G(geo), mat);
      o.position.set(x, y, z);
      o.castShadow = o.receiveShadow = true;
      chest.add(o);
      return o;
    };
    cm(new T.BoxGeometry(1.2, 0.62, 0.72), m.wood, 0, 0.31, 0);
    cm(new T.CylinderGeometry(0.36, 0.36, 1.2, 24, 1, false, 0, Math.PI), m.wood, 0, 0.62, 0).rotation.z = Math.PI / 2;
    for (const x of [-0.42, 0, 0.42]) {
      cm(new T.BoxGeometry(0.07, 0.64, 0.74), m.iron, x, 0.31, 0);
      cm(new T.TorusGeometry(0.366, 0.022, 6, 20, Math.PI), m.iron, x, 0.62, 0).rotation.y = Math.PI / 2;
    }
    cm(new T.BoxGeometry(0.14, 0.16, 0.05), m.gold, 0, 0.56, 0.37);
    const crnd = rng(101);
    const coins: { p: [number, number, number]; r: [number, number, number] }[] = [];
    for (let i = 0; i < 90; i++) {
      const rr = Math.pow(crnd(), 0.8) * 0.75;
      const a = crnd() * TAU;
      const hgt = Math.max(0, 0.26 * (1 - rr / 0.75)) * (0.5 + 0.5 * crnd());
      coins.push({ p: [-5.05 + Math.sin(a) * rr * 0.8, 0.012 + hgt, -0.75 + Math.cos(a) * rr], r: [(crnd() - 0.5) * 1.2, crnd() * TAU, (crnd() - 0.5) * 1.2] });
    }
    inst(new T.CylinderGeometry(0.065, 0.065, 0.016, 16), m.gold, coins, false);
    const rrnd = rng(55);
    const rub: { p: [number, number, number]; r: [number, number, number]; s: [number, number, number] }[] = [];
    for (let i = 0; i < 34; i++) {
      const side = i % 4;
      const u = (rrnd() - 0.5) * 13;
      const v = 0.2 + rrnd() * 0.7;
      const x = side === 0 ? u : side === 1 ? -6.9 + v : side === 2 ? P4[i % 4]![0] + (rrnd() - 0.5) * 1.6 : u * 0.8;
      const z = side === 0 ? -6.9 + v : side === 1 ? u : side === 2 ? P4[i % 4]![1] + (rrnd() - 0.5) * 1.6 : 6.6 - v;
      const s = 0.5 + rrnd() * 1.1;
      rub.push({ p: [x, 0.05 * s, z], r: [rrnd() * 3, rrnd() * 3, rrnd() * 3], s: [s, s * 0.6, s * (0.7 + rrnd() * 0.5)] });
    }
    inst(new T.IcosahedronGeometry(0.13, 1), m.stone, rub);
    if (late()) yield;
    // 접촉 그늘
    const sN = shadowStrip(T, 15.4, 1.4);
    sN.position.set(0, 0.006, -6.3);
    const sW = shadowStrip(T, 15.4, 1.4);
    sW.position.set(-6.3, 0.006, 0);
    sW.rotation.z = Math.PI / 2;
    world.add(sN, sW);
    const blobAt = (x: number, z: number, s: number, o = 0.75): void => {
      const b = blob(s, o);
      b.position.set(x, 0.008, z);
      world.add(b);
    };
    for (const [x, z] of P4) blobAt(x, z, 2.4, 0.8);
    blobAt(5.2, -6.1, 3.2, 0.7);
    blobAt(-5.95, 4.0, 2.4, 0.7);
    blobAt(-5.95, -1.1, 2.2, 0.7);
    blobAt(5.4, 5.6, 1.4, 0.6);
    // 기사
    K = buildKnight(T, m);
    K.root.scale.setScalar(1.3);
    world.add(K.root);
    heroBlob = blob(1.0, 0.7);
    world.add(heroBlob);
    if (late()) yield;
    world.updateMatrixWorld(true);
    wallTorch.forEach((f, i) => f.getWorldPosition(fp[i]!));
    texs = sceneTex(world);
    for (const p of parts) p.visible = true;
    for (const [a, b] of halos) a.visible = b.visible = true;
    built = true;
  }
  loadKit().then((k) => {
    if (dead) return;
    hdrTex = k.hdr;
    job.start(build(k));
  });

  let walking = true;
  let s = 0;
  let mul = 1;
  let shadows = true;
  const warm = new Warm();
  const tgt = new T.Vector3();
  const OFF = new T.Vector3(8.6, 12.4, 9.8);
  update0();
  function update0(): void {
    cam.position.copy(OFF);
    cam.lookAt(0, 0, 0);
  }

  return {
    scene,
    camera: cam,
    update(t, dt) {
      if (job.busy) job.step(3);
      uTime.value = t;
      if (!built || !K) return;
      if (walking) s += Math.min(dt, 0.05) * 0.42;
      const rx = 2.25;
      const rz = 1.85;
      const cz = -0.2;
      const x = Math.cos(s) * rx;
      const z = cz + Math.sin(s) * rz;
      const dx = -Math.sin(s) * rx;
      const dz = Math.cos(s) * rz;
      const ph = s * 9.5;
      const sw = walking ? 1 : 0;
      K.root.position.set(x, Math.abs(Math.sin(ph)) * 0.035 * sw, z);
      K.root.rotation.y = Math.atan2(dx, dz);
      K.legL.rotation.x = Math.sin(ph) * 0.55 * sw;
      K.legR.rotation.x = -Math.sin(ph) * 0.55 * sw;
      K.armL.rotation.x = -Math.sin(ph) * 0.3 * sw - 0.15;
      K.armR.rotation.x = -1.15 + Math.sin(ph) * 0.06 * sw;
      heroBlob!.position.set(x, 0.009, z);
      // 망토 — 아래로 갈수록 뒤로 날리고 물결
      const pa = K.cape.geometry.attributes.position!;
      const arr = pa.array as Float32Array;
      for (let i = 0; i < arr.length; i += 3) {
        const bx = K.capeBase[i]!;
        const by = K.capeBase[i + 1]!;
        const k = Math.max(0, -by / 0.9);
        const pleat = Math.sin(bx * 34 + 0.6) * 0.022 * (0.3 + k);
        arr[i + 2] = -Math.pow(k, 1.4) * (0.1 + 0.16 * sw) + Math.sin(t * 6.5 + k * 4.2 + bx * 3) * 0.045 * k - bx * bx * 1.6 * (1 - k * 0.5) + pleat;
        arr[i] = bx * (0.72 + k * 0.5);
        arr[i + 1] = by - (bx * bx * 0.9) * k;
      }
      pa.needsUpdate = true;
      K.cape.geometry.computeVertexNormals();
      K.root.updateMatrixWorld(true);
      K.flame.getWorldPosition(fp[2]!);
      for (let i = 0; i < 3; i++) {
        const f = flick(t, i * 2.1);
        const l = lights[i]!;
        l.position.copy(fp[i]!).y += 0.14;
        l.intensity = BASE[i]! * f * mul;
        lp[i]!.copy(l.position);
        lit.setComponent(i, f * mul * (i === 2 ? 1.2 : 1));
        const [a, b] = halos[i]!;
        a.position.copy(fp[i]!).y += 0.1;
        b.position.copy(fp[i]!).y += 0.1;
        a.scale.setScalar(0.5 * (0.85 + 0.3 * f) * Math.sqrt(mul));
        b.scale.setScalar(2.3 * f * Math.sqrt(mul));
      }
      tgt.set(x * 0.45, 0.6, cz * 0.55 + z * 0.45 - 0.6);
      cam.position.copy(tgt).add(OFF);
      cam.lookAt(tgt);
    },
    render(r, w, h) {
      if (!built) return waiting(r, w, h, hud, msg, job.busy ? '던전 짓는 중 …' : '실사 재질 받는 중 …');
      if (!pump(r, texs)) return waiting(r, w, h, hud, msg, 'GPU 에 텍스처 올리는 중 …');
      if (hdrTex) {
        const env = envFor(r, hdrTex);
        if (!env) return waiting(r, w, h, hud, msg, 'HDRI 빛 굽는 중 …');
        scene.environment = env;
        scene.environmentIntensity = 0.06;
      }
      uScale.value = h / (2 * Math.tan(THREE.MathUtils.degToRad(cam.fov) / 2));
      const ok = warm.ready(r, (rr) => withShadows(rr, shadows, () => rr.compileAsync(scene, cam)));
      if (!ok) return waiting(r, w, h, hud, msg, '셰이더 굽는 중 …');
      msg.visible = false;
      note.visible = true;
      r.setClearColor(BG, 1);
      withShadows(r, shadows, () => r.render(scene, cam));
      hud.draw(r, w, h);
    },
// … (16줄 더 — 전체는 라이브 견본 페이지의 「코드」 탭)
```

### flick — `src/demos/demosRender.ts:1148`
```ts
const flick = (t: number, ph: number): number => 0.84 + 0.08 * Math.sin(t * 11 + ph) + 0.05 * Math.sin(t * 23.7 + ph * 1.7) + 0.03 * Math.sin(t * 5.3 + ph * 0.3);
```

## 관련 기술
- 먼저 알면 좋은 기술: [실사 PBR 텍스처 (무료 CC0 스캔 재질)](https://ai-techstudio.web.app/ai/t/i474.md) `i474` · [HDRI 환경 빛 · 반사](https://ai-techstudio.web.app/ai/t/i475.md) `i475` · [셰이더 미리 데우기](https://ai-techstudio.web.app/ai/t/i385.md) `i385`
- 다음에 해 볼 기술: [가리는 벽 투명 처리](https://ai-techstudio.web.app/ai/t/i488.md) `i488` · [시야와 안개 (가 본 곳만 밝게)](https://ai-techstudio.web.app/ai/t/i487.md) `i487` · [카메라가 벽을 뚫지 않게](https://ai-techstudio.web.app/ai/t/i481.md) `i481`
- 참고 문서: [three.js 예제 — webgl_shadowmap_pointlight](https://threejs.org/examples/#webgl_shadowmap_pointlight) · [Poly Haven — CC0 텍스처](https://polyhaven.com/textures)
