# AI 꾸러미 — 저해상 3D 도트 + 1px 외곽선 — Low-res pixel-art 3D (pixelation)
> 3D 장면을 화면의 1/4 해상도로 그려 가까운 픽셀 그대로 크게 늘리고, 깊이 차이로 1px 어두운 외곽선 · 법선 차이로 모서리 밝힘을 넣어 도트 그림 같은 3D 를 만든다.  
> 견본: https://ai-techstudio.web.app/#t/i239

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

## 주문서

### 만들어 줘: 저해상 3D 도트 + 1px 외곽선 — Low-res pixel-art 3D (pixelation)

#### 1. 목표
작은 섬 마을 (집 · 나무 · 꼬마)을(를) 저해상 도트 3D 로 그려 줘 — 1/4 해상도로 그려 크게 늘리고 1px 외곽선 · 모서리 밝힘. 분위기는 따뜻한 레트로 도트.

#### 2. 핵심 기술 용어
- **Low-res pixel-art 3D (pixelation)** — 저해상 3D 도트
- **Depth-edge 1px outline** — 깊이 차이로 찾은 1픽셀 외곽선
- **Normal-edge highlight** — 같은 깊이에서 법선이 꺾이는 모서리를 밝게
- **RenderPixelatedPass** — three.js 에 있는 같은 계열 도트화 후처리

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

#### 4. 조건
- 색 렌더 타깃은 DepthTexture 포함 · Nearest 필터, 법선 렌더 타깃은 같은 크기
- 법선 그리기 동안 background = null · overrideMaterial = MeshNormalMaterial · 지우기 색 (0x8080ff), 끝나면 되돌리기
- 도트 크기는 화면 높이에 비례 (round(4 × max(1, 높이/420)))
- 정사영 카메라 + 툰 재질(3단 [0.35, 0.7, 1])
- 「도트 크기」 1~8 · 「1px 외곽선 · 모서리 밝힘」 · 「보통 렌더로 비교」

#### 5. 완성 기준 (이게 보이면 성공)
- 섬 마을이 굵은 도트로 보이고, 집 · 나무 · 꼬마 둘레에 1px 어두운 선이 있다
- 지붕 모서리 · 상자 모서리는 한 줄 밝게 빛나 입체가 산다
- 「보통 렌더로 비교」를 켜면 매끈한 원래 3D 가 보인다

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

## 원리
- 장면을 화면 ÷ 4 크기 렌더 타깃에 그리고(깊이 텍스처 포함), 같은 크기에 법선 그림도 한 번 더 그린다.
- 화면 덮는 사각형 셰이더가 floor(픽셀 / 4) 점 좌표로 읽어, 가까운 픽셀 그대로 커진 도트가 된다.
- 이웃 넷 중 깊이가 0.004 이상 먼 곳이 있으면 내가 앞쪽 경계 — 색을 0.38 배로 어둡게(1px 외곽선).
- 깊이는 비슷한데 법선이 꺾인 이웃이 있으면 모서리 — 1.35 배 밝게.
- 정사영 카메라 · 툰 재질(3단)과 함께 쓰면 또렷한 도트 그림이 된다.

