# AI 꾸러미 — 후처리 외곽선 (깊이 · 법선 경계) — Post-process edge detection outline
> 장면의 깊이 · 법선 그림을 한 번 더 그려 값이 갑자기 바뀌는 곳에 선을 그어, 얇은 표지판 · 바닥 선까지 일정한 굵기의 만화 외곽선을 넣는다.  
> 견본: https://ai-techstudio.web.app/#t/i03

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

## 주문서

### 만들어 줘: 후처리 외곽선 (깊이 · 법선 경계) — Post-process edge detection outline

#### 1. 목표
툰 주차장 장면 (차 · 고깔 · 얇은 표지판)에 후처리 외곽선을 넣어 줘 — 깊이 · 법선이 갑자기 바뀌는 곳에 일정한 굵기의 선. 느낌은 밝은 애니메이션.

#### 2. 핵심 기술 용어
- **Post-process edge detection outline** — 후처리 외곽선 — 화면에서 경계 찾기
- **Depth discontinuity (Laplacian)** — 깊이가 갑자기 바뀌는 곳 — 물체 윤곽
- **Normal discontinuity** — 법선이 갑자기 바뀌는 곳 — 모서리 · 접힌 곳
- **overrideMaterial = MeshNormalMaterial** — 장면을 법선 색으로 한 번 더 그리기

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

#### 4. 조건
- 법선 · 깊이 그리기 동안 scene.background = null · overrideMaterial = MeshNormalMaterial · 투명 지우기, 끝나면 되돌리기
- 깊이는 선형으로 바꾼 뒤 1/거리 로 비교 — 멀리 있는 것도 같은 문턱
- 선 굵기(이웃 간격)는 화면 높이에 비례 (thick × max(1, 높이/420))
- 렌더 타깃 크기는 화면 크기를 따라가게
- 왼쪽 선 없음 · 오른쪽 후처리 외곽선, 「선 굵기」 0.5~3

#### 5. 완성 기준 (이게 보이면 성공)
- 오른쪽에선 차 · 고깔 · 바닥 주차선 · 얇은 표지판 모두 같은 굵기의 남색 선이 둘러진다
- 차의 지붕과 몸체가 꺾이는 곳(법선)에도 선이 보인다
- 「선 굵기」를 올리면 모든 선이 함께 굵어진다

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

## 원리
- 장면을 MeshNormalMaterial 로 한 번 더 그려 법선 그림과 깊이 그림(DepthTexture)을 얻는다.
- 깊이: 1/선형 깊이의 이웃 넷 라플라시안(왼+오−2·가운데, 위+아래−2·가운데)을 가운데 값으로 나눠, 0.02~0.06 을 넘으면 윤곽이다.
- 법선: 이웃 넷과의 법선 거리 합이 0.5~0.9 를 넘으면 모서리다.
- 둘 중 큰 값만큼 원래 색을 잉크 색으로 섞는다. 이웃 간격이 굵기라 물체 크기와 상관없이 일정하다.
- 껍데기 방식이 못 하는 얇은 판(표지판 0.015)에도 선이 생긴다.

