# AI 꾸러미 — 접촉 그림자 (Contact shadow) — Contact shadow (baked depth shadow)
> 바닥 아래에서 위로 깊이를 한 장 찍어 두 번 흐려 바닥에 깔아, 발밑에 부드러운 그림자가 생기고 높이 뛰면 옅어지게 한다.  
> 견본: https://ai-techstudio.web.app/#t/i15

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

## 주문서

### 만들어 줘: 접촉 그림자 (Contact shadow) — Contact shadow (baked depth shadow)

#### 1. 목표
뛰는 캐릭터 셋 발밑에 접촉 그림자(아래에서 찍어 흐린 판)를 깔아 부드러운 스튜디오 사진 느낌으로 바닥에 붙어 보이게 해 줘.

#### 2. 핵심 기술 용어
- **Contact shadow (baked depth shadow)** — 닿은 곳 그림자 — 아래에서 찍은 깊이를 흐린 판
- **OrthographicCamera from below** — 바닥에서 위를 보는 직교 카메라
- **MeshDepthMaterial + onBeforeCompile** — 깊이를 검은색 투명도로 바꿔 찍기
- **HorizontalBlurShader / VerticalBlurShader** — 가로 · 세로 두 번 흐리기

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

#### 4. 조건
- 찍을 때 장면 배경을 null · 바닥과 그림자 판은 숨기고 overrideMaterial 로 깊이 재질 — 끝나면 모두 되돌리기
- 깊이 재질은 gl_FragColor 를 vec4(vec3(0.0), (1.0 - fragCoordZ) * darkness) 로 (견본 진하기 1.4)
- 흐리기는 가로 · 세로 두 렌더 타깃을 오가며 두 번 (세기 / 256)
- 그림자 판은 scale.y = -1 (아래서 찍어 뒤집힘), depthWrite false, renderOrder 1, 바닥보다 0.002 위
- 클리어 색을 투명(0x000000, 0)으로 바꿨다 되돌리기

#### 5. 완성 기준 (이게 보이면 성공)
- 왼쪽 그림자 없음 · 오른쪽 접촉 그림자를 나란히 두면, 오른쪽 캐릭터만 바닥에 서 있어 보인다
- 캐릭터가 높이 뛸수록 발밑 그림자가 넓어지고 옅어진다
- 「흐림」 0 ~ 10 · 「진하기」 0.2 ~ 3 슬라이더로 바로 바뀐다
- 폰에서도 60fps

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

## 원리
- 바닥 바로 위에 직교 카메라를 두고 위를 보게 해, 장면을 깊이로 찍는다 (견본 판 5 × 5, 높이 1.4 까지).
- 깊이 재질을 고쳐 「가까울수록 진한 검정」 투명도로 찍는다 — 바닥에 닿은 곳일수록 진하다.
- 그 그림을 가로 · 세로로 두 번 흐린다 (세기 3.5 와 그 0.4배). 그러면 높이 뜬 것일수록 넓고 옅게 번진다.
- 흐린 그림을 바닥 판에 붙여 반투명(0.85)으로 깐다. 그림자 지도 없이 바닥 한 장으로 끝난다.

