# AI 꾸러미 — 하프톤 점 · 리소 인쇄
> 신문 망점(밝기 = 점 크기) · 인쇄 얼룩 질감  
> 견본: https://ai-techstudio.web.app/#t/i113

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

## 주문서

### 만들어 줘: 하프톤 점 · 리소 인쇄

#### 1. 목표
화풍에 「하프톤 점 · 리소 인쇄」 기술을 깔끔한 기본 느낌으로 넣어 줘. — 신문 망점(밝기 = 점 크기) · 인쇄 얼룩 질감

#### 2. 핵심 기술 용어
- 하프톤 점 · 리소 인쇄 — 신문 망점(밝기 = 점 크기) · 인쇄 얼룩 질감

#### 3. 환경
- 플랫폼: HTML Canvas 2D (TypeScript), requestAnimationFrame 루프, 라이브러리 없이
- 화면: 2D · 브라우저 — PC 와 폰(390px 폭) 모두, devicePixelRatio 맞춰 또렷하게, 60fps 목표

#### 4. 조건
- devicePixelRatio 를 맞춰 또렷하게
- 매 프레임 다시 그릴 필요 없는 그림은 화면 밖 캔버스에 미리 구워 두기
- 한 프레임 16ms 안 (폰 포함)

#### 5. 완성 기준 (이게 보이면 성공)
- 견본처럼 「신문 망점(밝기 = 점 크기) · 인쇄 얼룩 질감」 이(가) 첫 화면에서 바로 보인다
- 켬/끔(또는 전/후) 비교 단추로 이 기술이 만드는 차이를 확인할 수 있다
- 중요한 값 1~3개를 슬라이더로 바꿔 볼 수 있다
- 콘솔 오류 0, 폰 폭(390px)에서도 동작한다

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

## 원리
- 신문 망점(밝기 = 점 크기) · 인쇄 얼룩 질감


## 흔한 실수 · 확인 목록
- (아직 정리된 것이 없어요)

## 완성 기준 체크리스트
- [ ] 견본처럼 「신문 망점(밝기 = 점 크기) · 인쇄 얼룩 질감」 이(가) 첫 화면에서 바로 보인다
- [ ] 켬/끔(또는 전/후) 비교 단추로 이 기술이 만드는 차이를 확인할 수 있다
- [ ] 중요한 값 1~3개를 슬라이더로 바꿔 볼 수 있다
- [ ] 콘솔 오류 0, 폰 폭(390px)에서도 동작한다