## 핵심 코드 — 법선 · 깊이 그리기 + 경계 셰이더
(발췌: demos/demosLight.ts i03 make() 를 정리)
```ts
const rtN = new THREE.WebGLRenderTarget(1, 1, { depthTexture: new THREE.DepthTexture(1, 1) });
const normalM = new THREE.MeshNormalMaterial();
const EDGE = new THREE.ShaderMaterial({
  uniforms: { tDiffuse: { value: null }, tN: { value: rtN.texture }, tD: { value: rtN.depthTexture },
    res: { value: new THREE.Vector2() }, near: { value: 0.1 }, far: { value: 40 }, thick: { value: 1 }, ink: { value: new THREE.Color(0x1d2340) } },
  vertexShader: 'varying vec2 vUv; void main(){ vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }',
  fragmentShader: [
    'uniform sampler2D tDiffuse; uniform sampler2D tN; uniform sampler2D tD; uniform vec2 res; uniform float near; uniform float far; uniform float thick; uniform vec3 ink; varying vec2 vUv;',
    'float iz(vec2 uv){ float z = texture2D(tD, uv).x * 2.0 - 1.0; float lin = (2.0 * near * far) / (far + near - z * (far - near)); return 1.0 / lin; }',
    'vec3 nn(vec2 uv){ return texture2D(tN, uv).rgb * 2.0 - 1.0; }',
    'void main(){ vec4 base = texture2D(tDiffuse, vUv); vec2 px = thick / res; float w0 = iz(vUv);',
    ' float lap = abs(iz(vUv - vec2(px.x, 0.0)) + iz(vUv + vec2(px.x, 0.0)) - 2.0 * w0) + abs(iz(vUv - vec2(0.0, px.y)) + iz(vUv + vec2(0.0, px.y)) - 2.0 * w0);',
    ' float de = smoothstep(0.02, 0.06, lap / max(w0, 1e-4)); vec3 n0 = nn(vUv);',
    ' float ne = distance(nn(vUv - vec2(px.x, 0.0)), n0) + distance(nn(vUv + vec2(px.x, 0.0)), n0) + distance(nn(vUv - vec2(0.0, px.y)), n0) + distance(nn(vUv + vec2(0.0, px.y)), n0);',
    ' float e = max(de, smoothstep(0.5, 0.9, ne));',
    ' gl_FragColor = vec4(mix(base.rgb, ink, e), base.a); }'].join(' '),
});
const edge = new ShaderPass(EDGE); // RenderPass 뒤, OutputPass 앞

function frame(dt: number, w: number, h: number) {
  if (rtN.width !== w || rtN.height !== h) rtN.setSize(w, h);
  const bg = scene.background;               // 법선 · 깊이 한 번 더 그리기
  scene.background = null;
  scene.overrideMaterial = normalM;
  renderer.setRenderTarget(rtN);
  renderer.setClearColor(0x000000, 0);
  renderer.clear();
  renderer.render(scene, cam);
  renderer.setRenderTarget(null);
  scene.overrideMaterial = null;
  scene.background = bg;
  (edge.uniforms.res.value as THREE.Vector2).set(w, h);
  edge.uniforms.thick.value = 1.2 * Math.max(1, h / 420);
  comp.render(dt);
}
```

## 흔한 실수 · 확인 목록
- [ ] **깊이를 그대로 비교하면 먼 물체엔 선이 안 생기거나 가까이엔 너무 많다** — 선형 깊이로 바꾼 뒤 1/거리 의 라플라시안을 가운데 값으로 나눠 비교한다.
- [ ] **법선 그리기 때 배경 · 지우기 색을 되돌리지 않으면 다음 그림이 깨진다** — 배경 · overrideMaterial · 지우기 색을 저장했다가 끝나면 되돌린다.
- [ ] **반투명 · 스프라이트가 법선 그림에 검게 찍혀 엉뚱한 선이 생긴다** — 그 그리기 동안 반투명 · 스프라이트를 숨긴다 (공용 후처리와 같은 이유).
- [ ] **평평한 바닥에서 가까운 곳에 줄무늬 선이 생긴다** — 깊이 문턱(0.02)을 조금 올리거나 near 를 키운다.

## 완성 기준 체크리스트
- [ ] 오른쪽에선 차 · 고깔 · 바닥 주차선 · 얇은 표지판 모두 같은 굵기의 남색 선이 둘러진다
- [ ] 차의 지붕과 몸체가 꺾이는 곳(법선)에도 선이 보인다
- [ ] 「선 굵기」를 올리면 모든 선이 함께 굵어진다

