# svg-filters — 재질(surface)

4-2 단계에서 읽는다. 결정할 것은 하나다: **이 디자인의 표면은 무엇으로 되어 있는가.**

SVG 필터는 장식이 아니라 재질을 만드는 도구다. "예뻐 보이니까" 얹는 순간 슬롭이 된다. 종이·유리·필름·잉크 중 무엇인지 **먼저 말로 정하고** 프리미티브를 고른다.

**필터는 three.js보다 먼저 검토한다.** 같은 인상을 필터로 낼 수 있으면 WebGL을 쓰지 않는다. 유리 질감·노이즈 표면·색 분산·유기적 형태는 대부분 필터로 충분하다. 4-3으로 넘어가는 것은 필터로 안 되는 것이 남았을 때뿐이다.

## 1. 재질 → 필터 매핑

이 표가 이 문서의 본체다. 원하는 재질을 찾고 해당 레시피로 간다.

| 재질 | 조합 | 비용 | 프리셋 |
|---|---|---|---|
| **필름 (그레인)** | `feTurbulence(fractalNoise)` → data URI 배경 | 상 | **editorial 기본값** · anti-grid 강하게 · swiss-minimal 최약 1개 · **dark-instrument 금지**(어두운 배경에선 노이즈로 보인다) |
| **종이 (요철)** | `feTurbulence` → `feDiffuseLighting`+`feDistantLight` | 중 | editorial · anti-grid |
| **인쇄물 (리소)** | CSS `mix-blend-mode:multiply` 잉크 + 그레인 + 판 어긋남 | 중 | anti-grid · editorial |
| **잉크 (번짐·거친 가장자리)** | `feTurbulence` → `feDisplacementMap` → 블러 → 알파 대비 | 중 | anti-grid · editorial |
| **유리 (굴절)** | `feImage` 변위맵 → `feDisplacementMap` + `backdrop-filter` | 중 | dark-instrument(내비 정도) · **swiss-minimal 금지** |
| **금속·젤리 (광택)** | `feGaussianBlur(SourceAlpha)` → `feSpecularLighting` → `feComposite arithmetic` | 중 | dark-instrument · anti-grid |
| **안개 (글로우)** | `feDropShadow` 체인 / CSS `drop-shadow` | 중 | dark-instrument — 강조 하나에만, 좁게 |
| **액체 (융합)** | `feGaussianBlur` → `feColorMatrix` 알파 대비 | 중 | anti-grid |
| **색조 (듀오톤)** | `feColorMatrix` → `feComponentTransfer` | 상 | editorial · anti-grid · dark-instrument |
| **렌즈 분산 (수차)** | `feOffset` + 채널 분리 + `feBlend screen` | 중 | anti-grid(호버) |

**프리셋 한 줄 규칙** — `editorial`: 그레인이 기본 재질, 유리·네온은 어울리지 않는다 / `swiss-minimal`: 표면은 평평한 종이, 재질을 거의 안 쓴다, 글래스모피즘 금지 / `anti-grid`: 거친 재질(그레인·리소·잉크·수차), 매끄러운 유리는 반대편 / `dark-instrument`: 재질을 거의 안 쓴다, 좁은 글로우까지.

## 2. 공통 뼈대

필터 정의는 문서에 한 번만 넣는다.

```html
<svg width="0" height="0" style="position:absolute" aria-hidden="true" focusable="false">
  <defs><!-- filter들 --></defs>
</svg>
```

**텍스트는 필터 밖에 둔다.** 표면 레이어(`position:absolute; inset:0; filter:url(#x)`)와 콘텐츠 레이어(`position:relative`, 필터 없음)를 형제로 분리해라. 예외는 잉크·수차처럼 텍스트 자체가 대상인 경우뿐이다.

## 3. 레시피

### R1. 필름 그레인 — 가장 많이 쓴다

표면에 아날로그 입자를 얹는다. **요소에 `filter`를 걸지 말고 data URI 배경으로 넣어라.** 배경은 한 번만 래스터화되고 캐시된다.

```css
.grainy { position: relative; isolation: isolate; }
.grainy::after {
  content: ''; position: absolute; inset: 0; pointer-events: none;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='4' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.5'/%3E%3C/svg%3E");
  mix-blend-mode: overlay;
  opacity: var(--grain, 0.06);
}
```

**강도 3단계 — 이 값에서 시작해라. 그레인 과다는 가장 흔한 실패다.**
- `--grain: 0.03` 거의 안 보인다(밴딩만 사라짐) — swiss-minimal, 넓은 그라디언트
- `--grain: 0.06` 가까이서 보면 있다 — **editorial 기본값**
- `--grain: 0.14` 명백히 질감이다 — anti-grid
- `0.25` 이상은 **과하다. 슬롭 신호다.** 쓰지 마라

**조절**: `baseFrequency` 0.6(굵은 입자)~0.95(미세), 기본 0.8 · 밝은 배경엔 `mix-blend-mode: multiply` + opacity 절반
**비용: 상**

### R2. 종이 질감

노이즈를 높이맵 삼아 조명한다. 종이 섬유의 요철이 나온다.

```html
<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>
```
```css
.paper-surface { position: absolute; inset: 0; background: #e8e0d0;
                 border-radius: 6px; filter: url(#paper); }
```

**조절**: `baseFrequency` 0.02(카드지)/0.04(표준)/0.06(수제지) · `surfaceScale` 1~3(넘으면 스투코 벽) · `elevation` 낮출수록 대비 강함 · `lighting-color`는 종이 색과 같게
**함정**: 마지막 `feComposite operator="in"`을 빼면 필터 영역 전체가 종이로 칠해진다
**비용: 중** — 정적 배경 전용. 반복 배경이면 한 번 렌더해 PNG로 구워라

