designpaca 초기 구현 — 스킬 · 설치 CLI · 배포 파이프라인

웹 디자인 파이프라인 스킬과 이를 5개 에이전트에 설치하는 CLI 를 담은 모노레포.

스킬 (packages/skill)
- SKILL.md 261줄 + 참조 문서 16개 3,349줄. progressive disclosure 로
  본문은 절차와 인덱스만, 지식은 references/ 로 분리
- 0~6단계 파이프라인. 규모에 따라 전체·연장·국소 세 경로로 분기
- 하드 게이트 12개는 grep·카운트로 검증 가능한 것만. 취향 판단은 제외
- 미학 프리셋 5종, AI 슬롭 지문 목록, 한글 조판 규칙,
  SVG 필터·three.js·인터랙티브 모션·HTML-in-Canvas 실전 지침

설치 CLI (packages/cli, packages/core)
- npx designpaca 온보딩 TUI. Claude Code · Codex · Cursor · Windsurf · AGENTS.md
- 매니페스트에 설치 시점 해시를 기록해 사용자가 고친 파일은 update 가 건너뛴다
- 타깃별로 본문의 references/ 경로를 실제 설치 위치로 재작성
- AGENTS.md 는 항상 로드되므로 본문 대신 303자 포인터만 주입
- Windsurf 는 12,000자 상한 초과 시 설치를 차단

배포 (build/ci, .forgejo/workflows)
- 태그 v* → 검사·테스트·빌드 → npmjs 배포 + Forgejo 레지스트리 미러
  → draft 릴리스 → Cloudflare Pages. 재실행 멱등

근거 (research/)
- 약 250개 웹 소스 조사 결과와 도그푸딩 검증 2건. 스킬의 모든 수치는 여기서 나온다

테스트 22개 통과 (core 16 · cli 6)
This commit is contained in:
Yun Chan 2026-08-20 10:48:00 +09:00
commit 8808c672dc
135 changed files with 38838 additions and 0 deletions

View file

