# AI 꾸러미 — 쿼터뷰 던전

## 주문서

### 한 장면 만들기: 쿼터뷰 던전

#### 1. 목표
작은 던전 방 3개짜리 체험판을 만들어 줘 — 비스듬히 내려다보는 쿼터뷰 던전에서 횃불을 든 주인공이 누른 곳으로 걷고, 어둠 속에서 횃불 빛 · 그림자가 주인공을 따라간다. 분위기는 어둡고 묵직한 실사풍.
아래 기술 6개를 한 장면에 엮어 줘. 한 번에 다 만들지 말고 1번부터 차례로 쌓되, 단계마다 화면에서 확인할 수 있게.

#### 2. 들어갈 기술 (쌓는 순서)
##### 1) 쿼터뷰 던전 조명 (횃불 · 그림자 · 안개) — Isometric / three-quarter view camera
- 역할: 분위기의 바탕 — 어둠 · 횃불 빛 · 그림자 · 안개
- 핵심: 바탕 빛(반구광)은 0.2 정도로 아주 어둡게 두고, 따뜻한 점광원(횃불)이 화면의 밝은 곳을 정한다.
- 확인: 횃불 주변만 따뜻하게 밝고 일렁이며, 그 밖은 거의 검게 묻힌다
- 확인: 주인공이 걸으면 주인공 횃불 그림자가 벽 · 기둥에 움직인다
##### 2) 실사 PBR 텍스처 (무료 CC0 스캔 재질) — PBR texture set (albedo · normal · ARM)
- 역할: 돌바닥 · 벽돌 벽 · 나무에 실사 재질
- 핵심: 스캔 재질은 보통 색(diff) · 법선(nor_gl — OpenGL 방식) · ARM 세 장이다. ARM 은 R = AO, G = 거칠기, B = 금속.
- 확인: 같은 장면이 네 칸으로 — ① 단색 ② 색 지도 ③ 법선 ④ AO · 거칠기 · 금속 — 오른쪽으로 갈수록 실사가 된다
- 확인: 낮게 훑는 빛이 지나가면 ③ ④ 칸에서 돌 · 벽돌 틈이 입체로 드러난다
##### 3) 가리는 벽 투명 처리 — Occluder fade (dithered cutout)
- 역할: 카메라와 주인공 사이를 가리는 벽만 점무늬로 비우기
- 핵심: 벽 · 기둥 상자마다 「비울 정도 fade(0~1)」를 정점 속성(aFade)으로 둔다.
- 확인: 기사가 벽 뒤로 걸어 들어가면 그 벽만 기사 둘레가 둥글게 비어 기사가 보인다
- 확인: 벽에 살짝 가린 순간엔 벽 너머로 파란 윤곽선이 보인다
##### 4) 시야와 안개 (가 본 곳만 밝게) — Fog of war (visibility grid)
- 역할: 가 본 곳만 밝히는 시야 안개
- 핵심: 던전은 28×18 칸 격자. 매 장면 주인공 둘레 반지름(6.5칸) 안 칸마다 주인공 → 칸 가운데를 0.18 간격으로 걸어 벽 칸에 막히는지 본다 (벽 칸은 주인공 쪽 면을 본다).
- 확인: 주인공 둘레만 횃불빛으로 밝고, 지나온 방은 푸르스름한 흑백으로 남고, 안 가 본 곳은 검다
- 확인: 벽 모퉁이 뒤는 밝아지지 않는다 (광선 부채가 벽에서 잘린다)
##### 5) 카메라가 벽을 뚫지 않게 — Camera collision (sphere cast)
- 역할: 벽이 카메라를 가로막으면 당겨 오기
- 핵심: 카메라는 주인공 등 뒤를 늦게 따라간다: 바라는 자리 = 주인공 − 바라보는 방향 × 거리(4.6), 높이 2.45.
- 확인: 주인공이 벽 쪽으로 돌아서도 카메라가 벽 앞에 머물러 주인공이 늘 보인다 (끔 쪽은 벽 뒤로 가 가려진다)
- 확인: 벽에서 벗어나면 카메라가 덜컥 튀지 않고 부드럽게 원래 거리로 돌아간다
##### 6) 던전 스킬 연출 (마법 · 피격)
- 역할: 불덩이 · 번개 같은 스킬 연출과 피격 반응
- 핵심: 불덩이 · 회오리 · 번개를 던전 위에 — 바닥 빛 · 그림자 · 맞은 적 반응까지
- 확인: 견본처럼 「불덩이 · 회오리 · 번개를 던전 위에 — 바닥 빛 · 그림자 · 맞은 적 반응까지」 이(가) 첫 화면에서 바로 보인다
- 확인: 켬/끔(또는 전/후) 비교 단추로 이 기술이 만드는 차이를 확인할 수 있다

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