## 핵심 코드 — 작게 그리기(색 · 깊이 · 법선) + 도트 외곽선 셰이더
(발췌: demos/demosLook2.ts makePixel3D() 를 정리)
```ts
const opts = { type: THREE.HalfFloatType, minFilter: THREE.NearestFilter, magFilter: THREE.NearestFilter };
const nMat = new THREE.MeshNormalMaterial();
let rt: THREE.WebGLRenderTarget, rn: THREE.WebGLRenderTarget;

const frag = [
  'uniform sampler2D tC, tD, tN; uniform vec2 uLow; uniform float uPx, uOutline;',
  'void main(){',
  '  vec2 pix = floor(gl_FragCoord.xy / uPx); vec2 uv = (pix + 0.5) / uLow; vec2 e = 1.0 / uLow;',
  '  vec3 c = pow(1.0 - exp(-texture2D(tC, uv).rgb * 1.25), vec3(1.0 / 2.2));',
  '  if (uOutline > 0.5){',
  '    float d = texture2D(tD, uv).r; vec3 n = texture2D(tN, uv).rgb * 2.0 - 1.0; float de = 0.0; float ne = 0.0;',
  '    vec2 off[4]; off[0] = vec2(e.x, 0.0); off[1] = vec2(-e.x, 0.0); off[2] = vec2(0.0, e.y); off[3] = vec2(0.0, -e.y);',
  '    for (int i = 0; i < 4; i++){',
  '      float dn = texture2D(tD, uv + off[i]).r; vec3 nn = texture2D(tN, uv + off[i]).rgb * 2.0 - 1.0;',
  '      de += step(0.004, dn - d);',                                           // 이웃이 더 멀다 = 내가 앞 경계
  '      ne += step(abs(dn - d), 0.004) * step(0.0, dn - d) * (1.0 - smoothstep(0.6, 0.9, dot(n, nn)));',
  '    }',
  '    if (de > 0.0) c *= 0.38; else if (ne > 0.0) c = min(c * 1.35 + 0.05, vec3(1.0));',
  '  }',
  '  gl_FragColor = vec4(c, 1.0);',
  '}'].join(' ');
// quad = 화면 덮는 사각형 (vertex: gl_Position = vec4(position.xy, 0.0, 1.0))

function frame(w: number, h: number) {
  const px = Math.max(1, Math.round(4 * Math.max(1, h / 420)));
  const lw = Math.ceil(w / px), lh = Math.ceil(h / px);
  if (!rt || rt.width !== lw || rt.height !== lh) {
    rt = new THREE.WebGLRenderTarget(lw, lh, opts);
    rt.depthTexture = new THREE.DepthTexture(lw, lh);
    rn = new THREE.WebGLRenderTarget(lw, lh, opts);
  }
  renderer.setRenderTarget(rt); renderer.render(scene, cam);          // 색 + 깊이
  const bg = scene.background; scene.background = null; scene.overrideMaterial = nMat;
  renderer.setRenderTarget(rn); renderer.setClearColor(0x8080ff, 1); renderer.clear(); renderer.render(scene, cam);
  scene.overrideMaterial = null; scene.background = bg; renderer.setRenderTarget(null);
  const u = quad.mat.uniforms;                                        // uniforms: tC · tD · tN · uLow · uPx · uOutline
  u.tC.value = rt.texture; u.tD.value = rt.depthTexture; u.tN.value = rn.texture;
  u.uLow.value.set(lw, lh);
  u.uPx.value = px;
  renderer.render(quad.scene, quad.cam);
}
```

## 흔한 실수 · 확인 목록
- [ ] **렌더 타깃을 기본 Linear 필터로 키우면 도트가 아니라 흐린 그림이 된다** — NearestFilter 로, 또는 셰이더에서 점 중심(pix + 0.5)만 읽는다.
- [ ] **원근 카메라가 움직이면 도트가 지글거린다** — 정사영 카메라 · 느린 회전에. 꼬마 창고지기 레트로 모드처럼 위에서 보는 판에 잘 맞는다.
- [ ] **법선 그리기 뒤 지우기 색 · 배경을 안 되돌리면 다음 프레임이 이상하다** — 저장했다가 바로 되돌린다.
- [ ] **도트 크기를 픽셀 고정으로 두면 고해상도 폰에서 도트가 너무 작다** — 화면 높이에 비례해 정한다.

## 완성 기준 체크리스트
- [ ] 섬 마을이 굵은 도트로 보이고, 집 · 나무 · 꼬마 둘레에 1px 어두운 선이 있다
- [ ] 지붕 모서리 · 상자 모서리는 한 줄 밝게 빛나 입체가 산다
- [ ] 「보통 렌더로 비교」를 켜면 매끈한 원래 3D 가 보인다

