# AI 꾸러미 — 시야와 안개 (가 본 곳만 밝게) — Fog of war (visibility grid)
> 주인공 시야를 칸마다 광선으로 재 「지금 보임 · 가 본 곳」 두 값을 작은 텍스처에 쓰고, 모든 재질이 그 텍스처로 밝게 · 푸르스름하게 · 검게 칠한다.  
> 견본: https://ai-techstudio.web.app/#t/i487

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

## 주문서

### 만들어 줘: 시야와 안개 (가 본 곳만 밝게) — Fog of war (visibility grid)

#### 1. 목표
방 9개 던전 탐험에 시야 안개를 넣어 줘 — 지금 보이는 곳 · 가 본 곳 · 모르는 곳 세 단계 어둠과 같은 텍스처를 쓰는 미니맵. 분위기는 횃불 하나 든 어두운 던전.

#### 2. 핵심 기술 용어
- **Fog of war (visibility grid)** — 안 가 본 곳은 검게, 가 본 곳은 어둡게, 보이는 곳만 밝게
- **Line of sight per cell** — 주인공 → 칸 가운데 직선이 벽에 막히는지
- **DataTexture (R = visible, G = explored)** — 격자 값을 텍스처로 올려 셰이더가 읽게
- **onBeforeCompile material patch** — 표준 재질 마지막 색에 시야를 곱하는 코드 끼워 넣기

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

#### 4. 조건
- 시야는 칸 격자로 계산하고 결과는 작은 DataTexture 하나로 (칸마다 물체 색을 바꾸지 않는다)
- 「가 본 곳」은 줄지 않고 「지금 보임」만 오르내린다
- 벽 칸은 칸 가운데가 아니라 주인공 쪽 면으로 시야를 잰다 (안 그러면 벽이 늘 어둡다)
- 바닥 · 벽 · 소품 모든 재질에 같은 시야 패치를, 인스턴스 벽은 instanceMatrix 까지 곱해서
- 시야 반지름 슬라이더 · 광선 부채 보기 · 미니맵 · 지도 지우기

#### 5. 완성 기준 (이게 보이면 성공)
- 주인공 둘레만 횃불빛으로 밝고, 지나온 방은 푸르스름한 흑백으로 남고, 안 가 본 곳은 검다
- 벽 모퉁이 뒤는 밝아지지 않는다 (광선 부채가 벽에서 잘린다)
- 미니맵이 큰 화면과 똑같이 밝은 곳 · 가 본 곳 · 모르는 곳을 보여 준다
- 「지도 지우기」를 누르면 다시 모두 검어진다

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

## 원리
- 던전은 28×18 칸 격자. 매 장면 주인공 둘레 반지름(6.5칸) 안 칸마다 주인공 → 칸 가운데를 0.18 간격으로 걸어 벽 칸에 막히는지 본다 (벽 칸은 주인공 쪽 면을 본다).
- 보이면 목표 1 (가장자리 1.6칸은 부드럽게 줄임). 지금 보임 vis 는 목표로 빠르게 따라가고(× 9), 가 본 곳 seen 은 보인 동안 쌓이기만 한다(× 3, 줄지 않음).
- vis · seen 을 RGBA DataTexture 의 R · G 에 쓰고 needsUpdate — 텍스처 하나가 지도 전체.
- 바닥 · 벽 · 소품 재질에 onBeforeCompile 로 끼워 넣기: 세계 xz 로 텍스처를 읽어 「가 본 곳 = 흑백에 가까운 푸른 기억 색 × G」, 「보임 = 원래 빛 색」을 R 로 섞는다.
- 미니맵은 같은 시야 텍스처 + 벽 지도 텍스처를 판 하나에 그린다.