### R3. 리소그래프 인쇄

잉크는 CSS 블렌드로, 그레인만 필터로. **판 어긋남(misregistration)이 리소의 정체다.**

```html
<filter id="risoGrain" x="0%" y="0%" width="100%" height="100%" color-interpolation-filters="sRGB">
  <feTurbulence type="fractalNoise" baseFrequency="0.7" numOctaves="2" stitchTiles="stitch" seed="11" result="n"/>
  <feColorMatrix in="n" type="matrix" values="0 0 0 0 1  0 0 0 0 1  0 0 0 0 1  0.4 0.4 0.4 0 -0.1"/>
</filter>
```
```css
.riso { --misreg: 2px; position: relative; background: #f2ece0;
        overflow: hidden; isolation: isolate; }
.riso .ink-a, .riso .ink-b { position: absolute; mix-blend-mode: multiply; }
.riso .ink-a { background: #ff4f39; transform: translate(calc(var(--misreg) * -1), -1.5px); }
.riso .ink-b { background: #2f6bff; transform: translate(var(--misreg), 1.5px); }
.riso::after { content: ''; position: absolute; inset: 0; pointer-events: none;
  background: #fff; filter: url(#risoGrain); mix-blend-mode: multiply; opacity: .5; }
```

**조절**: `--misreg` 0.5~4px · 잉크 `#FF48B0`/`#0078BF`/`#FFE800`/`#00A95C` · 종이 `#F2ECE0`
**함정**: `isolation: isolate`가 없으면 블렌드가 바깥으로 샌다
**비용: 중**

### R4. 듀오톤 / 그라디언트 맵

잡다한 스톡 이미지를 브랜드 2색으로 통일한다. 사진이 여러 장일 때 효과가 가장 큰 한 수.

```html
<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>
    <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>
```
```css
.duo { filter: url(#duotone); }
```

위 값은 그림자 `#0D1A59` → 하이라이트 `#FA591A`. **다른 색으로 바꾸는 공식**: 채널마다 255로 나눠 `tableValues="S/255 H/255"`. 값을 3개 넣으면 트라이톤.

**조절**: 앞에 `<feComponentTransfer><feFuncR type="linear" slope="1.3" intercept="-0.15"/>…`를 넣어 원본 대비를 올릴 수 있다
**함정**: **`color-interpolation-filters="sRGB"`가 없으면 지정한 hex와 다른 색이 나온다.** 여기선 필수
**비용: 상**

### R5. 잉크 번짐 & 거친 가장자리

딱딱한 사각형을 손으로 찍은 것처럼. 변위 → 블러 → 알파 대비 순서가 잉크가 스며들고 마르는 과정이다.

```html
<!-- 도형·카드용 -->
<filter id="rough" x="-10%" y="-10%" width="120%" height="120%">
  <feTurbulence type="fractalNoise" baseFrequency="0.03" numOctaves="4" seed="12" result="n"/>
  <feDisplacementMap in="SourceGraphic" in2="n" scale="9" xChannelSelector="R" yChannelSelector="G"/>
</filter>

<!-- 텍스트용 -->
<filter id="inkbleed" x="-20%" y="-20%" width="140%" height="140%" color-interpolation-filters="sRGB">
  <feTurbulence type="fractalNoise" baseFrequency="0.05" numOctaves="4" seed="7" result="n"/>
  <feDisplacementMap in="SourceGraphic" in2="n" scale="4"
                     xChannelSelector="R" yChannelSelector="G" result="d"/>
  <feGaussianBlur in="d" stdDeviation="1.2" result="b"/>
  <feColorMatrix in="b" type="matrix" values="1 0 0 0 0  0 1 0 0 0  0 0 1 0 0  0 0 0 14 -6"/>
</filter>
```

**조절**: `scale` 도형 3/9/14, **텍스트는 4를 넘기지 마라** · `stdDeviation` 0.8~2.5 · 알파 행렬의 `14`를 키우면 마른 잉크
**함정**: 채널 셀렉터 기본값이 `A`다 — **반드시 `R`/`G` 명시.** 여러 요소에 같은 필터를 쓰면 전부 똑같이 흔들린다 → `seed`만 다른 필터 3개를 돌려 써라
**비용: 중**

### R6. 유리 굴절

배경이 실제로 굴절된다. **Chromium 계열에서만 렌더된다** — 폴백을 먼저 쓰고 그 위에 얹는다. 변위맵의 중앙을 128(중립)로 평평하게 두고 가장자리에만 변위를 몰아야 렌즈가 된다.