## 이 기술 정보
- id: `i239` · 분류: 그래픽 · 셰이더 › 후처리 · 3D · 난이도 보통 · 폰 부담 가벼움 (폰 OK) — 1/4 해상도(픽셀 수 1/16)로 두 번 그리고 화면 셰이더 하나 — 원래 해상도 한 번보다 가볍다.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i239
- 쓰면 좋을 때: 레트로 · 도트 그림체 모드 / 작은 화면에서 또렷한 실루엣이 필요할 때
- 쓰지 말 때: 원근 카메라가 많이 움직이는 장면 — 도트가 지글거린다. 정사영 · 느린 카메라에 / 글씨 · 숫자를 3D 로 읽어야 할 때 — 1/4 해상도에서 뭉개진다. 글씨는 화면 위 HTML 로

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### makePixel3D — `src/demos/demosLook2.ts:3385`
```ts
function makePixel3D(): Scene3D {
  const scene = new THREE.Scene();
  scene.background = new THREE.Color(0x2b3a5c);
  const cam = new THREE.OrthographicCamera(-4, 4, 2.5, -2.5, 0.1, 40);
  scene.add(new THREE.HemisphereLight(0xcfe0ff, 0x40304a, 1.2));
  const sun = new THREE.DirectionalLight(0xfff0d0, 2.6);
  sun.position.set(4, 7, 2);
  scene.add(sun);
  const ramp = toonRamp([0.35, 0.7, 1]);
  const M = (c: number) => new THREE.MeshToonMaterial({ color: c, gradientMap: ramp });
  const root = new THREE.Group();
  scene.add(root);
  const add = (geo: THREE.BufferGeometry, c: number | THREE.Material, x: number, y: number, z: number, ry = 0) => {
    const m = new THREE.Mesh(geo, typeof c === 'number' ? M(c) : c);
    m.position.set(x, y, z);
    m.rotation.y = ry;
    root.add(m);
    return m;
  };
  add(new THREE.BoxGeometry(5, 0.9, 5), 0x8a5a3a, 0, -0.45, 0);
  add(new THREE.BoxGeometry(5, 0.2, 5), 0x6cc04a, 0, 0.1, 0);
  const water = add(new THREE.BoxGeometry(60, 0.1, 60), 0x3f8fd8, 0, -0.55, 0);
  // 집
  add(new THREE.BoxGeometry(1.4, 1.0, 1.2), 0xf3e2c0, -1.1, 0.7, -1.0);
  add(new THREE.ConeGeometry(1.15, 0.8, 4).rotateY(Math.PI / 4), 0xd8483a, -1.1, 1.6, -1.0);
  add(new THREE.BoxGeometry(0.35, 0.55, 0.05), 0x5a3a22, -1.1, 0.48, -0.38);
  add(new THREE.BoxGeometry(0.3, 0.3, 0.05), 0x8fd0ff, -0.7, 0.85, -0.38);
  // 나무
  const trunkGeo = new THREE.CylinderGeometry(0.1, 0.13, 0.6, 6);
  const leafGeo = new THREE.IcosahedronGeometry(0.5, 0);
  for (const [x, z, s] of [
    [1.4, -1.3, 1],
    [1.8, 0.6, 0.8],
    [-1.8, 1.5, 0.9],
  ] as const) {
    add(trunkGeo, 0x7a4a2a, x, 0.5, z);
    const l = add(leafGeo, 0x3f9a3a, x, 1.05 * s + 0.1, z, x);
    l.scale.setScalar(s);
    const l2 = add(leafGeo, 0x5fbf4a, x + 0.1, 1.45 * s + 0.1, z, x * 2);
    l2.scale.setScalar(s * 0.7);
  }
  // 울타리 · 길 · 꽃
  const postGeo = new THREE.BoxGeometry(0.1, 0.4, 0.1);
  for (let i = 0; i < 7; i++) add(postGeo, 0xc8a070, -2.3 + i * 0.4, 0.4, 2.2);
  add(new THREE.BoxGeometry(2.6, 0.06, 0.06), 0xc8a070, -1.1, 0.5, 2.2);
  const stoneGeo = new THREE.BoxGeometry(0.45, 0.06, 0.45);
  for (let i = 0; i < 5; i++) add(stoneGeo, 0xd8d0c0, -1.1 + i * 0.15, 0.22, -0.1 + i * 0.5, i);
  const flowerGeo = new THREE.BoxGeometry(0.12, 0.12, 0.12);
  for (let i = 0; i < 10; i++) add(flowerGeo, [0xff5a8a, 0xffd23f, 0xffffff][i % 3]!, 0.3 + Math.sin(i * 2.3) * 1.1, 0.26, 0.9 + Math.cos(i * 1.7) * 0.6);
  // 꼬마
  const kid = new THREE.Group();
  const body = new THREE.Mesh(new THREE.CapsuleGeometry(0.16, 0.2, 4, 10), M(0x4a7aff));
  body.position.y = 0.32;
  const head = new THREE.Mesh(new THREE.SphereGeometry(0.17, 16, 12), M(0xffd0a0));
  head.position.y = 0.66;
  const hat = new THREE.Mesh(new THREE.CylinderGeometry(0.12, 0.2, 0.12, 12), M(0xff4a4a));
  hat.position.y = 0.8;
  kid.add(body, head, hat);
  root.add(kid);
  const low = new LowRT({ minFilter: THREE.NearestFilter, magFilter: THREE.NearestFilter }, true);
  const lowN = new LowRT({ minFilter: THREE.NearestFilter, magFilter: THREE.NearestFilter });
  const nMat = new THREE.MeshNormalMaterial();
  const uni = { tC: { value: null as THREE.Texture | null }, tD: { value: null as THREE.Texture | null }, tN: { value: null as THREE.Texture | null }, uLow: { value: new THREE.Vector2() }, uPx: { value: 4 }, uOutline: { value: 1 } };
  const q = quad(
    /* glsl */ `
    uniform sampler2D tC, tD, tN; uniform vec2 uLow; uniform float uPx, uOutline;
    void main(){
      vec2 pix = floor(gl_FragCoord.xy / uPx);
      vec2 uv = (pix + 0.5) / uLow; vec2 e = 1.0 / uLow;
      vec3 c = texture2D(tC, uv).rgb;
      c = pow(1.0 - exp(-c * 1.25), vec3(1.0 / 2.2));
      if (uOutline > 0.5){
        float d = texture2D(tD, uv).r;
        vec3 n = texture2D(tN, uv).rgb * 2.0 - 1.0;
        float de = 0.0; float ne = 0.0;
        vec2 off[4]; off[0] = vec2(e.x, 0.0); off[1] = vec2(-e.x, 0.0); off[2] = vec2(0.0, e.y); off[3] = vec2(0.0, -e.y);
        for (int i = 0; i < 4; i++){
          float dn = texture2D(tD, uv + off[i]).r;
          vec3 nn = texture2D(tN, uv + off[i]).rgb * 2.0 - 1.0;
          de += step(0.004, dn - d);
          ne += step(abs(dn - d), 0.004) * step(0.0, dn - d) * (1.0 - smoothstep(0.6, 0.9, dot(n, nn)));
        }
        if (de > 0.0) c *= 0.38;
        else if (ne > 0.0) c = min(c * 1.35 + 0.05, vec3(1.0));
      }
      gl_FragColor = vec4(c, 1.0);
    }`,
    uni,
  );
  const hud = new Hud();
  const tag = hud.add('도트 3D (1/4 해상도) + 1px 외곽선');
  hud.at(tag, 0.03, 0.05, 0, 0);
  let compare = false;
  let ang = 0;
  return {
    scene,
    camera: q.cam,
    update(t) {
      ang = 0.6 + Math.sin(t * 0.25) * 0.5;
      cam.position.set(Math.sin(ang) * 12, 9, Math.cos(ang) * 12);
      cam.lookAt(0, 0.3, 0);
      const a = t * 0.7;
      kid.position.set(Math.cos(a) * 1.1 + 0.5, Math.abs(Math.sin(t * 7)) * 0.06 + 0.2, Math.sin(a) * 0.9 + 0.6);
      kid.rotation.y = -a;
      water.position.y = -0.55 + Math.sin(t * 1.5) * 0.02;
    },
    render(r, w, h) {
      const asp = w / h;
      const vs = 3.0;
      cam.left = -vs * asp;
      cam.right = vs * asp;
      cam.top = vs;
      cam.bottom = -vs;
      cam.updateProjectionMatrix();
      if (compare) {
        r.render(scene, cam);
        hud.set(tag, '보통 렌더 (비교)');
        hud.draw(r, w, h);
        return;
      }
      const px = Math.max(1, Math.round(uni.uPx.value * Math.max(1, h / 420)));
      const lw = Math.ceil(w / px);
      const lh = Math.ceil(h / px);
      const rt = low.get(lw, lh);
      const rn = lowN.get(lw, lh);
      r.setRenderTarget(rt);
      r.render(scene, cam);
      const bg = scene.background;
      scene.background = null;
      scene.overrideMaterial = nMat;
      const cc = r.getClearColor(new THREE.Color());
      const ca = r.getClearAlpha();
      r.setRenderTarget(rn);
      r.setClearColor(0x8080ff, 1);
      r.clear();
      r.render(scene, cam);
      r.setClearColor(cc, ca);
      scene.overrideMaterial = null;
      scene.background = bg;
      r.setRenderTarget(null);
      uni.tC.value = rt.texture;
      uni.tD.value = rt.depthTexture;
      uni.tN.value = rn.texture;
      uni.uLow.value.set(lw, lh);
      const save = uni.uPx.value;
      uni.uPx.value = px;
      r.render(q.scene, q.cam);
      uni.uPx.value = save;
      hud.set(tag, `도트 3D (1/${px} 해상도)${uni.uOutline.value ? ' + 1px 외곽선' : ''}`);
      hud.draw(r, w, h);
    },
    controls: [
      { type: 'range', label: '도트 크기', min: 1, max: 8, step: 1, value: 4, on: (v) => (uni.uPx.value = v) },
      { type: 'toggle', label: '1px 외곽선 · 모서리 밝힘', value: true, on: (v) => (uni.uOutline.value = v ? 1 : 0) },
      { type: 'toggle', label: '보통 렌더로 비교', value: false, on: (v) => (compare = v) },
    ],
    dispose() {
      freeAll(root);
      ramp.dispose();
      low.dispose();
      lowN.dispose();
      nMat.dispose();
      q.dispose();
      hud.dispose();
    },
  };
}
```

### i239 견본 항목 — `src/demos/demosLook2.ts:4523`
```ts
  i239: { kind: '3d', caption: '1/4 ~ 1/5 해상도로 그려 크게 늘림 — 깊이 차이로 1px 어두운 외곽선, 법선 차이로 모서리 밝힘', make: () => makePixel3D() }
```

## 관련 기술
- 같은 방법으로 만든 효과 예시: [도트화 (Pixelated pass)](https://ai-techstudio.web.app/ai/t/i08.md) `i08`
- 먼저 알면 좋은 기술: [후처리 외곽선 (깊이 · 법선 경계)](https://ai-techstudio.web.app/ai/t/i03.md) `i03` · [툰 셰이딩 (3단 명암)](https://ai-techstudio.web.app/ai/t/u01.md) `u01`
- 다음에 해 볼 기술: [디더링 화풍 (Bayer · 1비트)](https://ai-techstudio.web.app/ai/t/i237.md) `i237`
- 참고 문서: [three.js 예제 — webgl_postprocessing_pixel](https://threejs.org/examples/#webgl_postprocessing_pixel)
