designpaca/research/canvas/04-fallbacks.md
Yun Chan 8808c672dc 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)
2026-08-20 10:48:00 +09:00

14 KiB

04. 폴백 전략 비교

HTML-in-Canvas를 못 쓰는 브라우저(Firefox, Safari, 그리고 플래그/OT 없는 Chrome Stable 전부)에서 비슷한 인상을 내는 방법들. 핵심 질문 3개로 갈린다: ① 상호작용을 유지하는가 ② 매 프레임 갱신되는가 ③ 렌더된 픽셀을 셰이더가 읽을 수 있는가


1. 한눈에 보는 비교표

방식 원리 상호작용
유지
매 프레임
실시간
픽셀을
셰이더로
CSS 충실도 접근성 브라우저 비용
A. 네이티브 HTML-in-Canvas 브라우저가 요소 렌더링을 캔버스/텍스처로 직접 복사 완전 100% (브라우저 본체) 자동 (DOM 그대로) Chrome 148+ OT / 플래그만 낮음 (GPU 경로)
B. three-html-render 폴리필 네이티브 있으면 fast path, 없으면 foreignObject 래스터화 + DOM 오버레이 (오버레이가 이벤트 수신) △ 무효화마다 재래스터 높음 (브라우저 렌더러 재사용) (실 DOM 유지) 전 브라우저 중 (직렬화·이미지 디코드)
C. SnapDOM DOM → 인라인 SVG foreignObject → 래스터화 정지 이미지 매우 높음 (그라디언트·필터·블렌드·transform 포함) 별도 대체 필요 전 모던 브라우저 낮음 (html2canvas 대비 2~16배 빠름)
D. foreignObject 직접 구현 직접 SVG 직렬화 → <img>drawImage 높음 (단 폰트·이미지 인라인 직접 처리) 전 모던 브라우저
(Safari 제약 있음)
낮음, 단 구현 부담
E. html2canvas 브라우저 렌더러를 JS로 재구현해 DOM을 다시 그림 낮음 — 미지원 CSS 목록이 길다 전 브라우저 높음 (느림)
F. CSS3DRenderer (three.js) 실제 DOM 요소를 matrix3d로 3D 배치 완전 불가 100% (진짜 DOM) 전 브라우저 낮음
G. backdrop-filter + SVG feDisplacementMap 배경 레이어를 변위 맵으로 굴절 (위 콘텐츠 그대로) (CSS 합성) (고정 필터 셋) Chromium만. Safari·Firefox는 backdrop-filter: url() 미지원 → 블러로 degrade 낮음~중 (GPU)
H. View Transitions API 전/후 스냅샷 2장을 CSS로 크로스페이드/클립 전환 중 전환 중 100% Chrome/Edge/Safari 18+ (Firefox 뒤처짐) 낮음
I. 배경 캔버스 오버레이 HTML 뒤/앞에 별도 <canvas>를 깔고 이펙트만 그림 HTML을 못 읽음 전 브라우저 낮음
J. Satori / 서버 렌더 서버에서 HTML/CSS → SVG/PNG 중 (지원 CSS 부분집합) 무관 서버 비용

2. 각 방식의 실무 판단

A. 네이티브 (기준선)

당연히 최선이지만 오늘 프로덕션 불가. 나머지 전부는 "A의 어떤 성질을 포기할 것인가"의 문제다.

B. three-html-render 폴리필 — 가장 현실적인 "같은 코드, 전 브라우저" 해법

https://github.com/repalash/three-html-render (MIT). three.js 공식 예제 webgl_materials_texture_html.html이 실제로 이걸 쓴다.

if (!('requestPaint' in HTMLCanvasElement.prototype)) {
  const { installHtmlInCanvasPolyfill } = await import('three-html-render/polyfill');
  installHtmlInCanvasPolyfill();
}
  • API 표면을 그대로 제공: requestPaint(), captureElementImage(), drawElementImage(), texElementImage2D(), copyElementImageToTexture().
  • 내부 동작: 캔버스 자식을 오프스크린 host div로 옮기고 → SVG foreignObject로 변환 → <img>로 2D 캔버스에 렌더 → 텍스처 업로드. DOM 오버레이를 CSS matrix3d로 3D 지오메트리에 정렬해 포인터 이벤트는 진짜 DOM이 받는다.
  • Chrome Canary에서는 네이티브 texElementImage2D fast path를 자동으로 탄다.
  • three.js 0.150.0+ 및 standalone WebGL/WebGPU 지원.

