# AI 꾸러미 — 만화 망점 · 해칭 셰이더 — Halftone shader
> 빛 받는 정도에 따라 그늘을 화면 격자의 점 크기나 빗금 겹 수로 바꿔, 만화책 망점 · 연필 해칭 그림처럼 그린다.  
> 견본: https://ai-techstudio.web.app/#t/i11

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

## 주문서

### 만들어 줘: 만화 망점 · 해칭 셰이더 — Halftone shader

#### 1. 목표
꼬인 매듭 · 공 같은 기본 도형의 그늘을 만화책 망점 느낌의 망점 또는 빗금으로 그려 줘 — 밝기에 따라 점 크기 · 빗금 겹이 바뀌게.

#### 2. 핵심 기술 용어
- **Halftone shader** — 망점 — 어두울수록 점이 커짐
- **Cross-hatching shader** — 빗금 — 어두울수록 겹이 늘어남
- **Screen-space pattern (gl_FragCoord)** — 화면 픽셀 좌표로 무늬를 깔기
- **Non-photorealistic rendering (NPR)** — 사실이 아닌 그림체 렌더링

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

#### 4. 조건
- 무늬 칸 크기는 화면 높이에 비례 (견본: max(4, 높이/80 × 배율)) — 폰 · PC 에서 같은 느낌
- 망점 격자는 45° 돌리기 (인쇄 망점처럼)
- 점 가장자리는 smoothstep 으로 1.5 픽셀 부드럽게 (계단 방지)
- 렌더러 톤 매핑은 NoToneMapping — 잉크 · 종이 색이 그대로
- 「점 · 빗금 크기」 · 「빛 도는 빠르기」 슬라이더, 보통 그늘 · 망점 · 빗금 셋을 나란히

#### 5. 완성 기준 (이게 보이면 성공)
- 세 물체가 나란히 — 보통 그늘 · 망점 · 빗금 — 빛이 돌면 어두운 쪽 점이 커지고 빗금 겹이 늘어난다
- 점 · 빗금 굵기는 화면 크기를 바꿔도 비슷하게 유지된다
- 물체 가장자리에 잉크 외곽선이 둘러져 만화책처럼 보인다

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

## 원리
- 먼저 빛 밝기 lum = 0.12 + 0.88 × max(N·L, 0) 을 구한다.
- 망점: 화면 픽셀 좌표를 칸(uCell)으로 나누고 45° 돌린 격자 칸마다 원을 그린다. 반지름 = √(1 − lum) × 0.72.
- 빗금: 밝기 0.85 · 0.6 · 0.35 아래로 내려갈 때마다 방향이 다른 줄을 한 겹씩 더한다.
- 무늬를 물체가 아닌 화면 좌표에 깔아 크기가 늘 같고, 외곽선 껍데기를 함께 두르면 만화 같다.

## 핵심 코드 — 망점 · 빗금 조각 셰이더
(발췌: demos/demosMaterial.ts i11 make() 의 fragmentShader (uMode 1 · 2))
```glsl
uniform vec3 uLight; uniform float uCell; uniform float uMode; uniform vec3 uCol; uniform vec3 uInk;
varying vec3 vN;
// 줄 하나: fract 로 반복되는 띠
float ln(float x){ float f = abs(fract(x) - 0.5) * 2.0; return smoothstep(0.42, 0.18, f); }
void main(){
  float d = max(dot(normalize(vN), uLight), 0.0);
  float lum = 0.12 + 0.88 * d;
  vec3 col;
  if (uMode < 1.5) {                                   // 망점
    vec2 p = gl_FragCoord.xy / uCell;
    p = mat2(0.7071, -0.7071, 0.7071, 0.7071) * p;     // 45도 돌린 격자
    vec2 c = fract(p) - 0.5;
    float r = sqrt(max(1.0 - lum, 0.0)) * 0.72;        // 어두울수록 큰 점
    float aa = 1.5 / uCell;
    float dotv = 1.0 - smoothstep(r - aa, r + aa, length(c));
    col = mix(uCol, uInk, dotv * 0.92);
  } else {                                             // 빗금 — 어두울수록 한 겹씩
    vec2 q = gl_FragCoord.xy / uCell;
    float ink = 0.0;
    if (lum < 0.85) ink = max(ink, ln((q.x + q.y) * 0.7));
    if (lum < 0.6)  ink = max(ink, ln((q.x - q.y) * 0.7));
    if (lum < 0.35) ink = max(ink, ln(q.x * 0.7 + 0.25));
    vec3 paper = mix(vec3(1.0, 0.98, 0.93), uCol, 0.25);
    col = mix(paper, uInk, ink * 0.85);
  }
  gl_FragColor = vec4(col, 1.0);
}
// 정점: vN = normalize(normalMatrix * normal); — uLight 도 같은 보기 공간 방향으로 넘긴다
// JS: uCell.value = Math.max(4, (화면높이 / 80) * 배율);
```