```html
<filter id="lens" x="0%" y="0%" width="100%" height="100%" color-interpolation-filters="sRGB">
  <feImage preserveAspectRatio="none" x="0" y="0" width="220" height="88" result="map"
    href="data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='220' height='88'%3E%3Cdefs%3E%3ClinearGradient id='rx' x1='0' y1='0' x2='1' y2='0'%3E%3Cstop offset='0' stop-color='rgb(0,0,0)'/%3E%3Cstop offset='0.28' stop-color='rgb(128,0,0)'/%3E%3Cstop offset='0.72' stop-color='rgb(128,0,0)'/%3E%3Cstop offset='1' stop-color='rgb(255,0,0)'/%3E%3C/linearGradient%3E%3ClinearGradient id='gy' x1='0' y1='0' x2='0' y2='1'%3E%3Cstop offset='0' stop-color='rgb(0,0,0)'/%3E%3Cstop offset='0.28' stop-color='rgb(0,128,0)'/%3E%3Cstop offset='0.72' stop-color='rgb(0,128,0)'/%3E%3Cstop offset='1' stop-color='rgb(0,255,0)'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect width='220' height='88' rx='44' fill='rgb(128,128,128)'/%3E%3Crect width='220' height='88' rx='44' fill='url(%23rx)' style='mix-blend-mode:screen'/%3E%3Crect width='220' height='88' rx='44' fill='url(%23gy)' style='mix-blend-mode:screen'/%3E%3C/svg%3E"/>
  <feDisplacementMap in="SourceGraphic" in2="map" scale="-60"
                     xChannelSelector="R" yChannelSelector="G"/>
</filter>
```
```css
/* 1단계: 모든 브라우저에서 성립하는 유리 — 이것만으로 완성돼 있어야 한다 */
.glass {
  width: 220px; height: 88px; border-radius: 44px;
  backdrop-filter: blur(14px) saturate(1.4);
  background: rgba(255,255,255,.12);
  border: 1px solid rgba(255,255,255,.28);
  box-shadow: inset 0 1px 1px rgba(255,255,255,.7), 0 10px 30px rgba(0,0,0,.4);
}
/* 2단계: 굴절을 지원하면 덮어쓴다 */
@supports (backdrop-filter: url(#lens)) {
  .glass { backdrop-filter: url(#lens) brightness(1.06) saturate(1.25); }
}
```

**조절**: `scale` −30(약함)~−90(강함), 부호 반전 시 오목 렌즈 · 그라디언트 stop `0.28`/`0.72`를 `0.15`/`0.85`로 하면 가장자리 집중 · inset 하이라이트가 유리 두께감을 만든다
**함정 (중요)**: 맵의 `width`/`height`가 **요소 크기와 정확히 같아야** 한다 → 크기별로 맵을 따로 만들고 크기가 변하는 요소에는 쓰지 마라. 그리고 `@supports (backdrop-filter: url(…))`는 **파싱만 검사하므로 신뢰할 수 없다** — 미지원 엔진이 통과하면 `backdrop-filter`가 통째로 무시된다. 그래서 1단계를 `background`+`box-shadow`만으로도 유리처럼 보이게 설계하는 것이 핵심이다
**비용: 중** — 페이지에 1~2개까지. 큰 면적 금지

### R7. 액체 융합 (구이 / 메타볼)

도형들이 서로 붙어 흐른다. 블러로 알파를 번지게 하고 대비로 다시 잘라낸다.

```html
<filter id="goo" x="-25%" y="-25%" width="150%" height="150%" color-interpolation-filters="sRGB">
  <feGaussianBlur in="SourceGraphic" stdDeviation="9" result="blur"/>
  <feColorMatrix in="blur" type="matrix" values="1 0 0 0 0  0 1 0 0 0  0 0 1 0 0  0 0 0 20 -9" result="goo"/>
  <feComposite in="SourceGraphic" in2="goo" operator="atop"/>
</filter>
```
```css
.goo-group { filter: url(#goo); padding: 24px; display: flex; gap: 6px; align-items: center; }
.goo-group > * { width: 56px; height: 56px; border-radius: 50%; background: var(--accent); }
```

**조절**: `stdDeviation` 5(가까워야 붙음)/9(표준)/20(형태 뭉개짐) · 알파 행렬의 `20`을 키우면 경계가 날카롭다(12~30) · 마무리는 `feComposite atop`(모서리 뾰족한 도형) 또는 `feBlend`(원본 색 선명)
**함정**: **필터 안에 텍스트를 넣으면 판독 불가능한 덩어리가 된다.** 컨테이너에 여백이 없으면 블롭이 잘린다
**비용: 중** — 400×400px 이하

### R8. 크로마틱 애버레이션 (RGB 분리)

렌즈 색수차. 호버·전환 순간에만. 상시 적용은 읽기를 방해한다.

```html
<filter id="chromatic" x="-20%" y="-20%" width="140%" height="140%" color-interpolation-filters="sRGB">
  <feOffset in="SourceGraphic" dx="-3" dy="0" result="rShift"/>
  <feColorMatrix in="rShift" type="matrix" result="red"
    values="1 0 0 0 0  0 0 0 0 0  0 0 0 0 0  0 0 0 1 0"/>
  <feColorMatrix in="SourceGraphic" type="matrix" result="green"
    values="0 0 0 0 0  0 1 0 0 0  0 0 0 0 0  0 0 0 1 0"/>
  <feOffset in="SourceGraphic" dx="3" dy="0" result="bShift"/>
  <feColorMatrix in="bShift" type="matrix" result="blue"
    values="0 0 0 0 0  0 0 0 0 0  0 0 1 0 0  0 0 0 1 0"/>
  <feBlend in="red" in2="green" mode="screen" result="rg"/>
  <feBlend in="rg" in2="blue" mode="screen"/>
</filter>
```
```css
.chroma { color: #fff; }              /* 흰색 소스여야 분리가 선명하다 */
.chroma:hover { filter: url(#chromatic); }
```

**조절**: `dx` ±1(미묘)/±3(표준)/±8(글리치) · `dy`를 함께 주면 대각 분리
**함정**: 유채색 소스에서는 탁해진다. 난독증·시각 피로 사용자에게 불리하므로 **본문 텍스트 금지**
**비용: 중**

### R9. 부드러운 글로우

어두운 배경의 강조. **먼저 CSS로 되는지 확인해라.** 색을 여러 겹 쌓아야 할 때만 SVG로 온다.

```css
/* 한 가지 색이면 이걸로 충분하다 — 훨씬 싸다 */
.glow-cheap { filter: drop-shadow(0 0 6px var(--accent)) drop-shadow(0 0 16px var(--accent)); }
```
```html
<filter id="glow" 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>
```