#### 4. 조건 (공통)
- 그림자를 켜는 점광원은 주인공 횃불 하나
- 장면 짓기 · 텍스처 올리기 · 셰이더 굽기는 모두 프레임당 3ms 예산으로 나눠서, 그동안 「준비 중」 화면
- 폰(가로 844×390)에서도 끊기지 않게 — 그림자 지도 512, 텍스처 1k
- 그림자를 켜는 점광원은 하나만 (나머지는 castShadow = false)
- 안개 색 = 배경색, 반구광은 0.2 안팎으로 어둡게
- ARM 한 장을 aoMap · roughnessMap · metalnessMap 에 함께, roughness · metalness 값은 1
- 색 지도만 SRGBColorSpace, 법선 · ARM 은 NoColorSpace
- 가리는 것만 비운다 (광선에 걸린 상자) — 다른 벽은 그대로
- 진짜 투명(transparent) 대신 바이어 점무늬 discard — 깊이 · 그림자 유지
- 시야는 칸 격자로 계산하고 결과는 작은 DataTexture 하나로 (칸마다 물체 색을 바꾸지 않는다)
- 「가 본 곳」은 줄지 않고 「지금 보임」만 오르내린다
- 광선 하나가 아니라 구(반지름 0.25~0.3)로 쓸기 — 광선은 모서리 틈으로 빠져 벽이 화면에 걸린다
- 당길 때와 되돌릴 때 빠르기를 다르게 (당김은 즉시에 가깝게, 되돌림은 천천히)
- 한 프레임 16ms 안 (폰 포함) — 무거운 계산은 처음에 한 번 또는 여러 프레임에 나눠서

#### 5. 완성 기준 (전체)
- 바닥을 누르면 주인공이 그곳으로 걷고, 카메라가 부드럽게 따라간다
- 주인공이 벽 · 기둥 뒤로 가면 가리는 부분만 비워져 주인공이 늘 보인다
- 가 본 곳 · 지금 보이는 곳 · 모르는 곳이 세 단계 어둠으로 구분된다
- 스킬 단추를 누르면 바닥 빛 · 맞은 적 반응이 있는 마법 연출이 나온다
- 기술마다 켬/끔 단추가 있어 하나씩 꺼 보며 그 기술이 맡은 차이를 확인할 수 있다
- 콘솔 오류 0 · 폰에서도 끊기지 않는다

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