알려진 한계 (README 명시):

  • textarea 내부 스크롤이 텍스처에 반영되지 않음
  • contenteditable의 캐럿/선택 영역이 렌더되지 않음
  • 동적 스타일시트는 수동 무효화 필요
  • :visited는 폴리필 불가 (브라우저 보안)
  • 일부 CSS가 foreignObject 컨텍스트에서 다르게 렌더됨

판정: three.js 기반 3D UI라면 이걸 쓰고, 네이티브가 켜지면 자동으로 빨라지는 구조가 정답.

C. SnapDOM — 정지 스냅샷이 필요할 때 최선

https://snapdom.dev — DOM을 인라인 SVG foreignObject로 직렬화한 뒤 브라우저 네이티브 파이프라인으로 래스터화.

  • "브라우저가 화면에 그릴 수 있는 것이면 대체로 캡처된다" — 그라디언트, 필터, 블렌드 모드, transform, 최신 CSS 포함.
  • html2canvas 대비 2~16배 빠름 (복잡한 요소 기준, 단순 요소는 1ms 미만).
  • 한계: 정지 이미지다. 매 프레임 재캡처하면 GC와 이미지 디코딩 비용이 프레임을 잡아먹는다.

판정: "클릭 순간에 카드가 산산조각 나며 사라진다" 같은 일회성 트랜지션에는 충분하다. 스냅샷을 한 번 뜬 뒤 원본 DOM을 숨기고, 스냅샷 텍스처만 셰이더로 애니메이션하면 A와 시각적으로 구분이 잘 안 된다. 지속적 실시간 왜곡에는 부적합.

D. foreignObject 직접 구현

SnapDOM/html-to-image가 하는 일을 손으로 하는 것. 직접 만들 이유는 거의 없다. 다만 왜 라이브러리들이 폰트·이미지를 base64로 인라인하는지는 알아두는 게 좋다 — SVG <img>는 외부 리소스를 가져오지 못하고, 외부 리소스가 있으면 canvas가 taint되어 getImageData/WebGL 업로드가 막힌다.

주요 함정:

  • 외부 이미지 → CORS 필요, 아니면 캔버스 taint
  • 웹폰트 → @font-facesrc를 data URI로 인라인해야 함
  • <canvas> 자식이 taint 상태면 전체 실패
  • Safari는 foreignObject 안의 일부 CSS 처리가 Chromium과 다르다

E. html2canvas — 더 이상 기본 선택지가 아니다

브라우저 렌더링 엔진을 JS로 재구현하는 방식이라, 모든 CSS 속성·레이아웃 예외·텍스트 렌더링 엣지케이스를 손으로 재현해야 한다. 그래서 "지원하지 않는 CSS 속성" 목록이 길다. 외부 도메인 이미지는 CORS 헤더 없이는 못 읽는다. 느리다. 판정: 레거시 유지보수가 아니면 SnapDOM 또는 html-to-image로 갈아타라.

F. CSS3DRenderer"3D 배치"만 필요하면 이게 정답

three.js 애드온. 실제 DOM 요소에 matrix3d를 걸어 3D 공간에 배치한다.

  • 장점: 100% 진짜 DOM. 상호작용·접근성·폰트 렌더링 완벽. 전 브라우저. 가볍다.
  • 결정적 한계: 요소는 DOM 합성 레이어로 남으므로 WebGL 씬과 진짜로 섞이지 않는다. 깊이 테스트, 오클루전, 셰이더 왜곡, 조명, 그림자, 후처리 어느 것도 적용할 수 없다. 항상 WebGL 캔버스 위/아래 별도 레이어다.
  • 평면을 3D로 눕히는 것까지는 되지만, 천이 구겨지거나 유리에 굴절되게는 못 한다.