**조절**: `stdDeviation` 2/6/14 — 심지·중간·확산, 이 비율을 유지해라 · 바깥 레이어일수록 `flood-opacity`를 낮춘다
**함정**: 큰 `stdDeviation`(50+)은 매우 비싸다. **넓은 소프트 글로우는 `radial-gradient` 배경이 압도적으로 싸다**
**비용: 중** — dark-instrument에서는 강조 하나에만, 좁게

## 3-b. 실무에서 손이 줄어드는 레시피

질감을 만드는 필터는 데모에서 예쁘고, 아래 셋은 **매번 손으로 하던 일을 없앤다.**
전부 실측으로 확인한 값이다.

### 글자 모양대로 생기는 스크림

사진 위 캡션이 대비를 못 넘길 때, 반투명 박스를 사진마다 손으로 맞추는 대신.

```xml
<filter id="scrim" color-interpolation-filters="sRGB"
        x="-25%" y="-60%" width="150%" height="220%">
  <feMorphology in="SourceAlpha" operator="dilate" radius="4" result="fat"/>
  <feGaussianBlur in="fat" stdDeviation="3.5" result="soft"/>
  <!-- 블러가 알파를 옅게 만든다. 다시 세우지 않으면 스크림이 비친다 -->
  <feComponentTransfer in="soft" result="dense">
    <feFuncA type="linear" slope="4.4"/>
  </feComponentTransfer>
  <feFlood flood-color="#04100f" flood-opacity="1" result="ink"/>
  <feComposite in="ink" in2="dense" operator="in" result="scrim"/>
  <feMerge><feMergeNode in="scrim"/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
```

실측: 밝은 사진 위 흰 글자 **1.70:1 → 4.79:1**(AA 통과).
`slope` 를 1.8 로 뒀을 때는 3.12:1 에서 멈췄다 — **블러 뒤에 알파를 다시 세우는 단계가 핵심이다.**
박스가 아니라 글자를 따라가므로 사진을 덜 가린다.

### 색각 이상 시뮬레이션 — 유일하게 `linearRGB` 인 필터

상태를 색으로만 구분했는지 **눈으로는 못 잡는다.** 검사 도구로 쓴다.

```xml
<!-- 색각 변환은 물리적인 빛의 혼합이다. sRGB 로 돌리면 실제보다 덜 심하게 나온다 -->
<filter id="deuter" color-interpolation-filters="linearRGB">
  <feColorMatrix type="matrix"
    values="0.625 0.375 0     0 0
            0.700 0.300 0     0 0
            0     0.300 0.700 0 0
            0     0     0     1 0"/>
</filter>
```

실측: 흔한 초록·노랑·빨강 상태 배지 셋의 **최소 색거리 82 → 2**.
주의와 실패가 같은 색이 된다. 이 값이 한 자리로 떨어지면 색 말고 다른 단서가 필요하다.

### 구이(gooey) — 붙을지 말지는 눈대중이 아니라 산수다

커뮤니티에서 가장 많이 만들어지는 필터다(로더·메뉴 병합·인디케이터 전환).

```xml
<filter id="goo" color-interpolation-filters="sRGB"
        x="-20%" y="-40%" width="140%" height="180%">
  <feGaussianBlur in="SourceGraphic" stdDeviation="10" result="blur"/>
  <feColorMatrix in="blur" type="matrix" result="goo"
    values="1 0 0 0  0
            0 1 0 0  0
            0 0 1 0  0
            0 0 0 18 -7"/>
  <feComposite in="SourceGraphic" in2="goo" operator="atop"/>
</filter>
```

**붙는 조건**: 두 도형 사이 중간점의 블러된 알파가 `offset ÷ slope`(여기서는 7÷18 ≒ 0.39)를 넘어야 한다.

실측 — 지름 46px 원 셋:

| 간격 | stdDeviation | 중간점 알파 | 결과 |
|---|---|---|---|
| 18px | 7 | ≈ 0.2 | **안 붙는다** (덩어리 3개 그대로) |
| 12px | 10 | 문턱 통과 | 덩어리 **1개** |

값이 안 붙으면 `slope` 부터 만지지 마라 — 올리면 도형이 깎이고 각져 보인다.
**간격과 블러 반경의 비율**이 먼저다.
마지막 `feComposite atop` 이 없으면 블러된 헤일로가 남는다.

---

## 4. CSS로 되는 것은 CSS로

값싼 쪽을 먼저 쓴다. SVG 필터는 CSS로 표현 **불가능한** 것에만.

| 원하는 것 | 쓸 것 |
|---|---|
| 블러 · 밝기 · 대비 · 채도 · 색상 회전 · 단색 그림자 | **CSS `filter:` 함수.** 더 빠르고 잘 가속되며 Safari에서 안전하다 |
| 반투명 배경 흐리기 | **CSS `backdrop-filter: blur()`** |
| 스캔라인 · 줄무늬 · 격자 | **CSS `repeating-linear-gradient`.** 필터 불필요 |
| 넓은 소프트 글로우 · 비네트 | **CSS `radial-gradient` / `box-shadow`.** 큰 블러보다 훨씬 싸다 |
| 잉크 겹침 · 색 곱하기 | **CSS `mix-blend-mode`** |
| 노이즈 · 절차적 텍스처 | SVG 필터 — 단, **data URI 배경으로** |
| 변위 · 굴절 · 왜곡 / 채널 분리 · 알파 임계 · 톤커브 / 조명 · 범프맵 | SVG 필터. CSS에 대응물이 없다 |