@ -0,0 +1,865 @@
# 01. SVG 필터 프리미티브 전수 레퍼런스
> designpaca 스킬용 실전 레퍼런스. 모든 기본값·값 범위는 W3C SVG 1.1 / Filter Effects Module Level 1 스펙과 MDN 기준.
> "검증됨" 표시는 Chromium 151(Playwright)에서 실제 렌더링으로 확인한 항목이다. 그 외 엔진(Firefox / WebKit)은 별도 표기.
---
## 0. 실행 모델부터 이해하기
SVG 필터는 **이미지 처리 파이프라인**이다. CSS `filter: blur(4px)`처럼 "효과 하나"가 아니라, 노드 그래프를 직접 배선하는 도구다.
```
SourceGraphic ─┬─→ [feGaussianBlur] → result="blur" ─┐
│ ├─→ [feBlend] → 최종 출력
└──────────────────────────────────────┘
```
### 0.1 배선 규칙 (`in` / `in2` / `result`)
| 규칙 | 내용 |
|---|---|
| `in` 생략 시 | **첫 번째** 프리미티브면 `SourceGraphic`, 그 외에는 **직전 프리미티브의 출력** |
| `in2` | 입력 2개가 필요한 프리미티브(`feBlend`, `feComposite`, `feDisplacementMap`, `feTile`은 예외적으로 `in`만)에서 두 번째 입력 |
| `result` | 이 프리미티브의 출력에 이름을 붙여 뒤에서 재사용. **이름을 안 붙이면 직전 것만 참조 가능** |
| 출력 개수 | 프리미티브당 항상 **1개** |
> **함정 1**: `result`를 붙여 놓고도 다음 프리미티브에 `in`을 안 쓰면, 어차피 직전 출력이 들어가서 "왜 배선이 무시되지?"가 된다. 분기(branch)를 만들 때는 **양쪽 모두 `in`을 명시**하라.
### 0.2 소스 키워드
| 키워드 | 의미 | 실전 사용 가능? |
|---|---|---|
| `SourceGraphic` | 필터가 적용된 원본 그래픽 (RGBA 전체) | ✅ |
| `SourceAlpha` | 원본의 **알파 채널만** (RGB는 전부 0=검정) | ✅ 그림자·아웃라인·범프맵의 기본 재료 |
| `BackgroundImage` | 요소 뒤 배경 | ❌ **어떤 브라우저도 구현 안 함.** `backdrop-filter`로 대체 |
| `BackgroundAlpha` | 배경의 알파 | ❌ 미구현 |
| `FillPaint` | 대상의 `fill` 페인트로 무한 평면 채움 | ❌ 사실상 신뢰 불가 (엔진별 해석 불일치) |
| `StrokePaint` | 대상의 `stroke` 페인트로 무한 평면 채움 | ❌ 사실상 신뢰 불가 |
> **함정 2**: 튜토리얼에서 `BackgroundImage`를 봤다면 2010년대 초 문서다. 유리/글래스 효과는 `backdrop-filter: url(#id)`로 간다(§03 참고).
### 0.3 필터 영역 (filter region) — 가장 흔한 버그의 원인
```xml
<!-- 기본값 -->
<filter id="f" x="-10%" y="-10%" width="120%" height="120%"
filterUnits="objectBoundingBox" primitiveUnits="userSpaceOnUse">
```
필터 영역 밖은 **잘려 나간다**. 기본값은 바운딩 박스보다 사방 10%씩만 넓으므로, `stdDeviation`이 크거나 `feOffset`/`feMorphology`로 밀어내면 잘린 사각형이 그대로 보인다.
**경험칙**
| 상황 | 권장 영역 |
|---|---|
| 블러 `stdDeviation` ≤ 5 | 기본값(120%)으로 충분 |
| 블러 6~15, 글로우, 아웃라인 | `x="-30%" y="-30%" width="160%" height="160%"` |
| 다중 드롭섀도(네온), 큰 변위 | `x="-40%" y="-40%" width="180%" height="180%"` |
| 노이즈/그레인 오버레이처럼 **영역을 넓히면 안 되는** 경우 | `x="0%" y="0%" width="100%" height="100%"` (성능도 이득) |
> 필터 영역을 넓히면 **처리 픽셀 수가 제곱으로 증가**한다. 180% × 180% = 원본의 3.24배. 필요한 만큼만 넓혀라. (검증됨 — §02 레시피 18번 비교 데모)
### 0.4 좌표계: `filterUnits` vs `primitiveUnits`
| 속성 | 기본값 | 영향 대상 |
|---|---|---|
| `filterUnits` | `objectBoundingBox` | `<filter>``x/y/width/height` |
| `primitiveUnits` | `userSpaceOnUse` | 각 프리미티브의 `x/y/width/height` **그리고 `stdDeviation`, `radius`, `dx/dy`, `scale` 같은 길이값** |
- `objectBoundingBox` → 0~1 또는 % 로 해석. `0.05` = 바운딩 박스 크기의 5%.
- `userSpaceOnUse` → 현재 사용자 좌표계의 절대 길이(대개 px).
```xml
<!-- 요소 크기에 비례하는 블러 (반응형 컴포넌트에 유용) -->
<filter id="relBlur" primitiveUnits="objectBoundingBox">
<feGaussianBlur stdDeviation="0.05"/> <!-- 박스 폭의 5% -->
</filter>
```
> 검증됨: 90×90px 박스에 `primitiveUnits="objectBoundingBox"` + `stdDeviation="0.05"` → 약 4.5px 블러.
> **함정 3**: `primitiveUnits="objectBoundingBox"`로 바꾸면 `stdDeviation="10"`이 "박스 10배 크기 블러"가 되어 화면이 통째로 사라진다. 단위를 바꾸면 **모든 수치를 다시 계산**해야 한다.
> **함정 4 (HTML 요소)**: `filter: url(#f)`를 HTML 요소에 걸면 바운딩 박스는 **border box** 기준이다. `objectBoundingBox`를 쓰는 필터를 SVG용으로 만들어놓고 HTML에 재활용하면 크기가 달라진다.
### 0.5 색공간: `color-interpolation-filters`
**SVG 필터의 기본 연산 색공간은 `linearRGB`다.** CSS의 `filter: blur()` 같은 단축 함수는 `sRGB`다. 이 불일치가 "같은 값인데 왜 다르게 보이지?"의 정체다.
검증된 실측 (Chromium, `#e11d48` 사각형에 `feColorMatrix type="saturate" values="0"`):
| 설정 | 결과 |
|---|---|
| 기본(linearRGB) | 밝은 회색 (≈ `#808080`) |
| `color-interpolation-filters="sRGB"` | 어두운 회색 (≈ `#4a4a4a`, 계산값 R=G=B=74) |
같은 조건의 `feGaussianBlur stdDeviation="10"`:
- linearRGB → 헤일로가 **밝고 넓게** 번짐(물리적으로 정확한 빛의 합성)
- sRGB → 헤일로가 **좁고 진함**(포토샵/CSS와 동일한 룩)
**실전 지침**
```xml
<!-- 디자인 툴/CSS와 색을 맞추고 싶으면 항상 filter에 sRGB를 명시 -->
<filter id="f" color-interpolation-filters="sRGB"> ... </filter>
```
| 케이스 | 권장 |
|---|---|
| 듀오톤, 그라디언트 맵, 브랜드 컬러 정확도 | **반드시 `sRGB`** |
| 알파 대비 트릭(gooey), 합성 위주 | `sRGB` (예측 가능) |
| 조명(`feDiffuseLighting`/`feSpecularLighting`)에서 물리적 자연스러움 | linearRGB 기본이 더 그럴듯할 때도 있음 — 둘 다 보고 고른다 |
| feTurbulence 노이즈 자체 | 어느 쪽이든 무방, 단 후속 대비 조정 결과가 달라짐 |
> **함정 5**: `color-interpolation-filters`는 개별 프리미티브에도 걸 수 있다. 체인 중간에서 색공간이 바뀌면 브라우저가 변환을 삽입한다 → 비용 발생 + 예측 어려움. **`<filter>` 한 곳에만 지정**하는 것이 안전하다.
### 0.6 필터 붙이는 법
```html
<!-- 1. SVG 요소에 (프레젠테이션 어트리뷰트) -->
<circle filter="url(#f)" .../>
<!-- 2. HTML 요소에 (CSS) -->
<div style="filter: url(#f)"></div>
<!-- 3. 배경에 (Chromium 계열만) -->
<div style="backdrop-filter: url(#f)"></div>
```
필터 정의를 담는 SVG 컨테이너 패턴:
```html
<svg width="0" height="0" style="position:absolute" aria-hidden="true" focusable="false">
<defs>
<filter id="myFilter"> ... </filter>
</defs>
</svg>
```
> 검증됨: Chromium에서는 `<svg style="display:none">` 안에 정의한 필터도 정상 동작했다. 하지만 과거 엔진 이슈 보고가 있고 Firefox/WebKit은 이 세션에서 미검증이므로, **`width=0 height=0 + position:absolute` 패턴을 기본값으로 쓴다.**
> 검증됨: 존재하지 않는 필터를 참조하면(`filter:url(#없음)`) Chromium은 **필터를 무시하고 원본을 그대로 렌더**한다(CSS·SVG 어트리뷰트 양쪽 모두). 현행 Filter Effects L1과 일치. 다만 구 SVG 1.1은 "요소를 렌더하지 않음"으로 정의했었으므로, 레거시 엔진에서 요소가 사라질 수 있다 — **ID 오타는 치명적일 수 있다.**
---
## 1. 소스 생성 프리미티브
입력 없이 새 이미지를 만들어내는 것들. 필터 그래프의 "재료".
---
### 1.1 `<feTurbulence>` — 절차적 노이즈
가장 강력하고, 가장 비싼 프리미티브. Perlin 노이즈를 생성한다.
| 속성 | 기본값 | 범위/타입 | 의미 |
|---|---|---|---|
| `type` | `turbulence` | `turbulence` \| `fractalNoise` | `turbulence`=결·물결·대리석, `fractalNoise`=구름·그레인 |
| `baseFrequency` | `0` | 0 이상 실수, `x y` 2개 가능 | **노이즈의 촘촘함.** 작을수록 큰 무늬 |
| `numOctaves` | `1` | 양의 정수 | 겹칠 주파수 층 수. 디테일↑ 비용↑ |
| `seed` | `0` | 정수(소수는 0 방향으로 절삭) | 난수 시드 |
| `stitchTiles` | `noStitch` | `noStitch` \| `stitch` | 타일 경계 이음매 제거 |
**`baseFrequency` 실전 값 지도**
| 값 | 결과 | 용도 |
|---|---|---|
| `0.002 ~ 0.01` | 거대한 유기적 덩어리 | 액체 배경, 오로라, 마블 |
| `0.01 ~ 0.03` | 큰 파형 | 손그림 왜곡, 물결 텍스트 |
| `0.03 ~ 0.08` | 중간 결 | 종이 질감, 잉크 번짐, 러프 엣지 |
| `0.1 ~ 0.3` | 촘촘한 결 | 천·직물, 얕은 디스토션 |
| `0.6 ~ 0.95` | 픽셀 단위 그레인 | **필름 그레인 / 노이즈 오버레이** |
| `> 1.0` | 에일리어싱 발생, 무의미 | ❌ |
**2값 형식**: `baseFrequency="0.008 0.05"` → x축은 큰 파형, y축은 촘촘 → **가로로 늘어진 결**. 물결·글리치 스캔라인에 필수.
**`numOctaves` 실전**: 1=밋밋, 2~3=대부분의 경우 최적, 4~5=거친 종이, **5 초과는 시각적 이득이 거의 없고 비용만 늘어난다.**
**함정**
1. **`baseFrequency`의 기본값은 `0`** = 아무것도 안 나온다. 반드시 지정하라.
2. **알파 채널도 노이즈다.** feTurbulence 출력은 RGB뿐 아니라 A도 랜덤이라 그냥 보면 반투명 컬러 스노우다. 불투명 그레이 노이즈가 필요하면 `feColorMatrix`로 알파를 1로 고정하라:
```xml
<feColorMatrix type="matrix" values="
0.33 0.33 0.33 0 0
0.33 0.33 0.33 0 0
0.33 0.33 0.33 0 0
0 0 0 0 1"/> <!-- 마지막 행: A = 1 상수 -->
```
3. **특정 seed 값에서 사각형 아티팩트**가 나온다. 보고된 값: `514, 1977, 2337, 4777, 8032, 9615` 등. 스펙 참조 구현의 정수 나눗셈 버그라 Chrome·Firefox·Batik이 동일하게 재현한다. → **seed는 1~50 같은 작은 값에서 눈으로 확인하고 고정하라.**
4. **CPU 비용이 가장 크다.** `baseFrequency`를 애니메이션하면 매 프레임 전체 노이즈를 재생성한다. §03 참고.
5. `stitchTiles="stitch"`**타일링해서 배경으로 쓸 때만** 의미가 있다. `feTile`이나 CSS `background-repeat`와 조합할 때 켜라.
---
### 1.2 `<feFlood>` — 단색 채우기
| 속성 | 기본값 | 의미 |
|---|---|---|
| `flood-color` | `black` | 채울 색. **CSS 프로퍼티이기도 하다 → CSS/SMIL로 애니메이션 가능** |
| `flood-opacity` | `1` | 0~1 |
필터 **서브영역 전체**를 색으로 채운다. 단독으로는 쓸모없고 `feComposite operator="in"`으로 마스킹해서 쓴다.
```xml
<!-- 알파 모양대로 특정 색 칠하기 = 실루엣 -->
<feFlood flood-color="#22d3ee" result="col"/>
<feComposite in="col" in2="SourceAlpha" operator="in"/>
```
> **함정 6**: `feFlood`는 서브영역 전체를 채우므로, 서브영역이 필터 영역(기본 120%)이면 색이 사방으로 넘친다. `operator="in"` 마스킹을 **반드시** 뒤에 붙이거나 `x/y/width/height`로 서브영역을 제한하라.
> **활용**: `flood-color`가 CSS 프로퍼티라는 점이 핵심이다. `baseFrequency`·`stdDeviation` 같은 어트리뷰트는 CSS로 애니메이션할 수 없지만, **`flood-color`, `flood-opacity`, `lighting-color`는 CSS 트랜지션/애니메이션이 가능하다.** (검증됨: SMIL `<animate attributeName="flood-color">` 정상 동작)
---
### 1.3 `<feImage>` — 외부 이미지 끌어오기
| 속성 | 기본값 | 의미 |
|---|---|---|
| `href` | — | 이미지 URL. `xlink:href`는 레거시 |
| `preserveAspectRatio` | `xMidYMid meet` | 서브영역에 맞추는 방식. **변위맵으로 쓸 땐 `none` 권장** |
| `crossorigin` | — | CORS |
Liquid Glass 같은 정밀한 굴절에서 **미리 계산된 변위맵**을 넣는 통로.
**참조 방식별 지원 현황**
| 방식 | Chromium | WebKit | Firefox |
|---|---|---|---|
| 외부 파일 `href="map.png"` | ✅ | 부분적 | ✅ |
| 외부 SVG 파일 `href="map.svg"` | ✅ (래스터화) | ❌ 보고됨 | ✅ |
| 같은 문서 fragment `href="#id"` | ❌ | ✅ | ❌ |
| **data URI** `href="data:image/svg+xml;..."` | ✅ | ✅ | ✅ |
> **함정 7**: fragment 참조는 Safari 전용, 외부 SVG는 Safari 미지원 — **크로스브라우저로는 data URI가 유일한 안전 경로다.** (검증됨: data URI로 SVG 변위맵 생성 → Chromium 정상)
> **함정 8**: `feImage``x/y/width/height`**명시하지 않으면 필터 영역에 맞춰진다.** `backdrop-filter`에서 쓸 때 요소 크기와 맵 크기가 어긋나면 굴절이 엉뚱한 위치에 생긴다. 픽셀 값으로 정확히 지정하라.
> **함정 9**: data URI 안의 SVG는 **완전히 URL 인코딩**해야 한다. 최소한 `#``%23`, `<``%3C`, `>``%3E`, `"``'`. `#`을 인코딩 안 하면 fragment로 잘려서 조용히 실패한다.
---
### 1.4 `<feTile>` — 서브영역 타일링
`in`의 **필터 서브영역**을 하나의 타일로 삼아, 자기 서브영역을 가득 채울 때까지 x/y로 반복한다.
```xml
<filter id="tiled" primitiveUnits="userSpaceOnUse"
x="0%" y="0%" width="100%" height="100%">
<!-- 40×40 영역만 노이즈 생성 = 타일 원본 -->
<feTurbulence type="turbulence" baseFrequency="0.12" numOctaves="2" seed="5"
x="0" y="0" width="40" height="40" result="tile"/>
<feTile in="tile" x="0" y="0" width="220" height="140"/>
</filter>
```
> 검증됨 (Chromium). 작은 타일만 계산하고 복사하므로 **큰 면적 노이즈보다 훨씬 싸다** — 성능 최적화 기법으로도 유용.
> **함정 10**: `feTile``in`**서브영역이 명시되어 있어야** 동작한다. 앞 프리미티브에 `x/y/width/height`가 없으면 타일 크기가 필터 영역 전체가 되어 반복이 일어나지 않는다.
---
## 2. 기하/변형 프리미티브
---
### 2.1 `<feGaussianBlur>`
| 속성 | 기본값 | 의미 |
|---|---|---|
| `stdDeviation` | `0` | 가우시안 표준편차. `x y` 2값 가능 |
| `edgeMode` | `none` | `duplicate` \| `wrap` \| `none` |
- `stdDeviation="0"` = 효과 없음(기본값이므로 반드시 지정).
- **2값 = 방향성 블러**: `stdDeviation="15 0"` → 가로 모션 블러. 세로 결/속도감 표현에 필수.
- 음수는 에러(필터 전체 무시).
- CSS `blur(6px)``feGaussianBlur stdDeviation="6"` (단, CSS는 sRGB).
> **함정 11**: `edgeMode`는 **Safari에서만 지원**된다(Chrome·Firefox 미지원). `edgeMode="duplicate"`로 가장자리 하드컷을 만드는 트릭은 크로스브라우저 불가 — `feComposite operator="in"`으로 원본 알파에 다시 클리핑하는 방식으로 대체하라.
> **함정 12**: 큰 블러(≈100px 이상)는 엔진이 상한을 걸거나 극단적으로 느려진다. 큰 소프트 글로우는 블러 대신 **radialGradient**로 흉내내는 것이 압도적으로 싸다.
---
### 2.2 `<feOffset>`
| 속성 | 기본값 | 의미 |
|---|---|---|
| `dx`, `dy` | `0` | 이동량 (primitiveUnits 기준) |
가장 싼 프리미티브. 그림자·색분리(RGB split)의 기본 부품.
> **함정 13**: `feOffset`으로 민 결과는 필터 영역 밖으로 나가면 잘린다. `dx="20"`이면 영역도 그만큼 넓혀라.
---
### 2.3 `<feDropShadow>` — 단축 그림자
| 속성 | 기본값 |
|---|---|
| `dx` | `2` |
| `dy` | `2` |
| `stdDeviation` | `2` |
| `flood-color` | `black` |
| `flood-opacity` | `1` |
내부적으로 `feGaussianBlur + feOffset + feFlood + feComposite + feMerge`와 동등. 훨씬 짧고 최적화도 잘 된다.
**체이닝으로 다중 글로우** (검증됨 — 네온 텍스트):
```xml
<filter id="neon" x="-40%" y="-40%" width="180%" height="180%"
color-interpolation-filters="sRGB">
<feDropShadow dx="0" dy="0" stdDeviation="2" flood-color="#22d3ee" flood-opacity="1" result="s1"/>
<feDropShadow in="s1" dx="0" dy="0" stdDeviation="6" flood-color="#7c3aed" flood-opacity="0.9" result="s2"/>
<feDropShadow in="s2" dx="0" dy="0" stdDeviation="14" flood-color="#ec4899" flood-opacity="0.7"/>
</filter>
```
> `feDropShadow`는 **결과에 원본을 포함**하므로 `feMerge`가 필요 없다. 체인으로 연결하면 그림자가 누적된다.
---
### 2.4 `<feMorphology>` — 팽창/침식
| 속성 | 기본값 | 의미 |
|---|---|---|
| `operator` | `erode` | `erode`(축소/가늘게) \| `dilate`(팽창/굵게) |
| `radius` | `0` | 반경. `x y` 2값 가능. 음수는 에러 |
**핵심 용도**: 텍스트 아웃라인. `stroke`는 선이 글자 안쪽으로 반 들어가 글자를 얇게 만들지만, `feMorphology dilate`는 **바깥으로만 키운다.**
```xml
<!-- 컬러 아웃라인 (검증됨) -->
<filter id="outline" x="-20%" y="-20%" width="140%" height="140%"
color-interpolation-filters="sRGB">
<feMorphology in="SourceAlpha" operator="dilate" radius="4" result="thick"/>
<feFlood flood-color="#22d3ee" result="col"/>
<feComposite in="col" in2="thick" operator="in" result="ring"/>
<feMerge>
<feMergeNode in="ring"/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
```
```xml
<!-- 속 빈(knockout) 아웃라인 (검증됨) -->
<filter id="knockout" x="-25%" y="-25%" width="150%" height="150%"
color-interpolation-filters="sRGB">
<feMorphology in="SourceAlpha" operator="dilate" radius="3" result="thick"/>
<feComposite in="thick" in2="SourceAlpha" operator="out" result="ring"/>
<feFlood flood-color="#f472b6" result="col"/>
<feComposite in="col" in2="ring" operator="in"/>
</filter>
```
> **함정 14**: `feMorphology`의 커널은 **사각형**이다. `radius`가 커지면 둥근 모서리가 각지게 뭉개진다. radius 8 이상은 결과를 반드시 눈으로 확인하라. 부드러운 확장이 필요하면 `feGaussianBlur` + `feComponentTransfer`(알파 대비)로 대체.
> **함정 15**: `radius` 기본값이 `0`이라 지정 안 하면 아무 일도 안 일어난다.
---
### 2.5 `<feDisplacementMap>` — 변위 (유기적 디자인의 심장)
| 속성 | 기본값 | 의미 |
|---|---|---|
| `in` | — | 왜곡될 이미지 |
| `in2` | — | 변위맵 |
| `scale` | `0` | 최대 이동량(px). 음수 가능(방향 반전) |
| `xChannelSelector` | **`A`** | `R`\|`G`\|`B`\|`A` |
| `yChannelSelector` | **`A`** | `R`\|`G`\|`B`\|`A` |
**공식**
```
P'(x,y) ← P( x + scale × (XC(x,y) 0.5),
y + scale × (YC(x,y) 0.5) )
```
- 채널 값 **128(=0.5)** → 변위 0 (중립)
- 0 → `scale/2`px 이동, 255 → `+scale/2`px 이동
- 즉 `scale`은 **총 이동 폭**이고 한쪽 방향 최대치는 `scale/2`
> **함정 16 (가장 자주 틀림)**: `xChannelSelector`/`yChannelSelector`**기본값은 `A`(알파)**다. 지정하지 않으면 알파 채널로 왜곡한다. feTurbulence를 맵으로 쓸 때 알파도 노이즈라 "되긴 되는데 왜 이렇게 나오지?"가 된다. **항상 `R`/`G`를 명시하라.** (검증됨: 기본값과 R/G 명시의 결과가 눈에 띄게 다름)
> **함정 17**: 변위 후에는 **안티에일리어싱이 깨진다.** 가장자리가 계단처럼 보이면 `feGaussianBlur stdDeviation="0.5"`를 뒤에 살짝 넣거나, 텍스트라면 `scale`을 6 이하로 낮춰라.
> **함정 18**: 변위는 **필터 영역 안에서만** 일어난다. 영역 밖 픽셀은 존재하지 않으므로, 큰 `scale`은 가장자리를 투명하게 빨아들인다. `scale`의 절반 이상만큼 영역을 넓혀라.
**두 가지 맵 만드는 법**
```xml
<!-- (a) 절차적: feTurbulence — 유기적, 손그림, 물결 -->
<feTurbulence type="fractalNoise" baseFrequency="0.03" numOctaves="4" seed="12" result="n"/>
<feDisplacementMap in="SourceGraphic" in2="n" scale="9"
xChannelSelector="R" yChannelSelector="G"/>
```
```xml
<!-- (b) 결정론적: feImage + 그라디언트 — 렌즈, 굴절, 유리 -->
<!-- R채널 = x변위, G채널 = y변위. 중립은 128. -->
<feImage preserveAspectRatio="none" x="0" y="0" width="220" height="88" result="map"
href="data:image/svg+xml;charset=utf-8,%3Csvg .../%3E"/>
<feDisplacementMap in="SourceGraphic" in2="map" scale="-60"
xChannelSelector="R" yChannelSelector="G"/>
```
(b)의 맵 SVG 원문 (인코딩 전) — **가장자리에만 변위가 몰리는 렌즈 프로파일**:
```xml
<svg xmlns="http://www.w3.org/2000/svg" width="220" height="88">
<defs>
<linearGradient id="rx" x1="0" y1="0" x2="1" y2="0">
<stop offset="0" stop-color="rgb(0,0,0)"/>
<stop offset="0.28" stop-color="rgb(128,0,0)"/>
<stop offset="0.72" stop-color="rgb(128,0,0)"/>
<stop offset="1" stop-color="rgb(255,0,0)"/>
</linearGradient>
<linearGradient id="gy" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="rgb(0,0,0)"/>
<stop offset="0.28" stop-color="rgb(0,128,0)"/>
<stop offset="0.72" stop-color="rgb(0,128,0)"/>
<stop offset="1" stop-color="rgb(0,255,0)"/>
</linearGradient>
</defs>
<rect width="220" height="88" rx="44" fill="rgb(128,128,128)"/>
<rect width="220" height="88" rx="44" fill="url(#rx)" style="mix-blend-mode:screen"/>
<rect width="220" height="88" rx="44" fill="url(#gy)" style="mix-blend-mode:screen"/>
</svg>
```
> 검증됨: `mix-blend-mode: screen``feImage`가 래스터화하는 SVG 내부에서 정상 동작한다. R채널만 있는 레이어와 G채널만 있는 레이어를 screen으로 합치면 채널이 서로 간섭 없이 합쳐진다(= 채널별 가산). 중앙 28~72% 구간을 128로 평평하게 두는 것이 "가장자리에서만 굴절"의 핵심.
---
### 2.6 `<feConvolveMatrix>` — 컨볼루션 커널
| 속성 | 기본값 | 의미 |
|---|---|---|
| `order` | `3` | 커널 크기 (`3` = 3×3) |
| `kernelMatrix` | — | `order²`개의 값 (필수) |
| `divisor` | 커널 합(0이면 1) | 결과를 나눌 값 |
| `bias` | `0` | 더할 상수 |
| `targetX`/`targetY` | `floor(order/2)` | 커널 중심 위치 |
| `edgeMode` | `duplicate` | `duplicate`\|`wrap`\|`none` |
| `preserveAlpha` | `false` | true면 알파를 건드리지 않음 |
```xml
<!-- 샤픈: 변위 후 흐릿해진 엣지 복구 -->
<feConvolveMatrix order="3" preserveAlpha="true"
kernelMatrix="0 -1 0 -1 5 -1 0 -1 0"/>
<!-- 엠보스 -->
<feConvolveMatrix order="3" preserveAlpha="true" bias="0.5"
kernelMatrix="-2 -1 0 -1 1 1 0 1 2"/>
<!-- 엣지 검출(윤곽선 추출) -->
<feConvolveMatrix order="3" preserveAlpha="true"
kernelMatrix="0 1 0 1 -4 1 0 1 0"/>
```
> **함정 19**: `preserveAlpha="false"`(기본값)면 알파에도 커널이 적용되어 형태가 무너진다. 색만 만지고 싶으면 `true`로.
> **함정 20**: 큰 `order`(5×5 이상)는 픽셀당 연산이 제곱으로 늘어 매우 비싸다. 실시간 애니메이션에는 쓰지 마라.
---
## 3. 색 조작 프리미티브
---
### 3.1 `<feColorMatrix>`
| 속성 | 기본값 | 의미 |
|---|---|---|
| `type` | `matrix` | `matrix`\|`saturate`\|`hueRotate`\|`luminanceToAlpha` |
| `values` | type별 항등 | `matrix`=20개 값, `saturate`=0~1(초과도 허용), `hueRotate`=각도 |
**5×4 행렬 레이아웃** (열: R G B A 상수 / 행: R' G' B' A')
```
R' = r1·R + r2·G + r3·B + r4·A + r5
G' = g1·R + g2·G + g3·B + g4·A + g5
B' = b1·R + b2·G + b3·B + b4·A + b5
A' = a1·R + a2·G + a3·B + a4·A + a5
```
모든 값은 **0~1 정규화 기준**이고 결과는 0~1로 clamp된다. 5번째 열은 **상수 오프셋**(1이 곱해짐).
**항등 행렬** (변화 없음, 커스텀의 출발점):
```
1 0 0 0 0
0 1 0 0 0
0 0 1 0 0
0 0 0 1 0
```
**자주 쓰는 행렬 사전**
```xml
<!-- 그레이스케일 (Rec.709 휘도) -->
values="0.2126 0.7152 0.0722 0 0
0.2126 0.7152 0.0722 0 0
0.2126 0.7152 0.0722 0 0
0 0 0 1 0"
<!-- 노이즈 → 불투명 그레이 (알파 노이즈 제거) -->
values="0.33 0.33 0.33 0 0
0.33 0.33 0.33 0 0
0.33 0.33 0.33 0 0
0 0 0 0 1"
<!-- 단색 실루엣 (#22d3ee = 34,211,238 → /255) -->
values="0 0 0 0 0.133
0 0 0 0 0.827
0 0 0 0 0.933
0 0 0 1 0"
<!-- 알파 대비 극대화 = gooey/threshold 핵심 -->
values="1 0 0 0 0
0 1 0 0 0
0 0 1 0 0
0 0 0 18 -7"
<!-- 채널 분리 (R만) -->
values="1 0 0 0 0
0 0 0 0 0
0 0 0 0 0
0 0 0 1 0"
<!-- 색 반전 -->
values="-1 0 0 0 1
0 -1 0 0 1
0 0 -1 0 1
0 0 0 1 0"
```
**알파 대비 트릭의 수학** — `values="... 0 0 0 M -O"` 이면 `A' = M·A O`:
- 임계점: `A = O / M`
- 전이 폭: `1 / M`
- 예) `M=18, O=7` → 임계 0.389, 폭 0.056 → A가 0.389~0.444 사이에서 0→1로 급전환.
- `M`을 키우면 더 날카롭게, `O/M`을 키우면 형태가 더 수축한다.
**`type` 단축형**
| type | values | 결과 |
|---|---|---|
| `saturate` | `0` | 흑백 |
| `saturate` | `0.5` | 채도 절반 |
| `saturate` | `3` | 과채도 (1 초과 허용) |
| `hueRotate` | `180` | 색상환 180° 회전 |
| `luminanceToAlpha` | (없음) | RGB→0, A=휘도 → **밝기를 마스크로 변환** |
> **함정 21**: `luminanceToAlpha`는 결과의 RGB가 전부 0(검정)이다. 마스크로만 쓰고, 색이 필요하면 `feFlood`+`feComposite in`으로 다시 칠하라.
> **함정 22**: 행렬 값은 **0~1 스케일**이다. `#ff8000`을 넣겠다고 `255 128 0`을 쓰면 전부 clamp되어 흰색이 된다. 반드시 255로 나눠라.
> **함정 23**: `type="saturate"`의 결과는 색공간에 따라 극적으로 다르다(§0.5). 브랜드 흑백 톤을 맞추려면 `sRGB`를 명시하라.
---
### 3.2 `<feComponentTransfer>` + `<feFuncR/G/B/A>`
채널별 **전달 함수**를 적용한다. 톤 커브 도구라고 생각하면 된다.
| type | 파라미터 | 공식 |
|---|---|---|
| `identity` | — | `C' = C` |
| `table` | `tableValues="v0 v1 ... vn"` | n개 값을 균등 구간으로 **선형 보간** |
| `discrete` | `tableValues="v0 v1 ... vn"` | 균등 구간별 **계단 함수**(보간 없음) |
| `linear` | `slope`(1), `intercept`(0) | `C' = slope·C + intercept` |
| `gamma` | `amplitude`(1), `exponent`(1), `offset`(0) | `C' = amplitude·C^exponent + offset` |
**table의 동작**: 값이 n개면 n1개 구간이 만들어진다.
- `tableValues="0 1"` → 항등(0→0, 1→1)
- `tableValues="0.05 0.98"` → 그림자를 0.05로, 하이라이트를 0.98로 리매핑 → **듀오톤의 원리**
- `tableValues="0 0.5 1"` → 3점 톤 커브
**discrete의 동작**: `tableValues="0 0.25 0.5 0.75 1"` → 5단계 포스터화. (검증됨)
**듀오톤 = 그레이스케일 + 채널별 2점 table** (검증됨)
```xml
<filter id="duotone" color-interpolation-filters="sRGB">
<feColorMatrix type="matrix" values="
0.2126 0.7152 0.0722 0 0
0.2126 0.7152 0.0722 0 0
0.2126 0.7152 0.0722 0 0
0 0 0 1 0"/>
<feComponentTransfer>
<!-- 그림자색 #0D1A59, 하이라이트색 #FA591A -->
<feFuncR type="table" tableValues="0.05 0.98"/>
<feFuncG type="table" tableValues="0.10 0.35"/>
<feFuncB type="table" tableValues="0.35 0.10"/>
</feComponentTransfer>
</filter>
```
**hex → tableValues 변환 공식**
```
shadowColor #RRGGBB, highlightColor #RRGGBB
feFuncR tableValues = "R_shadow/255 R_highlight/255"
feFuncG tableValues = "G_shadow/255 G_highlight/255"
feFuncB tableValues = "B_shadow/255 B_highlight/255"
```
3색(트라이톤)이면 값 3개, 4색이면 4개를 넣으면 된다.
> **함정 24**: **`color-interpolation-filters="sRGB"`를 안 넣으면 듀오톤 색이 전부 어긋난다.** 이 프리미티브에서는 필수라고 봐도 된다.
> **함정 25**: 같은 채널의 `feFunc*`를 여러 개 쓰면 **마지막 것만** 적용된다.
> **함정 26**: 계산은 **비프리멀티플라이드(non-premultiplied)** 값으로 이뤄진다. 반투명 영역에서 색이 튀면 이 때문이다.
---
## 4. 합성 프리미티브
---
### 4.1 `<feBlend>`
| 속성 | 기본값 |
|---|---|
| `mode` | `normal` |
지원 모드 (CSS 블렌드 모드와 동일):
`normal, multiply, screen, overlay, darken, lighten, color-dodge, color-burn, hard-light, soft-light, difference, exclusion, hue, saturation, color, luminosity`
> `in`**위**, `in2`**아래** 레이어다. 포토샵 레이어 순서와 반대로 헷갈리기 쉽다.
---
### 4.2 `<feComposite>`
| 속성 | 기본값 |
|---|---|
| `operator` | `over` |
| `k1, k2, k3, k4` | `0` |
**Porter-Duff 연산자**
| operator | 결과 |
|---|---|
| `over` | `in``in2` 위에 (기본) |
| `in` | **`in2`의 알파로 `in`을 자른다** ← 마스킹의 핵심 |
| `out` | `in2`가 없는 곳의 `in`만 남긴다 ← 속 빈 아웃라인 |
| `atop` | `in2` 영역 안에서만 `in`을 얹는다 |
| `xor` | 겹치지 않는 부분만 |
| `lighter` | 가산 합성 |
| `arithmetic` | 아래 공식 |
**arithmetic 공식**
```
result = k1·i1·i2 + k2·i1 + k3·i2 + k4
```
| 목적 | k1 | k2 | k3 | k4 |
|---|---|---|---|---|
| 단순 덧셈 (조명 합성) | 0 | 1 | 1 | 0 |
| 곱셈 (multiply) | 1 | 0 | 0 | 0 |
| `in`만 통과 | 0 | 1 | 0 | 0 |
| 50:50 블렌드 | 0 | 0.5 | 0.5 | 0 |
| 노이즈 가산 (±0.25) | 0 | 1 | 0.5 | 0.25 |
> **함정 27**: `arithmetic`은 **프리멀티플라이드 알파 값**에 대해 계산된다. 반투명 입력에서 예상 밖 결과가 나오면 `feFlood`+`in`으로 불투명화 후 처리하라. Firefox에는 `arithmetic` 관련 시각 아티팩트 버그 리포트도 있다.
> **함정 28**: 조명 프리미티브 결과를 원본과 합칠 때는 관례적으로 `operator="arithmetic" k1=0 k2=1 k3=1 k4=0`(가산)을 쓴다. 그냥 `over`로 얹으면 조명이 원본을 덮어버린다.
---
### 4.3 `<feMerge>` / `<feMergeNode>`
여러 레이어를 순서대로 `over` 합성한다. **뒤에 오는 `feMergeNode`가 위**에 쌓인다.
```xml
<feMerge>
<feMergeNode in="glow"/> <!-- 맨 아래 -->
<feMergeNode in="ring"/>
<feMergeNode in="SourceGraphic"/> <!-- 맨 위 -->
</feMerge>
```
> `feComposite operator="over"`를 여러 번 체인하는 것과 같지만 훨씬 읽기 쉽다.
---
## 5. 조명 프리미티브
알파 채널을 **높이맵(범프맵)** 으로 해석해 3D 조명을 계산한다. `feTurbulence`와 조합하면 종이·가죽·금속 질감이 나온다.
### 5.1 `<feDiffuseLighting>` (난반사 — 무광)
| 속성 | 기본값 | 의미 |
|---|---|---|
| `surfaceScale` | `1` | 알파=1일 때의 표면 높이. 클수록 굴곡 심함 |
| `diffuseConstant` | `1` | 반사 계수(kd). 0 이상 |
| `lighting-color` | `white` | 광원 색. **CSS 프로퍼티 → 애니메이션 가능** |
| `kernelUnitLength` | (없음) | 노멀 계산 샘플 간격. 대개 생략 |
결과는 **불투명한 RGBA**다(알파=1). 그래서 반드시 원본 알파로 다시 잘라야 한다.
### 5.2 `<feSpecularLighting>` (정반사 — 광택)
| 속성 | 기본값 | 범위 |
|---|---|---|
| `surfaceScale` | `1` | — |
| `specularConstant` | `1` | ks, 0 이상 |
| `specularExponent` | `1` | **1 ~ 128**. 클수록 하이라이트가 작고 날카로움 |
| `lighting-color` | `white` | — |
결과는 **정반사 성분만 담긴 RGBA**로, 알파가 0이 아닌 영역이 하이라이트다. 원본에 **가산**해야 한다.
### 5.3 광원 요소 (정확히 하나를 자식으로)
```xml
<feDistantLight azimuth="45" elevation="60"/>
```
| 속성 | 기본값 | 의미 |
|---|---|---|
| `azimuth` | `0` | xy평면 방향각 0~360° |
| `elevation` | `0` | 고도각 0~90°. 낮을수록 그림자가 길고 대비 강함 |
```xml
<fePointLight x="60" y="20" z="120"/>
```
| 속성 | 기본값 |
|---|---|
| `x`, `y`, `z` | `0` |
```xml
<feSpotLight x="55" y="15" z="90"
pointsAtX="110" pointsAtY="80" pointsAtZ="0"
specularExponent="6" limitingConeAngle="42"/>
```
| 속성 | 기본값 | 의미 |
|---|---|---|
| `x/y/z` | `0` | 광원 위치 |
| `pointsAtX/Y/Z` | `0` | 조준점 |
| `specularExponent` | `1` | 중심부 집중도 |
| `limitingConeAngle` | (없음=제한 없음) | 원뿔 반각(도). 넘으면 빛 없음 |
### 5.4 검증된 조합 패턴
```xml
<!-- (A) 종이 질감: 노이즈를 높이맵으로 -->
<filter id="paper" x="0%" y="0%" width="100%" height="100%">
<feTurbulence type="fractalNoise" baseFrequency="0.04" numOctaves="5" seed="3" result="noise"/>
<feDiffuseLighting in="noise" lighting-color="#e8e0d0" surfaceScale="2" result="lit">
<feDistantLight azimuth="45" elevation="60"/>
</feDiffuseLighting>
<feComposite in="lit" in2="SourceAlpha" operator="in"/>
</filter>
```
```xml
<!-- (B) 광택 버튼: 알파 블러를 범프맵으로 -->
<filter id="specular" x="-20%" y="-20%" width="140%" height="140%"
color-interpolation-filters="sRGB">
<feGaussianBlur in="SourceAlpha" stdDeviation="6" result="bump"/>
<feSpecularLighting in="bump" surfaceScale="6" specularConstant="1"
specularExponent="25" lighting-color="#ffffff" result="spec">
<fePointLight x="60" y="20" z="120"/>
</feSpecularLighting>
<feComposite in="spec" in2="SourceAlpha" operator="in" result="specClip"/>
<feComposite in="SourceGraphic" in2="specClip" operator="arithmetic"
k1="0" k2="1" k3="1" k4="0"/>
</filter>
```
```xml
<!-- (C) 스포트라이트: 조명을 screen으로 얹기 -->
<filter id="spot" x="-20%" y="-20%" width="140%" height="140%"
color-interpolation-filters="sRGB">
<feGaussianBlur in="SourceAlpha" stdDeviation="5" result="bump"/>
<feDiffuseLighting in="bump" surfaceScale="5" diffuseConstant="1"
lighting-color="#ffd88a" result="light">
<feSpotLight x="55" y="15" z="90" pointsAtX="110" pointsAtY="80" pointsAtZ="0"
specularExponent="6" limitingConeAngle="42"/>
</feDiffuseLighting>
<feComposite in="light" in2="SourceAlpha" operator="in" result="lightClipped"/>
<feBlend in="SourceGraphic" in2="lightClipped" mode="screen"/>
</filter>
```
> 검증됨: (A)(B)(C) 모두 Chromium에서 의도대로 렌더. (C)에서 `mode="multiply"`로 바꾸면 음영/비네트 룩이 된다.
> **함정 29**: 조명 결과를 그냥 출력하면 **필터 영역 전체가 불투명 사각형**으로 칠해진다. `feComposite operator="in"` + `SourceAlpha` 클리핑을 잊지 마라.
> **함정 30**: `surfaceScale`이 크면 (20~50) 스투코/구겨진 플라스틱 같은 과장된 질감이 된다. 종이 느낌은 1~3이 적당.
> **함정 31**: `kernelUnitLength`는 엔진마다 해석이 달라 결과가 갈린다. 크로스브라우저가 중요하면 **쓰지 마라.**
---
## 6. CSS 단축 필터 ↔ 프리미티브 대응표
| CSS 함수 | 등가 프리미티브 | 비고 |
|---|---|---|
| `blur(<len>)` | `feGaussianBlur stdDeviation="<len>"` | CSS는 sRGB |
| `brightness(a)` | `feComponentTransfer` + `type="linear" slope="a"` | |
| `contrast(a)` | `feComponentTransfer` + `type="linear" slope="a" intercept="-(0.5a)+0.5"` | |
| `grayscale(a)` | `feColorMatrix type="matrix"` (휘도 행렬 보간) | |
| `sepia(a)` | `feColorMatrix type="matrix"` | |
| `saturate(a)` | `feColorMatrix type="saturate" values="a"` | |
| `hue-rotate(deg)` | `feColorMatrix type="hueRotate" values="deg"` | |
| `invert(a)` | `feComponentTransfer type="table" tableValues="a 1-a"` | |
| `opacity(a)` | `feComponentTransfer feFuncA type="table" tableValues="0 a"` | |
| `drop-shadow(...)` | `feDropShadow` | |
**결정적 차이**: CSS 단축 함수는 스펙상 **sRGB에서 동작**하고, SVG `<filter>`는 **linearRGB가 기본**이다. 같은 결과를 원하면 `color-interpolation-filters="sRGB"`를 명시하라.
**언제 CSS 단축을 쓰나**: 단순 blur/그림자/채도만 필요하면 CSS 단축이 **더 빠르고 더 잘 가속되며 Safari 호환성이 좋다.** SVG 필터는 CSS로 표현 불가능한 것(노이즈, 변위, 채널 분리, 알파 대비, 조명)에만 써라.
---
## 7. 애니메이션 가능성 매트릭스
| 대상 | SMIL `<animate>` | CSS 애니메이션 | JS (`setAttribute`) |
|---|---|---|---|
| `baseFrequency`, `stdDeviation`, `scale`, `radius`, `dx/dy`, `k1~k4`, `values` | ✅ | ❌ (CSS 프로퍼티가 아님) | ✅ |
| `flood-color`, `flood-opacity`, `lighting-color` | ✅ | ✅ **CSS 프로퍼티다** | ✅ |
| `filter` 속성 자체 (`url(#a)``url(#b)`) | — | ❌ 보간 불가, 즉시 전환만 | — |
| CSS 단축 필터 (`blur(2px)``blur(8px)`) | — | ✅ 보간됨 | — |
| 필터 걸린 요소의 `transform`/`opacity` | — | ✅ (단, 필터 재계산 유발 가능) | — |
**핵심**: `feTurbulence``baseFrequency`를 CSS로 애니메이션하려는 시도는 **작동하지 않는다.** SMIL이나 JS를 써야 한다. 대신 `flood-color`/`lighting-color`는 CSS 트랜지션으로 부드럽게 다룰 수 있어, 호버 인터랙션에 매우 유용하다.
```xml
<!-- SMIL: baseFrequency 애니메이션 (검증됨) -->
<feTurbulence type="turbulence" baseFrequency="0.008 0.04" numOctaves="2" seed="2" result="n">
<animate attributeName="baseFrequency" dur="8s"
values="0.008 0.04;0.012 0.055;0.008 0.04" repeatCount="indefinite"/>
</feTurbulence>
```
```xml
<!-- SMIL: flood-color 애니메이션 (검증됨) -->
<feFlood flood-color="#22d3ee" result="col">
<animate attributeName="flood-color"
values="#22d3ee;#a855f7;#f43f5e;#22d3ee" dur="4s" repeatCount="indefinite"/>
</feFlood>
```
```js
// JS: seed 순환으로 squigglevision (SMIL보다 제어가 쉽다)
const t = document.querySelector('#squiggle feTurbulence');
let i = 0;
const seeds = [1, 2, 3, 4];
setInterval(() => { t.setAttribute('seed', seeds[i++ % seeds.length]); }, 120);
```
---
## 8. 작성 전 체크리스트
작성한 필터가 이상하면 위에서부터 확인하라.
1. `id` 오타 / `url(#id)``#` 누락
2. `baseFrequency`, `stdDeviation`, `radius`, `scale` — **기본값이 0이라 지정 안 하면 무효과**
3. `feDisplacementMap``xChannelSelector`/`yChannelSelector` — **기본값 A**
4. 필터 영역이 좁아 잘리는가 → `x/y/width/height` 확대
5. 색이 이상한가 → `color-interpolation-filters="sRGB"`
6. `feFlood`/조명 결과가 사각형으로 넘치는가 → `feComposite operator="in"` 누락
7. 분기했는데 무시되는가 → `in`을 양쪽 다 명시했는가
8. `feColorMatrix` 값을 0~255로 썼는가 → 0~1로
9. 텍스트가 뭉개지는가 → 필터 컨테이너 안에 텍스트를 넣지 마라(§03)
10. `feTurbulence` seed가 514/1977/2337/4777/8032/9615인가 → 사각 아티팩트