## 핵심 코드 — 칸마다 시야 재기 → 텍스처 R · G → 재질에서 섞기
(발췌: demos/demosGame3dB.ts demoFog() 의 update · fogPatch() · FOG_FS 를 정리)
```ts
const fogData = new Uint8Array(MW * MH * 4);                 // R = 지금 보임, G = 가 본 곳
const fogTex = new THREE.DataTexture(fogData, MW, MH, THREE.RGBAFormat);
fogTex.magFilter = fogTex.minFilter = THREE.LinearFilter;

function updateFog(dt: number, R2: number): void {
  for (let y = 0; y < MH; y++) for (let x = 0; x < MW; x++) {
    const c = y * MW + x;
    let tx = cellX(x), ty = cellY(y);
    const d = Math.hypot(tx - hero.x, ty - hero.y);
    let tgt = 0;
    if (d < R2 + 0.5) {
      if (grid[c]) { const s = Math.min(0.48, d) / Math.max(d, 1e-3); tx += (hero.x - tx) * s; ty += (hero.y - ty) * s; } // 벽은 주인공 쪽 면
      if (sees(tx, ty, c)) tgt = Math.min(1, Math.max(0, (R2 + 0.5 - d) / 1.6));
    }
    vis[c] += (tgt - vis[c]) * Math.min(1, dt * 9);
    if (tgt > 0.2) seen[c] = Math.min(1, seen[c] + dt * 3);          // 가 본 곳은 쌓이기만
    fogData[c * 4] = Math.round(vis[c] * 255);
    fogData[c * 4 + 1] = Math.round(seen[c] * 255);
  }
  fogTex.needsUpdate = true;
}

// 모든 재질에: 빛 계산이 끝난 색(gl_FragColor)에 시야를 섞는다
function fogPatch(m: THREE.MeshStandardMaterial): THREE.MeshStandardMaterial {
  m.onBeforeCompile = (sh) => {
    sh.uniforms.uFogT = { value: fogTex };
    sh.uniforms.uGrid = { value: new THREE.Vector2(MW, MH) };
    sh.vertexShader = 'varying vec3 vFogW;\n' + sh.vertexShader.replace('#include <project_vertex>',
      '#include <project_vertex>\nvec4 fogP = vec4(transformed, 1.0);\n#ifdef USE_INSTANCING\nfogP = instanceMatrix * fogP;\n#endif\nvFogW = (modelMatrix * fogP).xyz;');
    sh.fragmentShader = 'uniform sampler2D uFogT; uniform vec2 uGrid; varying vec3 vFogW;\n' + sh.fragmentShader.replace('#include <opaque_fragment>',
      '#include <opaque_fragment>\nvec4 fg = texture2D(uFogT, (vFogW.xz + uGrid * 0.5) / uGrid); vec3 lit = gl_FragColor.rgb;' +
      ' vec3 mem = mix(vec3(dot(lit, vec3(0.299, 0.587, 0.114))), lit, 0.3) * vec3(0.62, 0.72, 1.0) * 1.15;' +
      ' gl_FragColor.rgb = mix(mem * fg.g, lit, fg.r);');
  };
  m.customProgramCacheKey = () => 'fog487';
  return m;
}
```

## 흔한 실수 · 확인 목록
- [ ] **벽 칸을 칸 가운데로 재면 벽이 늘 「안 보임」이 된다** — 벽 칸은 주인공 쪽으로 0.48 당긴 면 위 점으로 시야를 잰다.
- [ ] **인스턴스로 그린 벽만 안개가 엉뚱한 자리에 덮인다** — 정점 셰이더에서 USE_INSTANCING 이면 instanceMatrix 를 곱한 세계 좌표를 쓴다.
- [ ] **같은 패치 재질마다 프로그램이 따로 컴파일된다** — customProgramCacheKey 를 같은 값으로 — 셰이더 하나를 함께 쓴다.
- [ ] **「가 본 곳」을 검게만 칠하면 기억이 정보가 안 된다** — 흑백에 가까운 푸른 색으로 어둡게 남겨 지형은 알아보게 한다.