판정: "카드를 3D로 기울인다" → CSS3DRenderer. "카드를 물결처럼 왜곡한다" → 불가.

G. backdrop-filter + SVG feDisplacementMapLiquid Glass 폴백의 정석

filter 프로퍼티로 SVG 필터를 참조해 배경 레이어를 굴절시킨다. feDisplacementMap이 두 번째 이미지의 R/G 채널로 첫 이미지를 공간 변위시키므로, 높이장/법선/스넬 법칙 기반 변위 맵을 만들면 진짜 굴절처럼 보인다.

브라우저 현실 (2026 기준):

  • Chromium(Chrome/Edge/Brave/Arc): backdrop-filter: url(#filter) 동작 → 진짜 굴절
  • Safari / Firefox: backdrop-filter에 SVG 필터 URL을 지원하지 않는다. GPU 가속 안정성 때문에 내장 CSS 필터 함수로 제한. → 자동으로 블러 글래스모피즘으로 degrade (별도 코드 불필요)
  • 상호운용 표준화 논의 진행 중: w3c/svgwg#1142 "define interoperable backdrop displacement/refraction for 'liquid glass' UI"

판정: 영상의 "Apple Liquid Glass" 인상만 필요하다면 HTML-in-Canvas 없이 CSS+SVG로 상당 부분 재현 가능하고, 브라우저 지원 범위도 오히려 넓다. 다만 "유리 아래 콘텐츠가 마우스를 따라 실시간으로 출렁이며 색수차까지" 수준은 셰이더가 필요하다.

H. View Transitions API — "상태 A → 상태 B" 전환 한정

  • document.startViewTransition() 이 전/후 스냅샷을 만들고 ::view-transition-* 의사 요소를 CSS로 애니메이션한다.
  • Matt Rothenberg의 정확한 대비: "View Transitions는 두 개의 스냅샷을 clip-path와 opacity로 애니메이션한다. (HTML-in-Canvas는) 두 개의 라이브 렌더와 셰이더를 준다."
  • 즉 불타는 다크모드 전환의 타이밍과 구조는 View Transitions로 잡되, 불꽃의 픽셀 단위 시뮬레이션은 포기하고 CSS mask-image(노이즈 PNG/SVG) 애니메이션으로 근사하는 게 현실적인 폴백이다. mask-image + mask-position 애니메이션으로 "타들어가는 마스크"는 꽤 그럴듯하게 나온다.

I. 배경 캔버스 오버레이 — 가장 흔한 오해

"어차피 HTML 뒤에 canvas 깔면 되는 거 아냐?"에 대한 Matt Rothenberg의 답:

"배경 캔버스는 그릴 수는 있어도 읽을 수는 없다(A background canvas can draw. It can't read.)"

이 방식으로 불가능한 것 3가지:

  1. 픽셀 단위 왜곡 — CSS transform은 요소 박스 전체에 걸린다. div를 회전/스케일/스큐할 수는 있어도, 그 안에 렌더된 텍스트를 배럴 왜곡하거나 input의 아래쪽 절반만 압축할 수는 없다.
  2. 두 HTML 상태의 커스텀 블렌딩 — 라이트/다크 테마를 동시에 텍스처로 올려 노이즈·불·스캔라인으로 픽셀 단위 합성.
  3. 렌더된 콘텐츠에 반응 — 셰이더가 픽셀 휘도를 읽어 어두운 픽셀과 밝은 픽셀을 다르게 처리하거나, 렌더된 HTML의 엣지를 검출해 바운딩 박스가 아니라 콘텐츠의 실제 모양을 따라가는 효과.

판정: 글로우, 파티클, 커서 트레일, 배경 그라디언트 같은 "HTML을 읽을 필요 없는" 효과라면 이 방식으로 충분하고 훨씬 싸다. 실제로 많은 "화려한" 사이트가 이 정도로 만족한다.

J. Satori 등 서버 렌더

OG 이미지 생성처럼 결과가 이미지여도 되는 경우. 지원 CSS 부분집합이 제한적이지만 서버에서 안정적으로 돈다. 브라우저 인터랙션 효과의 폴백으로는 부적합.


3. 목표별 권장 조합

만들고 싶은 것 네이티브 있을 때 폴백
3D 씬 안의 상호작용 UI (영상의 천 위 포트폴리오, 3D 책) THREE.HTMLTexture + InteractionManager three-html-render 폴리필 (동일 코드, 자동 전환). 3D 배치만 필요하면 CSS3DRenderer
Liquid Glass / 굴절 오버레이 WebGL 굴절 셰이더 + drawElementImage backdrop-filter + SVG feDisplacementMap (Chromium 굴절, Safari/FF 블러 degrade)
다크모드 불타는 전환 두 텍스처 + FBM 셰이더 View Transitions + CSS mask-image 노이즈 애니메이션
폼 포커스 글로우 / 배경 반응 셰이더 한 패스에서 글로우+콘텐츠 합성 배경 캔버스 오버레이 (I). 글로우가 폼 , 도트 에 오는 레이어링만 포기
버튼 리플 / 클릭 시 산산조각 drawElementImage + 픽셀 조작 SnapDOM으로 1회 스냅샷 → 요소 숨김 → 파티클 애니메이션
HTML → 이미지/영상 내보내기 drawElementImage + canvas.captureStream() SnapDOM (클라이언트) 또는 Satori/Puppeteer (서버)
캔버스 앱의 리치 텍스트 UI (Figma/Docs류) drawElementImage 현행 유지 (DOM 오버레이 또는 자체 텍스트 레이아웃)

4. 실전 폴백 패턴 — CanvasUI 모델

canvasui.dev(David Haz)가 채택한 원칙이 가장 건전하다:

"컴포넌트가 런타임에 지원 여부를 감지하고 우아하게 degrade한다. API가 없으면 콘텐츠는 그냥 일반 HTML로 렌더되고, 그래도 돌 수 있는 이펙트 부분은 계속 돈다."

구현 형태:

const HAS_HIC = 'requestPaint' in HTMLCanvasElement.prototype;

if (HAS_HIC) {
  canvas.setAttribute('layoutsubtree', '');
  mountShaderEffect(canvas);          // 셰이더 왜곡 전체 경로
} else {
  canvas.replaceWith(...canvas.childNodes);   // HTML 을 문서로 승격
  mountCssOnlyEffect(container);      // backdrop-filter / transition / mask 로 근사
}

핵심 설계 규칙 3가지:

  1. HTML을 먼저 쓰고 캔버스를 나중에 씌운다. 캔버스가 없어도 페이지가 완성되어 있어야 한다.
  2. 캔버스는 장식이지 구조가 아니다. 레이아웃·포커스 순서·읽기 순서는 전부 HTML이 결정한다.
  3. 효과의 "의미"와 "구현"을 분리한다. "제출 시 폼이 사라진다"는 의미는 셰이더 왜곡으로도, CSS scale+opacity로도 표현된다. 폴백은 열화판이지 부재가 아니어야 한다.

5. 비용·성능 메모

  • A(네이티브): 스냅샷은 브라우저 내부 디스플레이 리스트에서 나오므로 직렬화·디코딩이 없다. 다만 explainer가 경고하듯 캔버스 안 콘텐츠는 컴포지터 스레드 스크롤/애니메이션 혜택을 잃고 JS에 묶인다. 캔버스 안에 스크롤 영역을 넣기보다 캔버스 전체를 스크롤시켜라.
  • B/C/D(foreignObject): 매 캡처마다 DOM 직렬화 → SVG 파싱 → 이미지 디코드. 60fps 지속 갱신에는 부적합. 무효화 시점에만 재캡처하도록 설계할 것.
  • E(html2canvas): 가장 느리다. 신규 채택 근거 없음.
  • F(CSS3DRenderer): DOM 합성 레이어라 저렴하지만, 요소가 많으면 레이어 폭발.
  • G(backdrop-filter): GPU 가속이지만 큰 영역에 걸면 비싸다. Safari/FF가 SVG 필터를 backdrop에 안 붙이는 이유가 정확히 이것(GPU 사용량·불안정성).
  • I(배경 캔버스): 가장 싸다. HTML을 읽을 필요가 없다면 이걸 먼저 검토하라.