## 5. 함정

| 증상 | 고치는 법 |
|---|---|
| 효과가 사각형으로 잘림 | 필터 영역 기본값은 사방 10%뿐 → `<filter x="-30%" y="-30%" width="160%" height="160%">` |
| 색이 물빠진 듯 밝고 채도가 낮음 / 듀오톤 hex가 안 맞음 | 필터 기본 연산이 linearRGB → `<filter color-interpolation-filters="sRGB">` 명시 |
| 변위가 엉뚱한 방향 | `xChannelSelector`/`yChannelSelector` 기본값이 `A` → `R`/`G` 명시 |
| `stdDeviation="10"`인데 화면이 사라짐 | `primitiveUnits="objectBoundingBox"`면 값이 0~1 스케일. 기본값 `userSpaceOnUse`를 유지하거나 전 수치 재계산 |
| `feFlood`/조명이 사각형으로 넘침 | 뒤에 `feComposite operator="in" in2="SourceAlpha"` 추가 |
| 색이 전부 흰색으로 날아감 / 분기 배선이 무시됨 | `feColorMatrix` 값은 0~1 스케일(255로 나눠라) · `in` 생략 시 직전 출력이 들어가므로 분기 양쪽 모두 `in` 명시 |
| 노이즈에 격자 사각형 | `seed` 514/1977/2337/4777/8032/9615 등에서 재현되는 스펙 버그 → 1~50 중 눈으로 확인한 값 고정 |
| 요소 안 텍스트가 뭉개짐 | 표면/콘텐츠 레이어 분리(§2) |
| 모달이 화면 밖으로 못 나감 | `filter`는 fixed/absolute 자손의 **컨테이닝 블록**을 만든다 → 필터는 표면 레이어에만, 오버레이는 `<body>` 직속 |
| Safari에서만 안 보임 | `backdrop-filter: url()` 미지원 · `feImage` 외부 파일 참조 미지원 · 대형 요소 렌더 실패 → 폴백 설계 + `feImage`는 **data URI만** |
| 모바일에서만 느림 | 일부 안드로이드 GPU는 필터 가속 불가 → 저사양 감지로 끄기(§7) |

**쓰지 말 것**: `edgeMode`(Safari 전용) · `kernelUnitLength`(엔진별 해석 상이) · `BackgroundImage`·`FillPaint`·`StrokePaint`(사실상 미구현).

## 6. 성능

**비용 = 필터 영역 픽셀 수 × 프리미티브 비용 × 재계산 빈도.** 세 항이 곱해진다.

- 거의 공짜: `feOffset` `feFlood` `feMerge` `feTile` / 싸다: `feColorMatrix` `feComponentTransfer` `feBlend` `feComposite`
- 보통: `feGaussianBlur` `feDropShadow`(반경 비례) / 비싸다: `feMorphology` `feDisplacementMap`
- **매우 비싸다: `feTurbulence` `feConvolveMatrix` `feDiffuseLighting` `feSpecularLighting`**

`feTurbulence`는 하드웨어 가속에 친화적이지 않고 큰 면적에서 CPU를 포화시킨다. **`numOctaves`는 4를 넘기지 마라** — 그 이상은 시각적 이득 없이 비용만 는다.

**필터 영역이 곱해진다**: 120%(기본)=1.44배 · 160%=2.56배 · 180%=3.24배. 그레인처럼 확장이 불필요한 필터는 `x="0%" y="0%" width="100%" height="100%"`로 줄여라.

### 필터를 애니메이션하지 마라

필터 값이 바뀌면 매 프레임 전체가 재계산된다. **SKILL.md 하드 게이트 8(`transform`/`opacity` 외 애니메이션 금지)에도 걸린다.** 필터는 정적으로 쓴다.

| 하고 싶은 것 | 대신 할 것 |
|---|---|
| 그레인이 지글거리게 | 안 한다. 정적 그레인으로 충분하다 |
| 노이즈가 흐르게 | CSS `background-position` 이동 또는 포기 |
| 호버 시 재질 세기 변화 | 필터를 건 **오버레이 레이어의 `opacity`**를 바꿔라. `filter` 값을 트랜지션하지 마라 |
| 호버 시 수차 켜기 | 상태 전환(on/off)으로만. 값 보간 금지 |
| 필터 걸린 요소를 이동 | 바깥 래퍼를 `transform`으로 움직이고 안쪽이 필터를 갖게 분리 |

**그 외** — `will-change: filter` 금지(레이어만 점유하고 필터 연산은 그대로다). 큰 면적 `backdrop-filter`는 실기기에서 **FPS 15~30% 하락**하므로 뷰포트 전체에 깔지 말고 칩·툴바·모달 헤더까지만. 종이 질감·리소 그레인처럼 변하지 않는 텍스처는 **PNG/WebP로 구워라**(런타임 비용 0). 검증은 **CPU throttling 4×**에서 한다.

## 7. 접근성 · 폴백

**규칙: 필터를 전부 끈 상태에서 디자인이 성립해야 한다.** 4단계 통과 조건이다. 감사용으로 `*, *::before, *::after { filter: none !important; backdrop-filter: none !important; }`를 주입해 확인하고, 무너지면 4-1로 돌아간다.

**prefers-reduced-motion** — 정적 필터는 모션이 아니므로 끌 필요 없다. 움직이는 것만 처리한다. SMIL(`<animate>`)은 CSS 미디어쿼리로 못 끄니 **애초에 쓰지 마라**(§6).
```css
@media (prefers-reduced-motion: reduce) { .goo-group { filter: none; } }
```