## 완성 기준 체크리스트
- [ ] 주인공 둘레만 횃불빛으로 밝고, 지나온 방은 푸르스름한 흑백으로 남고, 안 가 본 곳은 검다
- [ ] 벽 모퉁이 뒤는 밝아지지 않는다 (광선 부채가 벽에서 잘린다)
- [ ] 미니맵이 큰 화면과 똑같이 밝은 곳 · 가 본 곳 · 모르는 곳을 보여 준다
- [ ] 「지도 지우기」를 누르면 다시 모두 검어진다

## 이 기술 정보
- id: `i487` · 분류: 게임 시스템 · AI › 3D 게임 기본기 · 3D · 난이도 보통 · 폰 부담 가벼움 (폰 OK) — 반지름 안 칸(약 150칸) × 광선 걸음 수십 번 + 28×18 텍스처 업로드. 1ms 미만. 재질은 텍스처 한 번 더 읽을 뿐.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i487
- 쓰면 좋을 때: 던전 · 미로처럼 「가 본 곳」이 정보인 탐험 / 전략 게임의 정찰
- 쓰지 말 때: 벽이 없는 넓은 들판 — 거리만으로 원형 안개면 충분 / 칸 수가 수만 개 — 칸마다 광선은 무겁다. 그림자 그리기(광선 각도 훑기)로

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### demoFog — `src/demos/demosGame3dB.ts:2231`
```ts
function demoFog(): Scene3D {
  const st = makeStage({ w: MW + 10, d: MH + 10, wallH: 1, noWall: true, noFloor: true, torches: [], cam: [0, 9, 6.5], look: [0, 0, 0], fov: 40, shadow: 10 });
  const { scene, cam, bin } = st;
  scene.background = new THREE.Color(0x020203);
  st.sun.intensity = 0.55;
  st.sun.castShadow = false;
  const painter = new Painter(0x020203);
  const { grid, rooms } = buildDungeon();
  // 시야 텍스처: R = 지금 보임, G = 가 본 곳
  const fogData = new Uint8Array(MW * MH * 4);
  const fogTex = bin.add(new THREE.DataTexture(fogData, MW, MH, THREE.RGBAFormat));
  fogTex.magFilter = fogTex.minFilter = THREE.LinearFilter;
  fogTex.needsUpdate = true;
  const mapData = new Uint8Array(MW * MH * 4);
  for (let i = 0; i < MW * MH; i++) mapData[i * 4] = grid[i] ? 255 : 0;
  const mapTex = bin.add(new THREE.DataTexture(mapData, MW, MH, THREE.RGBAFormat));
  mapTex.needsUpdate = true;
  const W2 = (x: number): number => x - MW / 2 + 0.5;
  const H2 = (y: number): number => y - MH / 2 + 0.5;
  // 바닥
  const floorM = fogPatch(bin.add(new THREE.MeshStandardMaterial({ map: bin.add(texOf('floor', 512, 512, paintFloor, MW / 4, MH / 4)), roughness: 0.92 })), fogTex);
  const floor = new THREE.Mesh(bin.add(new THREE.PlaneGeometry(MW, MH)), floorM);
  floor.rotation.x = -Math.PI / 2;
  floor.receiveShadow = true;
  scene.add(floor);
  // 벽 (바닥에 닿은 것만 인스턴스)
  const wallM = fogPatch(bin.add(new THREE.MeshStandardMaterial({ map: bin.add(texOf('brick', 512, 512, paintBrick, 0.5, 0.5)), roughness: 0.9 })), fogTex);
  const wallG = bin.add(new THREE.BoxGeometry(1, 1.25, 1));
  wallG.translate(0, 0.625, 0);
  const wallCells: number[] = [];
  for (let y = 0; y < MH; y++)
    for (let x = 0; x < MW; x++) {
      if (!grid[y * MW + x]) continue;
      let edge = false;
      for (let dy = -1; dy <= 1; dy++)
        for (let dx = -1; dx <= 1; dx++) {
          const nx = x + dx;
          const ny = y + dy;
          if (nx >= 0 && ny >= 0 && nx < MW && ny < MH && !grid[ny * MW + nx]) edge = true;
        }
      if (edge) wallCells.push(y * MW + x);
    }
  const walls = new THREE.InstancedMesh(wallG, wallM, wallCells.length);
  const mtx = new THREE.Matrix4();
  wallCells.forEach((c, i) => walls.setMatrixAt(i, mtx.makeTranslation(W2(c % MW), 0, H2((c / MW) | 0))));
  walls.castShadow = walls.receiveShadow = true;
  scene.add(walls);
  // 소품: 통 · 보물 상자 (안개에 같이 덮인다)
  const barrelM = fogPatch(bin.add(new THREE.MeshStandardMaterial({ color: 0x8a5a32, roughness: 0.75 })), fogTex);
  const chestM = fogPatch(bin.add(new THREE.MeshStandardMaterial({ color: 0x9a4f2a, roughness: 0.6 })), fogTex);
  const goldM = fogPatch(bin.add(new THREE.MeshStandardMaterial({ color: 0xf0c050, roughness: 0.3, metalness: 0.6 })), fogTex);
  const barrelG = bin.add(new THREE.CylinderGeometry(0.22, 0.2, 0.5, 14));
  const chestG = bin.add(new THREE.BoxGeometry(0.55, 0.36, 0.38));
  const lidG = bin.add(new THREE.CylinderGeometry(0.19, 0.19, 0.55, 12, 1, false, 0, Math.PI));
  const props: [number, number, number][] = [
    [1, 1, 0], [2, 1, 0], [6, 4, 1], [13, 1, 0], [26, 1, 1], [26, 6, 0], [16, 6, 0],
    [1, 16, 1], [6, 11, 0], [18, 12, 0], [26, 16, 1], [20, 13, 0], [8, 10, 0], [26, 8, 0],
  ];
  for (const [x, y, k] of props) {
    if (grid[y * MW + x]) continue;
    const g = new THREE.Group();
    g.position.set(W2(x), 0, H2(y));
    if (k === 0) {
      const b = new THREE.Mesh(barrelG, barrelM);
      b.position.y = 0.25;
      g.add(b);
    } else {
      const c = new THREE.Mesh(chestG, chestM);
      c.position.y = 0.18;
      const l = new THREE.Mesh(lidG, goldM);
      l.rotation.z = Math.PI / 2;
      l.rotation.x = Math.PI / 2;
      l.position.y = 0.36;
      g.add(c, l);
    }
    g.traverse((o) => {
      o.castShadow = o.receiveShadow = true;
    });
    scene.add(g);
  }
  // 주인공 (작은 기사 + 횃불)
  const hero = makeKnight(bin, 0x2f7f5f, 0xe8b13a);
  hero.g.scale.setScalar(0.7);
  scene.add(hero.g);
  const torchL = new THREE.PointLight(0xffa050, 9, 11, 1.4);
  torchL.castShadow = true;
  torchL.shadow.mapSize.set(512, 512);
  torchL.shadow.bias = -0.002;
  torchL.shadow.radius = 2;
  torchL.position.set(0, 1.25, 0);
  scene.add(torchL);
  const flame = new THREE.Sprite(st.flameMat);
  flame.scale.set(0.2, 0.32, 1);
  const halo = new THREE.Sprite(st.haloMat);
  halo.scale.set(0.9, 0.9, 1);
  scene.add(flame, halo);
  // 시야 부채 (광선 끝을 이은 다각형)
  const RAYS = 160;
  const fanPos = new Float32Array((RAYS + 2) * 3);
  const fanG = bin.add(new THREE.BufferGeometry());
  fanG.setAttribute('position', new THREE.BufferAttribute(fanPos, 3).setUsage(THREE.DynamicDrawUsage));
  const fi: number[] = [];
  for (let i = 0; i < RAYS; i++) fi.push(0, i + 1, i + 2);
  fanG.setIndex(fi);
  const fan = new THREE.Mesh(fanG, bin.add(new THREE.MeshBasicMaterial({ color: 0xffc070, transparent: true, opacity: 0.09, blending: THREE.AdditiveBlending, depthWrite: false, side: THREE.DoubleSide, toneMapped: false })));
  fan.frustumCulled = false;
  fan.renderOrder = 2;
  const edgeG = bin.add(new THREE.BufferGeometry());
  edgeG.setAttribute('position', new THREE.BufferAttribute(fanPos, 3, false));
  edgeG.setDrawRange(1, RAYS + 1);
  const edge = new THREE.Line(edgeG, bin.add(new THREE.LineBasicMaterial({ color: 0xffd890, transparent: true, opacity: 0.55, depthWrite: false, toneMapped: false })));
  edge.frustumCulled = false;
  scene.add(fan, edge);

  // 순회 길: 방 가운데를 차례로
  const order = [0, 1, 2, 5, 8, 7, 4, 3, 6, 3, 0];
  const centers = order.map((r) => {
    const [x, y, w, h] = rooms[r]!;
    let cx = x + (w >> 1);
    let cy = y + (h >> 1);
    if (grid[cy * MW + cx]) cx = x;
    if (grid[cy * MW + cx]) cy = y;
    return cy * MW + cx;
  });
  const path: number[] = [centers[0]!];
  for (let i = 0; i + 1 < centers.length; i++) path.push(...bfsPath(grid, centers[i]!, centers[i + 1]!));
  const opt = { radius: 6.5, rays: true, mini: true };
  const vis = new Float32Array(MW * MH);
  const seen = new Float32Array(MW * MH);
  let walk = 0;
  const hp = new THREE.Vector2();
  let face = 0;
  const camT = new THREE.Vector3();
  let camInit = false;
  const blocked = (x: number, y: number): boolean => {
    const cx = Math.floor(x + MW / 2);
    const cy = Math.floor(y + MH / 2);
    return cx < 0 || cy < 0 || cx >= MW || cy >= MH || grid[cy * MW + cx] === 1;
  };
  /** 주인공 → 칸까지 직선이 벽에 막히나 (벽 칸은 주인공 쪽 면까지만) */
  const sees = (tx: number, ty: number, target: number): boolean => {
    const dx = tx - hp.x;
    const dy = ty - hp.y;
    const L = Math.hypot(dx, dy);
    const n = Math.ceil(L / 0.18);
    for (let s = 1; s < n; s++) {
      const x = hp.x + (dx * s) / n;
      const y = hp.y + (dy * s) / n;
      const cx = Math.floor(x + MW / 2);
      const cy = Math.floor(y + MH / 2);
      const c = cy * MW + cx;
      if (c !== target && grid[c] === 1) return false;
    }
    return true;
  };
  const mm = { x: 0, y: 0, w: 0, h: 0 };
  const miniU = { uMap: { value: mapTex as THREE.Texture }, uFog: { value: fogTex as THREE.Texture }, uHero: { value: new THREE.Vector2() } };
  const mini = new THREE.Mesh(
    bin.add(new THREE.PlaneGeometry(1, 1)),
    bin.add(new THREE.ShaderMaterial({ uniforms: miniU, vertexShader: 'varying vec2 vUv; void main(){ vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position,1.0); }', fragmentShader: MINI_FS, transparent: true, depthTest: false, depthWrite: false })),
  );
  mini.renderOrder = 30;
  const hud = new Hud((g, _w, h, u) => {
    tag(g, `시야 = 벽에 가리는 광선 · 반지름 ${opt.radius}칸`, 8 * u, 14 * u, 9.5 * u, '#ffd9a0');
    // 범례
    const items: [string, string][] = [
      ['#ffd27a', '지금 보임'],
      ['#56607a', '가 본 곳'],
      ['#08080c', '모름'],
    ];
    let x = 8 * u;
    const y = h - 13 * u;
    g.font = `800 ${9 * u}px ${FONT}`;
    for (const [c, s] of items) {
      const tw = g.measureText(s).width;
      g.fillStyle = 'rgba(12,10,18,0.66)';
      g.beginPath();
      g.roundRect(x, y - 8 * u, tw + 22 * u, 16 * u, 8 * u);
      g.fill();
      g.fillStyle = c;
      g.strokeStyle = 'rgba(255,255,255,0.5)';
      g.lineWidth = 1;
      g.beginPath();
      g.roundRect(x + 5 * u, y - 4 * u, 8 * u, 8 * u, 2 * u);
      g.fill();
      g.stroke();
      g.fillStyle = '#e8e8f0';
      g.textAlign = 'left';
      g.textBaseline = 'middle';
      g.fillText(s, x + 16 * u, y + 0.5 * u);
      x += tw + 26 * u;
    }
    if (opt.mini) {
      g.fillStyle = 'rgba(12,10,18,0.7)';
      g.strokeStyle = 'rgba(255,214,140,0.8)';
      g.lineWidth = Math.max(1, 1.5 * u);
      g.beginPath();
      g.roundRect(mm.x - 4 * u, h - mm.y - mm.h - 4 * u, mm.w + 8 * u, mm.h + 8 * u, 5 * u);
      g.fill();
      g.stroke();
    }
  });
  hud.onResize = (w, h) => {
    const u0 = Math.min(w / 280, h / 175);
    const u = u0 <= 1 ? u0 : 1 + (u0 - 1) * 0.55;
    mm.w = Math.round(84 * u);
    mm.h = Math.round((mm.w * MH) / MW);
    mm.x = w - mm.w - 8 * u;
    mm.y = h - mm.h - 8 * u;
    mini.scale.set(mm.w, mm.h, 1);
    mini.position.set(mm.x + mm.w / 2, mm.y + mm.h / 2, 0);
  };
  hud.scene.add(mini);
  const resetMap = (): void => {
    seen.fill(0);
    vis.fill(0);
  };
  const update = (t: number, dt: number): void => {
    st.tick(t);
    // 걷기
    walk = Math.max(0, walk + dt * 2.4);
    const segs = path.length - 1;
    if (walk >= segs) {
      walk -= segs;
      resetMap();
    }
    const i0 = Math.floor(walk);
    const k = walk - i0;
    const a = path[i0]!;
    const b = path[Math.min(segs, i0 + 1)]!;
    const ax0 = W2(a % MW);
    const ay0 = H2((a / MW) | 0);
    const bx0 = W2(b % MW);
    const by0 = H2((b / MW) | 0);
    // 모서리를 부드럽게: 다음 칸 쪽으로 살짝 당김
    hp.set(lerp(ax0, bx0, k), lerp(ay0, by0, k));
    const want = Math.atan2(-(by0 - ay0), bx0 - ax0);
    let dA = want - face;
    while (dA > Math.PI) dA -= TAU;
    while (dA < -Math.PI) dA += TAU;
    face += dA * Math.min(1, dt * 10);
    hero.g.position.set(hp.x, 0, hp.y);
    hero.g.rotation.y = face;
    const stp = Math.sin(walk * Math.PI * 2);
    hero.legL.rotation.z = stp * 0.55;
    hero.legR.rotation.z = -stp * 0.55;
    hero.body.position.y = Math.abs(Math.cos(walk * Math.PI * 2)) * 0.05;
    hero.pivot.rotation.set(0, -0.3, -1.2);
    // 왼손 횃불
    const lx = Math.cos(face + 1.2) * 0.3;
    const lz = -Math.sin(face + 1.2) * 0.3;
    const fl = 0.86 + 0.1 * Math.sin(t * 13) + 0.05 * Math.sin(t * 29);
    torchL.position.set(hp.x + lx, 1.1, hp.y + lz);
    torchL.intensity = 9 * fl;
    flame.position.set(hp.x + lx, 0.95, hp.y + lz);
    flame.scale.set(0.18, 0.3 * fl, 1);
    halo.position.copy(flame.position);
    // 시야 계산 (칸마다 광선)
    const R2 = opt.radius;
    for (let y = 0; y < MH; y++)
      for (let x = 0; x < MW; x++) {
        const c = y * MW + x;
        let tx = W2(x);
        let ty = H2(y);
        const d = Math.hypot(tx - hp.x, ty - hp.y);
        let tgt = 0;
        if (d < R2 + 0.5) {
          if (grid[c]) {
            // 벽은 주인공 쪽 면을 본다
            const s = Math.min(0.48, d) / Math.max(d, 1e-3);
            tx += (hp.x - tx) * s;
            ty += (hp.y - ty) * s;
          }
          if (sees(tx, ty, c)) tgt = clamp((R2 + 0.5 - d) / 1.6, 0, 1);
        }
        vis[c] = vis[c]! + (tgt - vis[c]!) * Math.min(1, dt * 9);
        if (tgt > 0.2) seen[c] = Math.min(1, seen[c]! + dt * 3);
        fogData[c * 4] = Math.round(vis[c]! * 255);
        fogData[c * 4 + 1] = Math.round(seen[c]! * 255);
      }
    fogTex.needsUpdate = true;
    // 광선 부채
    fan.visible = edge.visible = opt.rays;
    if (opt.rays) {
      fanPos[0] = hp.x;
      fanPos[1] = 0.04;
      fanPos[2] = hp.y;
      for (let r = 0; r <= RAYS; r++) {
        const an = (r / RAYS) * TAU;
        const cx = Math.cos(an);
        const cz = Math.sin(an);
        let L = 0;
        while (L < R2 && !blocked(hp.x + cx * L, hp.y + cz * L)) L += 0.06;
        fanPos.set([hp.x + cx * L, 0.04, hp.y + cz * L], (r + 1) * 3);
      }
      fanG.attributes.position!.needsUpdate = true;
      edgeG.attributes.position!.needsUpdate = true;
    }
    miniU.uHero.value.set((hp.x + MW / 2) / MW, (hp.y + MH / 2) / MH);
    mini.visible = opt.mini;
    // 카메라는 주인공을 따라
    camT.set(hp.x, 0, hp.y + 0.3);
    if (!camInit) {
      cam.position.set(camT.x, 7.2, camT.z + 5.4);
      camInit = true;
    }
    cam.position.lerp(TV.set(camT.x, 7.2, camT.z + 5.4), Math.min(1, dt * 3));
    cam.lookAt(cam.position.x, 0, cam.position.z - 5.4);
  };
  const controls: Control[] = [
    { type: 'range', label: '시야 반지름 (칸)', min: 2, max: 12, step: 0.5, value: 6.5, on: (v) => ((opt.radius = v), (hud.dirty = true)) },
    { type: 'toggle', label: '광선(시야 부채) 보기', value: true, on: (v) => (opt.rays = v) },
    { type: 'toggle', label: '미니맵', value: true, on: (v) => ((opt.mini = v), (hud.dirty = true)) },
    { type: 'button', label: '지도 지우기 (처음부터)', on: () => resetMap() },
  ];
  return {
    scene,
    camera: cam,
    update,
    render: (r) => painter.draw(r, scene, cam, hud),
    controls,
    dispose: () => {
      bin.dispose();
      hud.dispose();
      walls.dispose();
    },
  };
}
```

## 관련 기술
- 먼저 알면 좋은 기술: [재질에 셰이더 끼워 넣기](https://ai-techstudio.web.app/ai/t/u08.md) `u08`
- 다음에 해 볼 기술: [가리는 벽 투명 처리](https://ai-techstudio.web.app/ai/t/i488.md) `i488` · [쿼터뷰 던전 조명 (횃불 · 그림자 · 안개)](https://ai-techstudio.web.app/ai/t/i477.md) `i477`
