# 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에서는 강조 하나에만, 좁게

## 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)