**저사양 감지**
```js
function reduceEffects() {
  if (matchMedia('(prefers-reduced-motion: reduce)').matches) return true;
  if (typeof navigator.hardwareConcurrency === 'number' && navigator.hardwareConcurrency <= 4) return true;
  if (typeof navigator.deviceMemory === 'number' && navigator.deviceMemory <= 4) return true; // Chromium 전용
  if (navigator.connection && navigator.connection.saveData) return true;
  return matchMedia('(max-width: 640px)').matches;
}
if (reduceEffects()) document.documentElement.classList.add('reduce-effects');
```
```css
.reduce-effects .paper-surface { filter: none; background: #e8e0d0; }
.reduce-effects .glass { backdrop-filter: blur(10px) saturate(1.4); }
.reduce-effects .grainy::after { opacity: .03; }
```

**필터가 실패하면 무엇이 보이나**
- 존재하지 않는 필터를 참조하면 현행 스펙상 **필터만 무시되고 요소는 정상 렌더**된다. 다만 구 엔진에서는 요소가 사라질 수 있다 — **ID 오타는 치명적**
- `backdrop-filter: url()` 미지원 엔진은 해당 선언 전체를 무시한다 → 배경과 테두리만 남는다. **그 상태가 이미 유리처럼 보여야 한다**
- 텍스트에 건 필터는 DOM을 바꾸지 않는다(선택·검색·스크린리더 유지). 단 듀오톤은 **대비를 낮추므로** 텍스트 위라면 렌더 결과로 대비비를 다시 측정해라

## 8. 채택 체크리스트

5단계 프리플라이트에서 그대로 돌린다. 하나라도 실패하면 4-2로 복귀.

- [ ] 이 필터가 만드는 **재질을 한 단어로 말할 수 있다**(종이/유리/필름/잉크/금속). 못 하면 장식이다 — 뺀다
- [ ] 프리셋과 재질이 어긋나지 않는다 (§1)
- [ ] 같은 인상을 **CSS로 낼 수 없음**을 확인했다 (§4). 그리고 필터로 낼 수 있어서 three.js를 쓰지 않았다
- [ ] 모든 필터에 `color-interpolation-filters="sRGB"`를 의도적으로 설정했다
- [ ] `feDisplacementMap`의 채널 셀렉터를 명시했다
- [ ] 필터 영역이 최소 크기이고, 잘리는 곳이 없다. `numOctaves` ≤ 4
- [ ] 그레인 opacity ≤ 0.15 (anti-grid 제외)
- [ ] `feImage`를 썼다면 data URI다
- [ ] **애니메이션되는 필터가 없다**(하드 게이트 8). `will-change: filter`도 없다
- [ ] `backdrop-filter`가 뷰포트 전체를 덮지 않는다
- [ ] 필터를 전부 끈 상태에서 페이지가 완성돼 있다
- [ ] 필터 건 요소 안에 본문 텍스트나 `position: fixed` 자손이 없다
- [ ] Safari·모바일 실기기, CPU throttling 4×에서 확인했다

> 근거: research/svg/01~03 (조사일 2026-08-20)

---

## 유리 테두리(헤어라인)를 그릴 때

1px 테두리에 광을 흘리는 흔한 방법은 그라디언트를 깔고 마스크로 테두리만 남기는 것이다.

```css
.glass::after {
  content: ""; position: absolute; inset: 0; padding: 1px;
  border-radius: inherit;
  background: var(--glass-rim);
  mask: linear-gradient(black 0 0) content-box, linear-gradient(black 0 0);
  mask-composite: exclude;   /* -webkit-mask-composite: xor */
}
```

**`conic-gradient` 를 쓰지 마라.** 중심에서 각도로 돌기 때문에, 납작한 형태
(내비게이션 바처럼 1180×54)에서는 **각도가 급변해 변마다 광이 끊긴다.**
긴 변에서는 색이 거의 안 변하고 짧은 변에서 한꺼번에 바뀐다.

```css
/* 네 변이 같은 그라디언트를 공유한다 — 테두리를 따라 이어진다 */
--glass-rim: linear-gradient(100deg,
  rgb(255 255 255 / 0.34) 0%,
  rgb(255 255 255 / 0.10) 26%,
  rgb(255 255 255 / 0.06) 58%,
  rgb(255 255 255 / 0.28) 100%);
```

정사각형에 가까운 카드라면 conic 도 자연스럽다. **비율을 보고 골라라.**

글로우를 더한다면 테두리에서 얕게 번지는 정도로 충분하다 —
`0 0 22px -8px` 처럼 **음수 spread** 를 써서 형태 밖으로 새지 않게 한다.
밝은 배경에서는 흰빛 대신 그림자로 뒤집어야 같은 인상이 난다.

### 테두리 광을 회전시키려면

`conic-gradient(from var(--angle), …)` 를 `@property` 로 애니메이션하는 방법이 흔히 보이는데,
**그건 매 프레임 배경을 다시 칠한다.** 컴포지터에서 못 돌고 메인 스레드가 페인트한다.
`transform`·`opacity` 만 애니메이션하라는 규칙에 걸린다.

**정사각형 원뿔 레이어를 마스크 안에서 회전시켜라.** 움직이는 것은 `rotate` 뿐이다.

```css
.rim {                      /* 테두리만 남기는 마스크 */
  position: absolute; inset: 0; border-radius: inherit;
  overflow: hidden; padding: 1px;
  mask: linear-gradient(black 0 0) content-box, linear-gradient(black 0 0);
  mask-composite: exclude;
}
.rim::before {              /* 실제로 도는 것 */
  content: ""; position: absolute; top: 50%; left: 50%;
  width: 200%; aspect-ratio: 1;      /* 정사각형이라 회전해도 밝기가 균일하다 */
  translate: -50% -50%;
  background: conic-gradient(from 0deg, transparent 0 58%, var(--glow) 74%, transparent 86%);
  animation: rim-spin 11s linear infinite;
}
@keyframes rim-spin { to { rotate: 360deg; } }
```