## 참고 1. 쿼터뷰 던전 조명 (횃불 · 그림자 · 안개) — 어두운 바탕 + 일렁이는 횃불 3개 (그림자는 하나) + 안개 + 쿼터뷰 카메라
```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.
- [ ] **어둠을 비네트 · 가장자리 흐림으로 만들면 답답하고 흐려 보인다** — 이 사이트에서 금지한 효과. 어둠은 빛 배치 · 안개로 만든다.

## 참고 2. 실사 PBR 텍스처 (무료 CC0 스캔 재질) — 스캔 재질 3장 → MeshStandardMaterial (ARM 한 장을 세 칸에)
```ts
const loader = new THREE.TextureLoader();
function tex(url: string, isColor: boolean): THREE.Texture {
  const t = loader.load(url);
  t.colorSpace = isColor ? THREE.SRGBColorSpace : THREE.NoColorSpace; // 데이터는 색 변환 금지
  t.wrapS = t.wrapT = THREE.RepeatWrapping;
  t.anisotropy = 16; // 렌더러가 최대치로 자른다
  return t;
}
const brick = {
  map: tex('/polyhaven/castle_brick_07_diff_1k.jpg', true),
  normal: tex('/polyhaven/castle_brick_07_nor_gl_1k.jpg', false),  // OpenGL 방식 법선
  arm: tex('/polyhaven/castle_brick_07_arm_1k.jpg', false),        // R AO · G 거칠기 · B 금속
};
// 같은 그림을 반복만 다르게 — source 를 나눠 써서 GPU 에 다시 올리지 않는다
function rep(t: THREE.Texture, x: number, y = x): THREE.Texture {
  const c = new THREE.Texture();
  c.source = t.source;
  c.colorSpace = t.colorSpace;
  c.wrapS = c.wrapT = THREE.RepeatWrapping;
  c.anisotropy = t.anisotropy;
  c.repeat.set(x, y);
  c.version = Math.max(1, t.version);
  return c;
}
function pbrMat(p: typeof brick, rx: number, ry = rx): THREE.MeshStandardMaterial {
  const arm = rep(p.arm, rx, ry);
  return new THREE.MeshStandardMaterial({
    map: rep(p.map, rx, ry),
    normalMap: rep(p.normal, rx, ry),
    aoMap: arm, roughnessMap: arm, metalnessMap: arm, // 한 장을 세 칸에 (채널은 three 가 알아서)
    roughness: 1, metalness: 1,                        // 지도 값 그대로
  });
}
const wall = new THREE.Mesh(new THREE.BoxGeometry(6.6, 2.7, 0.3), pbrMat(brick, 3.2, 1.3));
scene.add(wall);
// 첫 화면 멈춤 막기: renderer.initTexture(t) 를 프레임당 3ms 씩 → await renderer.compileAsync(scene, camera)
```
- [ ] **법선 · ARM 에 sRGB 를 걸면 요철이 뭉개지고 거칠기가 틀어진다** — 색 지도만 SRGBColorSpace, 나머지는 NoColorSpace.
- [ ] **metalness 를 기본값 0 으로 두면 금속 지도가 0 과 곱해져 금속이 사라진다** — 지도 값은 재질 값과 곱해진다. 지도를 쓸 땐 roughness = 1, metalness = 1 로 두어 지도 값이 그대로 쓰이게 한다.
- [ ] **같은 그림을 물체마다 clone 해서 needsUpdate 하면 GPU 에 여러 번 올라간다** — source 를 나눠 쓰는 복사 텍스처를 만들고 version 만 맞춘다 (견본 rep).
- [ ] **1K 텍스처 열두 장을 한 프레임에 올리면 화면이 멈춘다** — initTexture 를 프레임당 3ms 로 나누고, 셰이더는 compileAsync 로 미리 굽는다.
- [ ] **DirectX 법선(nor_dx)을 받으면 요철이 뒤집혀 보인다** — nor_gl 을 받거나 normalScale.y = −1 로 뒤집는다.

## 참고 3. 가리는 벽 투명 처리 — 주인공 둘레 화면 원 안을 바이어 점무늬로 비우기 (벽 재질에 끼워 넣기)
```glsl
// TS 쪽 (매 장면):
//  updateOcc — 상자마다 카메라 → 주인공 (발 0.3 · 가슴 1.1 · 머리 1.75) 광선이 0.16 키운 상자와
//              주인공보다 앞에서 만나면 목표 1. fade += (목표 − fade) × (1 − exp(−dt / 0.11)) → aFade 속성
//  uHero = (주인공 화면 픽셀 x, y, 시점 깊이 − 0.3), uRad = 화면 반경 0.22 × 화면 높이, uMode 0 끔 · 1 반투명 · 2 비우기
//  cutify(m): onBeforeCompile 로 아래를 '#include <clipping_planes_fragment>' 뒤에 넣는다
uniform vec3 uHero; uniform float uRad; uniform float uMode;
varying float vFade;                                // 정점 속성 aFade
float bayer2(vec2 a){ a = floor(a); return fract(dot(a, vec2(0.5, a.y * 0.75))); }
float bayer4(vec2 a){ return bayer2(0.5 * a) * 0.25 + bayer2(a); }