## 이 기술 정보
- id: `i113` · 분류: 이펙트 · 연출 › 모션 그래픽 · 2D · 난이도 보통 · 폰 부담 보통 (폰 주의)
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i113
- 쓰면 좋을 때: 쓰면 좋을 곳 — 화풍 · 비율 · 넓이 시각화

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### i113 견본 항목 — `src/demos/demosMotionB.ts:706`
```ts
  i113: {
    kind: '2d',
    caption: '왼쪽 부드러운 명암 · 오른쪽 같은 그림을 망점으로 — 어두울수록 점이 커지고, 분홍 · 파랑 두 잉크가 살짝 어긋나요',
    make() {
      let spacing = 7;
      let img: ImageData | null = null;
      let tmp: HTMLCanvasElement | null = null;
      const PAPER = [255, 246, 228];
      const PINK = [255, 72, 176];
      const BLUE = [0, 120, 191];
      let grain: CanvasPattern | null = null;
      // 잉크 양: (지역 좌표 0~1, 시간) → [분홍, 파랑]
      const ink = (x: number, y: number, t: number, asp: number): [number, number] => {
        const cx = 0.5 + 0.16 * Math.sin(t * 0.9);
        const cy = 0.5 + 0.08 * Math.abs(Math.sin(t * 1.8)) * -1 + 0.06;
        const rad = 0.3;
        const dx = ((x - cx) * asp) / rad;
        const dy = (y - cy) / rad;
        const r2 = dx * dx + dy * dy;
        let p = 0;
        let b = 0.12 + 0.3 * y;
        // 바닥 그림자
        const sx = (x - cx) * asp / (rad * 1.1);
        const sy = (y - 0.86) / 0.06;
        if (sx * sx + sy * sy < 1) b = Math.max(b, 0.6 * (1 - (sx * sx + sy * sy)) + 0.35);
        if (r2 < 1) {
          const nz = Math.sqrt(1 - r2);
          const la = t * 0.7;
          const lx = Math.cos(la) * 0.7;
          const ly = -0.55;
          const lz = 0.55;
          const lam = Math.max(0, (dx * lx + dy * ly + nz * lz) / Math.hypot(lx, ly, lz));
          p = 0.25 + 0.75 * (1 - lam * 0.85);
          b = 0.7 * Math.pow(1 - lam, 1.6);
          // 공 위 띠 (수학 공)
          if (Math.abs(dy - Math.sin(dx * 2 + t) * 0.15) < 0.12) p = Math.min(1, p * 0.25), (b = Math.max(b, 0.55));
        }
        return [p, b];
      };
      return {
        draw(g, w, h, t) {
          const u = scaleOf(w, h);
          const hw = w / 2;
          const asp = hw / h;
          // 왼쪽: 낮은 해상도로 계산해 키우기
          const gw = 150;
          const gh = Math.max(20, Math.round((gw * h) / hw));
          if (!img || img.width !== gw || img.height !== gh) {
            img = new ImageData(gw, gh);
            tmp = document.createElement('canvas');
            tmp.width = gw;
            tmp.height = gh;
          }
          for (let j = 0; j < gh; j++)
            for (let i = 0; i < gw; i++) {
              const [p, b] = ink((i + 0.5) / gw, (j + 0.5) / gh, t, asp);
              const o = (j * gw + i) * 4;
              for (let c = 0; c < 3; c++) img.data[o + c] = PAPER[c]! * (1 - p * (1 - PINK[c]! / 255)) * (1 - b * (1 - BLUE[c]! / 255));
              img.data[o + 3] = 255;
            }
          tmp!.getContext('2d')!.putImageData(img, 0, 0);
          g.imageSmoothingEnabled = true;
          g.drawImage(tmp!, 0, 0, hw, h);
          // 오른쪽: 종이 + 망점 두 판 (곱하기)
          g.save();
          g.beginPath();
          g.rect(hw, 0, hw, h);
          g.clip();
          g.fillStyle = `rgb(${PAPER.join(',')})`;
          g.fillRect(hw, 0, hw, h);
          g.globalCompositeOperation = 'multiply';
          const s = spacing * u;
          const layers: [number, string, number, number, number][] = [
            [0.26, `rgb(${PINK.join(',')})`, 0, 0, 0],
            [1.31, `rgb(${BLUE.join(',')})`, 1, (1.3 + Math.sin(t * 0.6) * 0.5) * u, 0.9 * u],
          ];
          for (const [ang, col, ch, mx, my] of layers) {
            g.fillStyle = col;
            const ca = Math.cos(ang);
            const sa = Math.sin(ang);
            const span = Math.hypot(hw, h);
            const n = Math.ceil(span / s) + 1;
            g.beginPath();
            for (let a = -n; a <= n; a++)
              for (let b = -n; b <= n; b++) {
                const x = hw * 1.5 + (a * ca - b * sa) * s;
                const y = h / 2 + (a * sa + b * ca) * s;
                if (x < hw - s || x > w + s || y < -s || y > h + s) continue;
                const v = ink((x - hw) / hw, y / h, t, asp)[ch]!;
                if (v < 0.03) continue;
                const r = s * 0.62 * Math.sqrt(v);
                g.moveTo(x + mx + r, y + my);
                g.arc(x + mx, y + my, r, 0, TAU);
              }
            g.fill();
          }
          g.globalCompositeOperation = 'source-over';
          if (!grain) {
            const gc = document.createElement('canvas');
            gc.width = gc.height = 64;
            const gx = gc.getContext('2d')!;
            const R = rng(5);
            for (let i = 0; i < 500; i++) {
              gx.fillStyle = `rgba(90,60,40,${R() * 0.18})`;
              gx.fillRect(R() * 64, R() * 64, 1, 1);
            }
            grain = g.createPattern(gc, 'repeat');
          }
          if (grain) {
            g.fillStyle = grain;
            g.fillRect(hw, 0, hw, h);
          }
          g.restore();
          splitLabels(g, w, h, u, '부드러운 명암', '하프톤 · 리소');
        },
        controls: [{ type: 'range', label: '망점 간격', min: 4, max: 16, step: 0.5, value: 7, on: (v) => (spacing = v) }] as Control[],
      };
    },
  }
```

## 관련 기술
- 이 효과를 만든 기술: [만화 펜선 + 스크린톤](https://ai-techstudio.web.app/ai/t/i335.md) `i335`