**두 가지를 지켜라.**

- `aspect-ratio: 1` — 정사각형이어야 회전 중 밝기가 일정하다. 납작하면 변마다 달라진다
- `width: 200%` — 요소보다 크게 잡아야 납작한 형태에서도 모서리까지 닿는다

`prefers-reduced-motion` 에서는 애니메이션만 빼면 된다. 정지 상태의 광은 그대로 남는다.

---

## 데모는 추상 도형이 아니라 실무 소재에 걸어라

필터를 소개할 때 줄무늬·체크무늬 같은 도형에 걸면 **필터가 무엇을 하는지는 보이지만
그것을 왜 쓰는지는 보이지 않는다.** 보는 사람이 "그래서?" 하고 넘어간다.

**각 필터를 그 필터가 실제로 걸리는 자리에 올려라.**

| 필터 | 실무 소재 | 그 소재라야 보이는 것 |
|---|---|---|
| `feTurbulence` 그레인 | **매끈한 그라디언트 면** | 노이즈가 없으면 밋밋하다는 대비 |
| `feDisplacementMap` 굴절 | **글자** | 유리 뒤의 것이 실제로 휘는 것 |
| `feColorMatrix` 듀오톤 | **사진** | 출처가 다른 사진이 한 팔레트로 묶이는 것 |

실측: 셋 다 같은 줄무늬에 걸었더니 "이게 어필이 되는 예시냐"는 지적을 받았다.
소재를 바꾸자 설명 문장 없이도 용도가 읽혔다.

**어필 포인트는 효과가 아니라 대체 비용이다.** `feTurbulence` 의 진짜 매력은
"노이즈가 예쁘다"가 아니라 **200KB JPEG 텍스처를 300바이트 코드가 대신한다**는 것이다.
카피에 그 수치를 써라.

## `feComposite` 입력 순서를 뒤집으면 필터가 조용히 죽는다

```xml
<!-- 틀림: 원본이 노이즈 위에 올라가 노이즈를 통째로 가린다 -->
<feComposite in="SourceGraphic" in2="soft" operator="over" />
```

`operator="over"` 는 **`in` 을 `in2` 위에** 올린다. 원본을 `in` 에 두면 효과가 전부 덮인다.
**에러도 경고도 없다.** 필터는 정상적으로 적용되고 결과만 원본과 같다.

그레인처럼 표면에 **섞이는** 효과는 합성이 아니라 블렌드다.

```xml
<feBlend in="SourceGraphic" in2="soft" mode="overlay" />
```

그리고 `baseFrequency` 를 0.8 이상으로 올리면 노이즈가 픽셀보다 촘촘해져
화면에서 회색으로 뭉갠다. 그레인은 **0.5~0.7** 이 보인다.

**필터를 붙였는데 "아무 변화가 없다"면 의심 순서는 이렇다.**

1. `in`/`in2` 순서가 뒤집혔는가
2. `baseFrequency` 가 너무 높은가
3. 필터 영역(`x`/`y`/`width`/`height`)이 좁아 결과가 잘렸는가

---

## 리퀴드 글래스 — `backdrop-filter: url()` 은 살아 있다

**정정부터.** 이 문서는 한때 "Chrome 은 `CSS.supports` 로 true 를 주면서 렌더에서
`backdrop-filter` 선언 전체를 버린다" 고 적고 있었다. **틀렸다.**
Chrome 151 에서 실측하면 computed 값이 `none` 이 아니라 `url("#id")` 로 유지되고,
배경이 실제로 굴절된다. 우리가 겪은 실패는 다른 원인이었다(아래 §함정).

### 구조

```html
<svg style="position:absolute;width:0;height:0">
  <filter id="lg" color-interpolation-filters="sRGB"
          x="0" y="0" width="100%" height="100%">
    <!-- 변위 지도. feTurbulence 가 아니라 사전계산 이미지다 -->
    <feImage href="data:image/png;base64,…" x="0" y="0" width="240" height="160" result="map"/>
    <feDisplacementMap in="SourceGraphic" in2="map" scale="34"
                       xChannelSelector="R" yChannelSelector="G"/>
  </filter>
</svg>
```
```css
.glass { backdrop-filter: url(#lg); }
```

### 변위 지도를 어떻게 만드나

**노이즈가 아니다.** 유리는 무작위로 휘지 않는다 — 가장자리에서 **경계 법선 방향**으로 휜다.

- `R` 채널 = X 변위, `G` 채널 = Y 변위, **128 이 중립**(변위 없음)
- 값 인코딩: `r = 128 + x * 127`, `g = 128 + y * 127`
- 가장자리에서 거리 `d` 를 구해 그 함수로 변위 크기를 정한다
- 감쇠 곡선은 **Squircle** `y = ⁴√(1 - (1-x)⁴)` 이 원보다 자연스럽다(Apple 이 쓰는 곡선)

캔버스로 즉석 생성해 `toDataURL()` 로 넘기면 이미지 파일이 필요 없다.
실측: 240×160 맵이 **2.7KB** data URI.

### 함정 — 여기서 실패한다

1. **`feImage` 의 `width`/`height` 를 요소 크기와 맞춰라.**
   `backdrop-filter` 는 요소 크기에 맞춰 필터 이미지를 자동 조정하지 **않는다.**
   크기가 어긋나면 굴절이 엉뚱한 곳에 걸리거나 아예 안 보인다.
   요소가 리사이즈되면 맵을 다시 만들어야 한다