## 핵심 코드 — 아래에서 깊이 찍기 → 흐리기 → 바닥에 깔기
(발췌: demos/demosLight.ts i15 make() 를 정리 (three.js contact shadows 예제 방식))
```ts
import * as THREE from 'three';
import { HorizontalBlurShader } from 'three/examples/jsm/shaders/HorizontalBlurShader.js';
import { VerticalBlurShader } from 'three/examples/jsm/shaders/VerticalBlurShader.js';

const SW = 5, CH = 1.4; // 판 크기 · 찍는 높이
const rt = new THREE.WebGLRenderTarget(512, 512), rtB = new THREE.WebGLRenderTarget(512, 512);
const group = new THREE.Group();
group.position.y = 0.002;
const planeGeo = new THREE.PlaneGeometry(SW, SW).rotateX(Math.PI / 2);
const plane = new THREE.Mesh(planeGeo, new THREE.MeshBasicMaterial({ map: rt.texture, opacity: 0.85, transparent: true, depthWrite: false }));
plane.renderOrder = 1;
plane.scale.y = -1; // 아래에서 찍어 뒤집힌 그림
const blurPlane = new THREE.Mesh(planeGeo);
blurPlane.visible = false;
const shadowCam = new THREE.OrthographicCamera(-SW / 2, SW / 2, SW / 2, -SW / 2, 0, CH);
shadowCam.rotation.x = Math.PI / 2; // 위를 본다
group.add(plane, blurPlane, shadowCam);
scene.add(group);

// 깊이 → 가까울수록 진한 검정 투명도
const dark = { value: 1.4 };
const depthM = new THREE.MeshDepthMaterial();
depthM.onBeforeCompile = (sh) => {
  sh.uniforms['darkness'] = dark;
  sh.fragmentShader = 'uniform float darkness;' + sh.fragmentShader.replace(
    'gl_FragColor = vec4( vec3( 1.0 - fragCoordZ ), opacity );',
    'gl_FragColor = vec4( vec3( 0.0 ), ( 1.0 - fragCoordZ ) * darkness );');
};
const hB = new THREE.ShaderMaterial(HorizontalBlurShader), vB = new THREE.ShaderMaterial(VerticalBlurShader);
function blurOnce(amount: number): void {
  blurPlane.visible = true;
  blurPlane.material = hB; hB.uniforms['tDiffuse']!.value = rt.texture; hB.uniforms['h']!.value = amount / 256;
  renderer.setRenderTarget(rtB); renderer.render(blurPlane, shadowCam);
  blurPlane.material = vB; vB.uniforms['tDiffuse']!.value = rtB.texture; vB.uniforms['v']!.value = amount / 256;
  renderer.setRenderTarget(rt); renderer.render(blurPlane, shadowCam);
  blurPlane.visible = false;
}
// 매 장면: 배경 null · 바닥/판 숨김 · scene.overrideMaterial = depthM · 투명 클리어 → rt 에 render
// → overrideMaterial = null → blurOnce(3.5) → blurOnce(3.5 * 0.4) → 모두 되돌리고 본 장면 그리기
```

## 흔한 실수 · 확인 목록
- [ ] **찍을 때 바닥을 숨기지 않으면 판 전체가 까맣다** — 바닥도 「가까운 것」으로 찍힌다. 찍는 동안 바닥 · 그림자 판 · 배경을 숨긴다.
- [ ] **클리어 색을 되돌리지 않으면 다음 장면 배경이 투명해진다** — getClearColor · getClearAlpha 로 저장했다가 끝에 setClearColor 로 되돌린다.
- [ ] **판을 뒤집지 않으면 그림자가 좌우가 바뀐 자리에 생긴다** — 아래에서 찍었으니 plane.scale.y = -1.

## 완성 기준 체크리스트
- [ ] 왼쪽 그림자 없음 · 오른쪽 접촉 그림자를 나란히 두면, 오른쪽 캐릭터만 바닥에 서 있어 보인다
- [ ] 캐릭터가 높이 뛸수록 발밑 그림자가 넓어지고 옅어진다
- [ ] 「흐림」 0 ~ 10 · 「진하기」 0.2 ~ 3 슬라이더로 바로 바뀐다
- [ ] 폰에서도 60fps