void cutOccluder(){
  if (uMode > 0.5 && vFade > 0.002) {
    vec2 d = (gl_FragCoord.xy - uHero.xy) / uRad;
    float rr = length(d * vec2(1.0, 0.78));
    float inside = 1.0 - smoothstep(0.62, 1.0, rr);   // 주인공 둘레 화면 원
    float front = step(vViewPosition.z, uHero.z);     // 주인공보다 카메라 쪽만
    float k = vFade * inside * front;
    float keep = uMode < 1.5 ? 1.0 - k * 0.66 : 1.0 - k;
    if (bayer4(gl_FragCoord.xy) >= keep - 0.001) discard;
  }
}
// 벽 너머 윤곽: 주인공 층만 MeshBasicMaterial({ depthFunc: THREE.GreaterDepth, depthWrite: false,
//   transparent: true, opacity: 0.75, blending: THREE.AdditiveBlending }) 로 한 번, 그다음 보통으로 한 번
```
- [ ] **벽을 transparent 반투명 재질로 바꾸면 그림자 · 정렬이 엉킨다** — 불투명 그대로 두고 바이어 점무늬로 discard — 깊이가 살아 있어 정렬 문제가 없다.
- [ ] **광선 하나(가슴)만 쏘면 발이나 머리만 가린 벽을 놓친다** — 발 · 가슴 · 머리 세 높이로 쏜다.
- [ ] **주인공 뒤쪽 벽까지 비우면 방이 뻥 뚫려 보인다** — 시점 깊이를 비교해 주인공보다 카메라 쪽 조각만 비운다.
- [ ] **fade 를 바로 0/1 로 바꾸면 경계에서 깜빡인다** — 0.11초 시간 상수로 부드럽게 따라가게.

## 참고 4. 시야와 안개 (가 본 곳만 밝게) — 칸마다 시야 재기 → 텍스처 R · G → 재질에서 섞기
```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 를 같은 값으로 — 셰이더 하나를 함께 쓴다.
- [ ] **「가 본 곳」을 검게만 칠하면 기억이 정보가 안 된다** — 흑백에 가까운 푸른 색으로 어둡게 남겨 지형은 알아보게 한다.

## 참고 5. 카메라가 벽을 뚫지 않게 — 구 쓸기 + 이분 탐색 + 당김 · 되돌림 빠르기 다르게
```ts
const damp = (a: number, b: number, k: number, dt: number): number => a + (b - a) * (1 - Math.exp(-k * dt));
const SR = 0.28;                          // 쓸어 보는 구 반지름

/** 주인공 → 바라는 카메라 자리를 구로 쓸어 처음 닿는 거리 */
function sweep(from: THREE.Vector3, to: THREE.Vector3): number {
  const len = from.distanceTo(to);
  const N = 22;
  let free = 0;
  for (let i = 1; i <= N; i++) {
    const k = i / N;
    if (col.sphereHit(lerp3(from, to, k), SR)) {
      let lo = free, hi = k;
      for (let j = 0; j < 6; j++) {        // 막힌 조각 앞뒤를 이분 탐색으로 좁힌다
        const m = (lo + hi) / 2;
        if (col.sphereHit(lerp3(from, to, m), SR)) hi = m; else lo = m;
      }
      return lo * len;
    }
    free = k;
  }
  return len;
}

// 매 장면
pivot.set(hero.x, 1.35, hero.z);
desired.set(hero.x - Math.sin(camYaw) * dist, 2.45, hero.z - Math.cos(camYaw) * dist);
const full = pivot.distanceTo(desired);
const tgt = Math.max(0.55, sweep(pivot, desired));
curD = tgt < curD ? damp(curD, tgt, 30, dt) : damp(curD, tgt, back, dt);   // 당김은 빠르게, 되돌림은 천천히(2.2)
curD = Math.min(curD, full);
camera.position.copy(pivot).lerp(desired, curD / full);
```
- [ ] **광선 하나로만 검사하면 모서리 틈으로 빠져 벽 면이 화면에 걸린다** — 카메라 가까운 면(near)보다 조금 큰 구로 쓸어 본다.
- [ ] **당김 · 되돌림을 같은 빠르기로 하면 벽 뒤가 잠깐 보이거나 덜컥거린다** — 짧아질 땐 거의 즉시(30), 길어질 땐 천천히(2.2).
- [ ] **바라는 자리까지 너무 성기게 나누면 얇은 기둥을 건너뛴다** — 구 반지름보다 촘촘하게 조각을 나누고, 막힌 곳은 이분 탐색으로 다듬는다.

## 참고 6. 던전 스킬 연출 (마법 · 피격) — 
```ts

```

## 들어간 기술 (하나씩 자세히)
- [쿼터뷰 던전 조명 (횃불 · 그림자 · 안개)](https://ai-techstudio.web.app/ai/t/i477.md) `i477`
- [실사 PBR 텍스처 (무료 CC0 스캔 재질)](https://ai-techstudio.web.app/ai/t/i474.md) `i474`
- [가리는 벽 투명 처리](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`
- [던전 스킬 연출 (마법 · 피격)](https://ai-techstudio.web.app/ai/t/i490.md) `i490`