2. **Chrome 전용이다.** Safari·Firefox 는 `backdrop-filter` 에 SVG 필터를 받지 않는다.
   `@supports` 로 감싸고 `blur()` 폴백을 둬라
3. **모양·크기를 애니메이션하지 마라.** 변위 맵 전체가 다시 계산된다.
   `<feDisplacementMap scale>` 만 애니메이션해라 — 그건 재계산이 없다
4. `color-interpolation-filters="sRGB"` 를 빼면 색이 어둡게 뜬다

```css
.glass { backdrop-filter: blur(18px) saturate(150%); }   /* 기본 */
@supports (backdrop-filter: url(#lg)) {
  .glass { backdrop-filter: url(#lg); }                   /* 굴절 */
}
```

> 표준화가 진행 중이다(w3c/svgwg#1142). 지금은 Chrome 한정 기법으로 다루고,
> **굴절이 없어도 성립하는 화면**을 먼저 만든 뒤에 얹어라.

### 실패 기록 — `feTurbulence` 를 변위 지도로 쓰면 안 된다

위에 "노이즈가 아니다" 라고 적어놓고, 나중에 이 문서를 안 보고 이렇게 썼다.

```xml
<feTurbulence baseFrequency="0.02" numOctaves="2" result="n"/>
<feGaussianBlur in="n" stdDeviation="2.5" result="soft"/>
<feDisplacementMap in="SourceGraphic" in2="soft" scale="42" .../>
```

`backdrop-filter` 에 걸고 재보니 필터 전후 **평균 채널차가 1.66** 이었다.
`scale` 을 42 에서 120 으로 세 배 올려도 2.96 — 사실상 아무 일도 안 일어난다.
그때 내린 결론은 "Chrome 이 `backdrop-filter` 체인에서 displacement 를 못 쓴다" 였고,
**그것도 틀렸다.** 원인은 두 가지 다 이 문서에 이미 적혀 있었다.

1. **블러한 난류는 중립값으로 수렴한다.** 변위 지도에서 128 은 "움직이지 마라" 다.
   `fractalNoise` 의 평균이 그 근처인데 거기에 블러까지 걸면 지도 전체가 128 이 된다.
   `scale` 을 아무리 키워도 0 을 곱하는 것이라 변하지 않는다.
2. **`feImage` 의 크기를 요소에 맞추지 않았다.** 자동 조정되지 않는다.

교훈은 필터가 아니라 작업 방식이다 — **이미 기록해 둔 함정에 다시 빠지는 것은
문서가 없어서가 아니라 쓰기 전에 읽지 않아서다.** 4단계에서 필터를 쓰기로 했으면
레시피를 짜기 **전에** 이 문서의 §함정을 먼저 편다.

> 진단법: 굴절이 안 보일 때 `scale` 부터 올리지 마라. 지도를 화면에 직접 그려
> **128 이 아닌 픽셀이 실제로 있는지** 먼저 봐라. 없으면 지도가 문제다.

### 지도를 SVG 로 그릴 때 — R 과 G 를 한 장에 못 담는다

`linearGradient` 하나로는 R 과 G 를 각각 다르게 변화시킬 수 없다.
겹쳐 그리면 뒤엣것이 앞엣것을 덮고, 블렌드 모드로는 채널이 섞인다.
**지도를 두 장 놓고 displacement 를 두 번 건다.** 쓰지 않는 축에는
128 로 고정된 `B` 채널을 물려 이동량을 0 으로 만든다.

```xml
<filter id="lens" color-interpolation-filters="sRGB" x="0" y="0" width="100%" height="100%">
  <feImage href={MAP_X} x="0" y="0" width="216" height="72" result="mx"/>
  <feDisplacementMap in="SourceGraphic" in2="mx" scale="30"
                     xChannelSelector="R" yChannelSelector="B" result="dx"/>
  <feImage href={MAP_Y} x="0" y="0" width="216" height="72" result="my"/>
  <feDisplacementMap in="dx" in2="my" scale="30"
                     xChannelSelector="B" yChannelSelector="G"/>
</filter>
```

지도 한 장은 가장자리에서만 중립을 벗어나는 그라디언트다.
data URI 안에서 `#` 은 URI 를 끊으므로 색은 `rgb()` 로 적는다.

```js
`<svg xmlns="http://www.w3.org/2000/svg" width="216" height="72">` +
  `<defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="0">` +
    `<stop offset="0"    stop-color="rgb(255,128,128)"/>` +   // 왼쪽 끝: +X 최대
    `<stop offset="0.30" stop-color="rgb(128,128,128)"/>` +   // 가운데: 중립
    `<stop offset="0.70" stop-color="rgb(128,128,128)"/>` +
    `<stop offset="1"    stop-color="rgb(0,128,128)"/>` +     // 오른쪽 끝: -X 최대
  `</linearGradient></defs>` +
  `<rect width="216" height="72" fill="url(#g)"/></svg>`
```

**확인 방법**: 배경에 격자를 깔아라. 사진만 놓으면 무엇이 어떻게 밀렸는지 알 수 없다.
제대로 걸리면 **패널 가장자리에서 격자 셀이 눌린다** — 그것이 렌즈의 지문이다.
실측: 필터 전후 평균 채널차 1.66(난류 지도) → **9.29**(그린 지도), 최대 126.

`backdrop-filter` 앞의 `blur()` 는 약하게 둬라. 세게 걸면 굴절이 뭉개져서
지도를 고쳐도 차이가 안 보인다.