## 이 기술 정보
- id: `i15` · 분류: 그래픽 · 셰이더 › 빛 · 환경 · 3D · 난이도 보통 · 폰 부담 가벼움 (폰 OK) — 512² 깊이 한 장 + 흐리기 4번(가로 · 세로 × 2). 그림자 지도(장면 다시 그리기 + 비교)보다 가볍다. 움직임이 없으면 다시 찍지 않아도 된다.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i15
- 쓰면 좋을 때: 폰에서 그림자 지도 없이 「붙어 있는 느낌」만 필요할 때 / 캐릭터 · 말이 바닥에 떠 보일 때 / 해 방향이 중요하지 않은 진열 · 메뉴 화면
- 쓰지 말 때: 해 방향 · 긴 그림자가 보여야 할 때 — 대신 그림자 지도(u17) / 물체가 넓은 판 전체에 흩어진 큰 장면 — 판 해상도가 모자란다

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### fit — `src/demos/demosLight.ts:80`
```ts
function fit(cam: THREE.PerspectiveCamera, aspect: number, target: THREE.Vector3, radius: number, dir: THREE.Vector3): THREE.PerspectiveCamera {
  cam.aspect = aspect;
  cam.updateProjectionMatrix();
  const v = THREE.MathUtils.degToRad(cam.fov) / 2;
  const hf = Math.atan(Math.tan(v) * aspect);
  const d = radius / Math.sin(Math.min(v, hf));
  cam.position.copy(target).addScaledVector(dir.clone().normalize(), d);
  cam.lookAt(target);
  return cam;
}
```