## 흔한 실수 · 확인 목록
- [ ] **화면에 붙은 무늬는 카메라가 움직이면 물체 위를 미끄러진다** — 느린 카메라 장면에서만, 또는 무늬를 물체 좌표(uv · 정점 위치)에 깐다.
- [ ] **칸 크기를 픽셀 고정으로 두면 고해상도 폰에서 무늬가 깨알만 해진다** — 견본처럼 화면 높이에 비례해 정한다.
- [ ] **톤 매핑이 켜져 있으면 종이 · 잉크 색이 바뀐다** — NoToneMapping 으로 둔다.

## 완성 기준 체크리스트
- [ ] 세 물체가 나란히 — 보통 그늘 · 망점 · 빗금 — 빛이 돌면 어두운 쪽 점이 커지고 빗금 겹이 늘어난다
- [ ] 점 · 빗금 굵기는 화면 크기를 바꿔도 비슷하게 유지된다
- [ ] 물체 가장자리에 잉크 외곽선이 둘러져 만화책처럼 보인다

## 이 기술 정보
- id: `i11` · 분류: 그래픽 · 셰이더 › 재질 · 그림체 · 3D · 난이도 보통 · 폰 부담 가벼움 (폰 OK) — 조각마다 계산 몇 줄. ShaderMaterial 이라 조명 계산을 직접 해서 오히려 가볍다.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i11
- 쓰면 좋을 때: 만화 · 회상 · 그림책 연출 장면 / 흑백 · 2색 인쇄물 같은 그림체
- 쓰지 말 때: 카메라가 빨리 움직이는 장면 — 화면에 붙은 무늬가 미끄러져 보인다(샤워 커튼 효과). 대신 물체 좌표 무늬 / 작은 화면에서 아주 작은 칸 — 무늬가 뭉개진다

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### i11 견본 항목 — `src/demos/demosMaterial.ts:1160`
```ts
  i11: {
    kind: '3d',
    caption: '같은 물체의 그늘을 — 가운데 만화책 망점 · 오른쪽 연필 빗금으로',
    make() {
      const B = base({ bg: gradBg('#fbf3e0', '#f2e6c8'), labels: ['보통 그늘', '망점', '빗금'], fit: [3.1, 0.9], target: [0, -0.05, 0], dir: [0, 0.15, 1] });
      const uLight = { value: new THREE.Vector3(0.5, 0.7, 0.6).normalize() };
      const uCell = { value: 6 };
      const mk = (mode: number, col: number): THREE.ShaderMaterial =>
        new THREE.ShaderMaterial({
          uniforms: { uLight, uCell, uMode: { value: mode }, uCol: { value: new THREE.Color(col) }, uInk: { value: new THREE.Color(0x1e1a3a) } },
          vertexShader: `varying vec3 vN;
            void main(){ vN = normalize(normalMatrix * normal); gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }`,
          fragmentShader: `uniform vec3 uLight; uniform float uCell; uniform float uMode; uniform vec3 uCol; uniform vec3 uInk;
            varying vec3 vN;
            float ln(float x){ float f = abs(fract(x) - 0.5) * 2.0; return smoothstep(0.42, 0.18, f); }
            void main(){
              float d = max(dot(normalize(vN), uLight), 0.0);
              float lum = 0.12 + 0.88 * d;
              vec3 col;
              if (uMode < 0.5) {
                col = uCol * (0.35 + 0.65 * lum);
              } else if (uMode < 1.5) {
                vec2 p = gl_FragCoord.xy / uCell;
                p = mat2(0.7071, -0.7071, 0.7071, 0.7071) * p;
                vec2 c = fract(p) - 0.5;
                float r = sqrt(max(1.0 - lum, 0.0)) * 0.72;
                float aa = 1.5 / uCell;
                float dotv = 1.0 - smoothstep(r - aa, r + aa, length(c));
                col = mix(uCol, uInk, dotv * 0.92);
              } else {
                vec2 q = gl_FragCoord.xy / uCell;
                float ink = 0.0;
                if (lum < 0.85) ink = max(ink, ln((q.x + q.y) * 0.7));
                if (lum < 0.6) ink = max(ink, ln((q.x - q.y) * 0.7));
                if (lum < 0.35) ink = max(ink, ln(q.x * 0.7 + 0.25));
                vec3 paper = mix(vec3(1.0, 0.98, 0.93), uCol, 0.25);
                col = mix(paper, uInk, ink * 0.85);
              }
              gl_FragColor = vec4(col, 1.0);
            }`,
        });
      const push = { value: 0.025 };
      const ink = new THREE.MeshBasicMaterial({ color: 0x1e1a3a, side: THREE.BackSide });
      ink.onBeforeCompile = pushShader(push);
      const knot = new THREE.TorusKnotGeometry(0.42, 0.16, 140, 20);
      const sph = new THREE.SphereGeometry(0.3, 32, 20);
      const objs: THREE.Group[] = [];
      [0, 1, 2].forEach((mode, i) => {
        const g = new THREE.Group();
        const m = mk(mode, [0xff7a8a, 0x58b8ff, 0xffc04a][i]!);
        const k = new THREE.Mesh(knot, m);
        k.add(new THREE.Mesh(knot, ink));
        const s = new THREE.Mesh(sph, m);
        s.position.set(0.55, -0.55, 0.3);
        s.add(new THREE.Mesh(sph, ink));
        g.add(k, s);
        g.position.x = (i - 1) * 2.1;
        B.scene.add(g);
        objs.push(g);
      });
      let speed = 0.6;
      let cellK = 1;
      return B.done({
        tone: THREE.NoToneMapping,
        update(t) {
          for (const g of objs) g.rotation.y = t * 0.35;
          const a = t * speed;
          uLight.value.set(Math.cos(a) * 0.8, 0.55, Math.sin(a) * 0.5 + 0.55).normalize();
        },
        render(r, w, h) {
          uCell.value = Math.max(4, (h / 80) * cellK);
          void w;
          r.render(B.scene, B.cam);
        },
        controls: [
          { type: 'range', label: '점 · 빗금 크기', min: 0.5, max: 2.5, step: 0.1, value: 1, on: (v) => { cellK = v; } },
          { type: 'range', label: '빛 도는 빠르기', min: 0, max: 2, step: 0.1, value: 0.6, on: (v) => { speed = v; } },
        ],
      });
    },
  }
```

## 관련 기술
- 먼저 알면 좋은 기술: [툰 셰이딩 (3단 명암)](https://ai-techstudio.web.app/ai/t/u01.md) `u01` · [외곽선 (뒤집은 껍데기)](https://ai-techstudio.web.app/ai/t/u02.md) `u02`
- 다음에 해 볼 기술: [디더링 화풍 (Bayer · 1비트)](https://ai-techstudio.web.app/ai/t/i237.md) `i237` · [아스키 렌더](https://ai-techstudio.web.app/ai/t/i238.md) `i238`