## 이 기술 정보
- id: `i03` · 분류: 그래픽 · 셰이더 › 후처리 · 3D · 난이도 보통 · 폰 부담 보통 (폰 주의) — 법선 · 깊이용으로 장면을 한 번 더 그리고, 화면 픽셀마다 이웃 8번 읽기. 물체 수와는 상관없다.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i03
- 쓰면 좋을 때: 얇은 판 · 바닥 선이 많은 툰 장면 / 물체 수가 많아 껍데기(u02)로 그리기 횟수가 두 배가 되는 게 부담일 때
- 쓰지 말 때: 물체마다 선 색 · 굵기를 달리해야 할 때 — 대신 껍데기 외곽선(u02) / 폰 저사양 — 장면을 한 번 더 그린다

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### i03 견본 항목 — `src/demos/demosLight.ts:1712`
```ts
  i03: {
    kind: '3d',
    caption: '왼쪽 선 없음 · 오른쪽 깊이 · 법선이 갑자기 바뀌는 곳에 선 — 굵기가 일정하고 얇은 표지판도 선이 생겨요',
    make() {
      const scene = new THREE.Scene();
      scene.background = gradTex('#bfe6ff', '#f2fbff');
      basicLights(scene, 1.0, 1.6, V3(-3, 6, 3));
      const toon = (c: number): THREE.MeshToonMaterial => new THREE.MeshToonMaterial({ color: c });
      const ground = mesh(new THREE.BoxGeometry(6, 0.1, 4.4), toon(0x8d94a6), 0, -0.05, 0);
      scene.add(ground);
      const lineM = toon(0xffffff);
      for (let i = -2; i <= 2; i++) scene.add(mesh(new THREE.BoxGeometry(0.06, 0.02, 1.4), lineM, i * 1.15, 0.01, -1.2));
      const car = (c: number, x: number, z: number, ry: number): THREE.Group => {
        const g = new THREE.Group();
        g.add(mesh(new RoundedBoxGeometry(0.75, 0.32, 1.2, 2, 0.08), toon(c), 0, 0.28, 0));
        g.add(mesh(new RoundedBoxGeometry(0.6, 0.28, 0.6, 2, 0.08), toon(0xdff4ff), 0, 0.55, -0.05));
        const wg = new THREE.CylinderGeometry(0.15, 0.15, 0.12, 20);
        wg.rotateZ(Math.PI / 2);
        for (const [wx, wz] of [
          [-0.38, 0.38],
          [0.38, 0.38],
          [-0.38, -0.38],
          [0.38, -0.38],
        ] as const)
          g.add(mesh(wg, toon(0x2a2a3a), wx, 0.15, wz));
        g.position.set(x, 0, z);
        g.rotation.y = ry;
        return g;
      };
      const cars = [car(0xff5d5d, -0.57, -1.2, 0), car(0x4aa3ff, 1.72, -1.2, 0), car(0xffd23f, 0.3, 0.9, Math.PI / 2)];
      scene.add(...cars);
      for (const [x, z] of [
        [-2.2, 0.8],
        [-1.6, 1.4],
      ] as const) {
        const cone = mesh(new THREE.ConeGeometry(0.16, 0.45, 20), toon(0xff8a3a), x, 0.25, z);
        scene.add(cone);
      }
      const sign = new THREE.Group();
      sign.add(mesh(new THREE.BoxGeometry(0.6, 0.6, 0.015), toon(0x3ec46d), 0, 1.0, 0));
      sign.add(mesh(new THREE.CylinderGeometry(0.03, 0.03, 0.75, 10), toon(0xdddddd), 0, 0.37, -0.02));
      sign.position.set(2.2, 0, 1.1);
      scene.add(sign);
      const cam = new THREE.PerspectiveCamera(36, 1, 0.1, 40);
      const rtN = new THREE.WebGLRenderTarget(1, 1, { depthTexture: new THREE.DepthTexture(1, 1) });
      const normalM = new THREE.MeshNormalMaterial();
      let thick = 1.2;
      const EDGE = new THREE.ShaderMaterial({
        uniforms: {
          tDiffuse: { value: null },
          tN: { value: rtN.texture },
          tD: { value: rtN.depthTexture },
          res: { value: new THREE.Vector2(1, 1) },
          near: { value: 0.1 },
          far: { value: 40 },
          thick: { value: 1 },
          ink: { value: new THREE.Color(0x1d2340) },
        },
        vertexShader: VERT,
        fragmentShader: `uniform sampler2D tDiffuse; uniform sampler2D tN; uniform sampler2D tD; uniform vec2 res; uniform float near; uniform float far; uniform float thick; uniform vec3 ink; varying vec2 vUv;
          float iz(vec2 uv){ float z = texture2D(tD, uv).x * 2.0 - 1.0; float lin = (2.0 * near * far) / (far + near - z * (far - near)); return 1.0 / lin; }
          vec3 nn(vec2 uv){ return texture2D(tN, uv).rgb * 2.0 - 1.0; }
          void main(){
            vec4 base = texture2D(tDiffuse, vUv);
            vec2 px = thick / res;
            float w0 = iz(vUv);
            float wl = iz(vUv - vec2(px.x, 0.0)); float wr = iz(vUv + vec2(px.x, 0.0));
            float wd = iz(vUv - vec2(0.0, px.y)); float wu = iz(vUv + vec2(0.0, px.y));
            float lap = abs(wl + wr - 2.0 * w0) + abs(wd + wu - 2.0 * w0);
            float de = smoothstep(0.02, 0.06, lap / max(w0, 1e-4));
            vec3 n0 = nn(vUv);
            float ne = distance(nn(vUv - vec2(px.x, 0.0)), n0) + distance(nn(vUv + vec2(px.x, 0.0)), n0) + distance(nn(vUv - vec2(0.0, px.y)), n0) + distance(nn(vUv + vec2(0.0, px.y)), n0);
            float e = max(de, smoothstep(0.5, 0.9, ne));
            gl_FragColor = vec4(mix(base.rgb, ink, e), base.a);
          }`,
      });
      const kit = postKit((r) => {
        const edge = new ShaderPass(EDGE);
        return { comp: splitChain(r, scene, cam, [edge]), edge };
      });
      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');
      let now = 0;
      let dt = 0.016;
      const clear = new THREE.Color();
      return {
        scene,
        camera: cam,
        update(t, d) {
          now = t;
          dt = d;
          cars[2]!.position.x = 0.3 + Math.sin(t * 0.8) * 1.3;
          sign.rotation.y = Math.sin(t * 0.7) * 0.9;
        },
        render(r, w, h) {
          const a = Math.sin(now * 0.25) * 0.35;
          fit(cam, w / h, V3(0, 0.2, 0), 2.6, V3(Math.sin(a), 1.0, Math.cos(a)));
          if (rtN.width !== w || rtN.height !== h) rtN.setSize(w, h);
          // 법선 · 깊이 한 번 더 그리기
          const bg = scene.background;
          r.getClearColor(clear);
          const ca = r.getClearAlpha();
          scene.background = null;
          scene.overrideMaterial = normalM;
          r.setClearColor(0x000000, 0);
          r.setRenderTarget(rtN);
          r.clear();
          r.render(scene, cam);
          r.setRenderTarget(null);
          scene.overrideMaterial = null;
          scene.background = bg;
          r.setClearColor(clear, ca);
          const k = kit.get(r, w, h);
          const u = k.edge.uniforms as Record<string, { value: unknown }>;
          (u['res']!.value as THREE.Vector2).set(w, h);
          u['thick']!.value = thick * Math.max(1, h / 420);
          k.comp.render(dt);
          hud.draw(r, w, h);
        },
        controls: [{ type: 'range', label: '선 굵기', min: 0.5, max: 3, step: 0.1, value: 1.2, on: (v) => (thick = v) }],
        dispose() {
          kit.dispose();
          rtN.depthTexture?.dispose();
          rtN.dispose();
          normalM.dispose();
          hud.dispose();
          disposeTree(scene);
        },
      };
    },
  }
```

## 관련 기술
- 먼저 알면 좋은 기술: [외곽선 (뒤집은 껍데기)](https://ai-techstudio.web.app/ai/t/u02.md) `u02` · [후처리 사슬 (EffectComposer → OutputPass)](https://ai-techstudio.web.app/ai/t/u21.md) `u21`
- 다음에 해 볼 기술: [저해상 3D 도트 + 1px 외곽선](https://ai-techstudio.web.app/ai/t/i239.md) `i239` · [계단 줄이기 (SMAA · FXAA · 다중 샘플)](https://ai-techstudio.web.app/ai/t/i04.md) `i04`
- 참고 문서: [three.js 예제 — webgl_postprocessing_sobel (화면 경계 찾기)](https://threejs.org/examples/#webgl_postprocessing_sobel)