### i15 견본 항목 — `src/demos/demosLight.ts:2434`
```ts
  i15: {
    kind: '3d',
    caption: '왼쪽 그림자 없음(둥둥 떠 보임) · 오른쪽 접촉 그림자 — 아래에서 한 장 찍어 흐린 그림자, 높이 뛰면 옅어져요',
    make() {
      const build = (): { scene: THREE.Scene; chars: THREE.Group[]; cube: THREE.Mesh; ground: THREE.Mesh } => {
        const scene = new THREE.Scene();
        scene.background = gradTex('#d9ccff', '#fff6ea');
        basicLights(scene, 1.0, 1.4, V3(-2, 5, 3));
        const ground = new THREE.Mesh(new THREE.CircleGeometry(2.6, 64), std(0xfff3e2, 0.95));
        ground.rotation.x = -Math.PI / 2;
        scene.add(ground);
        const chars = [cutie(0xff8fb1), cutie(0x6fd3c7), cutie(0xffc94a)];
        chars.forEach((c, i) => {
          c.position.set((i - 1) * 1.1, 0, i === 1 ? -0.4 : 0.3);
          scene.add(c);
        });
        const cube = mesh(new RoundedBoxGeometry(0.45, 0.45, 0.45, 2, 0.06), std(0x4aa3ff, 0.5), 1.6, 0.6, -0.8);
        scene.add(cube);
        return { scene, chars, cube, ground };
      };
      const A = build();
      const B = build();
      // 접촉 그림자 (three 예제 방식)
      const SW = 5;
      const CH = 1.4;
      const rt = new THREE.WebGLRenderTarget(512, 512);
      rt.texture.generateMipmaps = false;
      const rtB = new THREE.WebGLRenderTarget(512, 512);
      rtB.texture.generateMipmaps = false;
      const group = new THREE.Group();
      group.position.y = 0.002;
      const planeGeo = new THREE.PlaneGeometry(SW, SW).rotateX(Math.PI / 2);
      const plane = new THREE.Mesh(planeGeo, new THREE.MeshBasicMaterial({ map: rt.texture, opacity: 0.85, transparent: true, depthWrite: false }));
      plane.renderOrder = 1;
      plane.scale.y = -1;
      group.add(plane);
      const blurPlane = new THREE.Mesh(planeGeo);
      blurPlane.visible = false;
      group.add(blurPlane);
      const shadowCam = new THREE.OrthographicCamera(-SW / 2, SW / 2, SW / 2, -SW / 2, 0, CH);
      shadowCam.rotation.x = Math.PI / 2;
      group.add(shadowCam);
      B.scene.add(group);
      const depthM = new THREE.MeshDepthMaterial();
      const dark = { value: 1.4 };
      depthM.onBeforeCompile = (sh) => {
        sh.uniforms['darkness'] = dark;
        sh.fragmentShader = `uniform float darkness;\n${sh.fragmentShader.replace('gl_FragColor = vec4( vec3( 1.0 - fragCoordZ ), opacity );', 'gl_FragColor = vec4( vec3( 0.0 ), ( 1.0 - fragCoordZ ) * darkness );')}`;
      };
      depthM.depthTest = false;
      depthM.depthWrite = false;
      const hB = new THREE.ShaderMaterial(HorizontalBlurShader);
      hB.depthTest = false;
      const vB = new THREE.ShaderMaterial(VerticalBlurShader);
      vB.depthTest = false;
      let blur = 3.5;
      const blurOnce = (r: R, amount: number): void => {
        blurPlane.visible = true;
        blurPlane.material = hB;
        hB.uniforms['tDiffuse']!.value = rt.texture;
        hB.uniforms['h']!.value = amount / 256;
        r.setRenderTarget(rtB);
        r.render(blurPlane, shadowCam);
        blurPlane.material = vB;
        vB.uniforms['tDiffuse']!.value = rtB.texture;
        vB.uniforms['v']!.value = amount / 256;
        r.setRenderTarget(rt);
        r.render(blurPlane, shadowCam);
        blurPlane.visible = false;
      };
      const cam = new THREE.PerspectiveCamera(34, 1, 0.1, 40);
      const hud = new Hud();
      hud.vline(0.5);
      hud.label('그림자 없음', 0.25, 0.95, 0.5, 1);
      hud.label('접촉 그림자', 0.75, 0.95, 0.5, 1, 'on');
      const clear = new THREE.Color();
      return {
        scene: A.scene,
        camera: cam,
        update(t) {
          for (const S of [A, B]) {
            S.chars.forEach((c, i) => {
              c.position.y = Math.max(0, Math.sin(t * 2.2 + i * 2.1)) * 0.75;
              c.rotation.y = Math.sin(t * 0.6 + i) * 0.5;
            });
            S.cube.position.y = 0.45 + Math.sin(t * 1.3) * 0.35;
            S.cube.rotation.set(t * 0.5, t * 0.7, 0);
          }
        },
        render(r, w, h) {
          // 아래에서 깊이 찍기 → 두 번 흐리기
          const bg = B.scene.background;
          B.scene.background = null;
          B.scene.overrideMaterial = depthM;
          plane.visible = false;
          B.ground.visible = false;
          r.getClearColor(clear);
          const ca = r.getClearAlpha();
          r.setClearColor(0x000000, 0);
          r.setRenderTarget(rt);
          r.clear();
          r.render(B.scene, shadowCam);
          B.scene.overrideMaterial = null;
          blurOnce(r, blur);
          blurOnce(r, blur * 0.4);
          r.setRenderTarget(null);
          r.setClearColor(clear, ca);
          B.scene.background = bg;
          plane.visible = true;
          B.ground.visible = true;
          panels(r, w, h, [{ scene: A.scene }, { scene: B.scene }], (a) => fit(cam, a, V3(0, 0.45, 0), 1.9, V3(0, 0.55, 1)));
          hud.draw(r, w, h);
        },
        controls: [
          { type: 'range', label: '흐림', min: 0, max: 10, step: 0.5, value: 3.5, on: (v) => (blur = v) },
          { type: 'range', label: '진하기', min: 0.2, max: 3, step: 0.1, value: 1.4, on: (v) => (dark.value = v) },
        ],
        dispose() {
          rt.dispose();
          rtB.dispose();
          depthM.dispose();
          hB.dispose();
          vB.dispose();
          hud.dispose();
          disposeTree(A.scene, B.scene);
        },
      };
    },
  }
```

## 관련 기술
- 먼저 알면 좋은 기술: [그림자 맵 (PCF · PCF Soft)](https://ai-techstudio.web.app/ai/t/u17.md) `u17`
- 다음에 해 볼 기술: [휘는 지평선 (굴러가는 세계)](https://ai-techstudio.web.app/ai/t/i537.md) `i537`
- 참고 문서: [three.js 예제 — webgl_shadow_contact](https://threejs.org/examples/#webgl_shadow_contact)
