# experimental-canvas — HTML-in-Canvas 실전 지침

Chrome의 HTML-in-Canvas API로 **실제 HTML 요소를 캔버스 픽셀/GPU 텍스처로 그린다.** 요소는 DOM에 남아 클릭·포커스·접근성이 유지된다. 구현 직전에 읽는 문서다.

## 1. 발동 조건 게이트

**기본값은 "쓰지 않는다".** 세 관문을 전부 통과할 때만 진행한다.

### 관문 1 — 셰이더가 HTML의 렌더된 픽셀을 읽어야 하는가

| 하려는 것 | 판정 |
|---|---|
| 글로우, 파티클, 커서 트레일, 배경 그라디언트 | **배경 캔버스 오버레이로 충분.** 쓰지 마라 |
| 유리 굴절, 프로스티드 패널 | **SVG `feDisplacementMap` + `backdrop-filter`** |
| 카드/패널을 3D로 기울이기 | **`CSS3DRenderer`.** DOM 그대로라 완벽하다 |
| 상태 A → B 페이지 전환 | **View Transitions + `mask-image`** |
| 텍스트를 픽셀 단위로 왜곡, 요소의 일부만 압축 | 후보 |
| 라이트/다크 두 렌더를 노이즈로 픽셀 합성 | 후보 |
| 렌더된 픽셀의 휘도·엣지에 반응하는 효과 | 후보 |
| 3D 메시 위의 상호작용 UI (천, 책, 화면) | 후보 |

배경 캔버스는 **그릴 수는 있어도 읽을 수는 없다.** 읽어야만 하는 경우가 아니면 탈락이다.

### 관문 2 — 폴백이 이미 완성되어 있는가

**폴백 없는 구현은 금지다.** 순서를 뒤집지 마라.

1. HTML/CSS만으로 페이지를 **완성**한다. 레이아웃·포커스 순서·읽기 순서는 여기서 끝난다.
2. 폴백 이펙트(SVG 필터 / CSS 트랜지션 / 배경 캔버스)를 붙여 **그 상태로 출시 가능하게** 만든다.
3. 그 위에 HTML-in-Canvas를 **얹는다**.

캔버스는 장식이다. 구조가 아니다.

### 관문 3 — 맥락이 허용하는가

사용자가 **명시적으로 실험을 원했거나**, **데모·포트폴리오·사내 도구**처럼 브라우저를 통제할 수 있을 때만. 일반 사용자 대상 프로덕션이면 여기서 멈춘다.

## 2. 가용성 — 읽는 시점에 반드시 확인

| 항목 | 값 |
|---|---|
| 플래그 | `chrome://flags/#canvas-draw-element` → Enabled |
| 권장 브라우저 | Chrome Canary 149+ |
| Origin Trial | Chrome 148 ~ **154**. **2026년 10월 초 만료 예정** |
| Stable 기본 활성화 | **없음.** chromestatus 상태는 `In development` |
| Firefox / Safari | 구현 없음, 입장 미표명 |

**OT 만료 후에는 플래그 전용으로 되돌아간다.** 일반 사용자에게는 아무것도 보이지 않고 **폴백이 곧 실제 결과물이 된다.** 2026-10 이후에 읽고 있다면 `chromestatus.com/feature/5172548013916160`에서 연장/출시 여부를 먼저 확인하라.

## 3. 권장 경로 — 폴리필 우선

3D를 쓴다면 **`three-html-render` 폴리필로 시작한다.** three.js 공식 예제가 쓰는 방식이다. 네이티브가 있으면 `texElementImage2D` fast path, 없으면 `foreignObject` 래스터화 + `matrix3d` DOM 오버레이로 **자동 전환**된다. 같은 코드가 전 브라우저에서 돌고 상호작용도 유지된다.

폴리필 한계: `textarea` 내부 스크롤 미반영, `contenteditable` 캐럿/선택 미렌더, 동적 스타일시트 수동 무효화 필요, `:visited` 불가. **매 프레임 재캡처는 비싸다 — 무효화 시점에만 갱신하도록 짜라.** 2D 전용 이펙트라면 폴리필 없이 능력 감지 분기(§7c)로 간다.

## 4. 현재 API 표면

`<canvas layoutsubtree>` 를 선언하고, 그릴 요소를 **직계 자식**으로 둔다. 손자는 그릴 수 없다.

| 용도 | 호출 |
|---|---|
| 2D 그리기 | `ctx.drawElementImage(el, dx, dy[, dw, dh])` → `DOMMatrix` |
| 2D 크롭 | `ctx.drawElementImage(el, sx, sy, sw, sh, dx, dy[, dw, dh])` |
| WebGL 업로드 | `gl.texElementImage2D(gl.TEXTURE_2D, gl.RGBA8, el)` |
| WebGPU 업로드 | `device.queue.copyElementImageToTexture({source: el}, {destination: {texture}, width, height})` |
| 갱신 훅 | `canvas.onpaint = (e) => {}` — `e.changedElements`로 바뀐 요소만 온다 |
| 킥스타트 / 프레임 루프 | `canvas.requestPaint()` |
| 3D 위치 동기화 | `canvas.getElementTransform(el, screenSpaceMatrix)` → `DOMMatrix` |
| 워커 전송 | `canvas.captureElementImage(el)` → `ElementImage` (Transferable) |
| three.js | `material.map = new THREE.HTMLTexture(element)` (r184+) |

### 이 이름을 본다면 낡은 자료다 — 따라 쓰지 마라

| 낡은 이름 | 현재 |
|---|---|
| `canvas place element`, `placeElement()` | 제안명 `html-in-canvas` |
| `drawElement()`, `drawHTMLElement()`, `drawHTML()` | `drawElementImage()` |
| `texElement2D()` | `texElementImage2D()` |
| `copyElementImage()` | `copyElementImageToTexture()` |
| `setHitTestRegions()` | **폐기.** 반환 `DOMMatrix`를 `style.transform`에 반영 |

## 5. 필수 계약 3가지

### ① 반환 `DOMMatrix`를 매 프레임 `style.transform`에 반영한다

```js
const t = ctx.drawElementImage(el, x, y);
el.style.transform = t.toString();   // 이 줄이 없으면 클릭이 전부 어긋난다
```

히트테스트·포커스·탭 이동·find-in-page는 전부 **DOM 위치**를 본다. 그린 위치와 DOM 위치를 맞추는 건 개발자 책임이다. 소스 요소의 CSS transform은 **그리기에서 무시**되므로 이 대입이 캔버스 그림을 바꾸지 않는다 — 무한 루프는 생기지 않는다. 3D에서는 `canvas.getElementTransform(el, screenSpaceMatrix)`가 같은 역할을 하고, three.js는 `InteractionManager.update()`가 대신 해준다.

### ② WebGL/WebGPU는 try/catch로 신·구 시그니처를 모두 지원한다

2026년 상반기에 두 시그니처가 모두 바뀌었다. **Chrome 공식 블로그 예제가 이미 구버전이다.** WICG 공식 데모조차 양쪽을 지원한다.

```js
try { gl.texElementImage2D(gl.TEXTURE_2D, gl.RGBA8, el); }                         // 현재
catch (e) { gl.texElementImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA,               // 구버전
                                 gl.UNSIGNED_BYTE, el); }

try { device.queue.copyElementImageToTexture(                                      // 현재
        { source: el }, { destination: { texture }, width, height }); }
catch (e) { device.queue.copyElementImageToTexture(el, width, height, { texture }); } // 구버전
```

### ③ 캔버스 안에 스크롤 영역을 넣지 마라

캔버스 안 콘텐츠는 JS로 그려진다. **컴포지터 스레드 스크롤·애니메이션을 잃고** 스크롤이 메인 스레드에 묶여 끊긴다. 캔버스 안을 스크롤시키지 말고 **캔버스 전체를 페이지와 함께 스크롤**시켜라.

## 6. 그리지 않는 것

픽셀을 읽을 수 있으므로, 저자가 원래 못 보던 정보는 **아예 안 그려진다.** 검게 보인다고 버그가 아니다.

| 안 그려짐 | 비고 |
|---|---|
| cross-origin `<iframe>`·`<img>`·`url()` 참조·SVG `<use>` | **same-origin iframe은 그려진다.** 그 안의 교차 출처만 빠진다 |
| `:visited` 링크 스타일 | 히스토리 스니핑 차단 |
| 시스템 색상 · OS 테마 · 사용자 환경설정 | — |
| 맞춤법/문법 밑줄, 폼 자동완성 미리보기 | — |
| 서브픽셀 텍스트 안티에일리어싱 | 텍스트가 미묘하게 다르게 보인다 |
| **IME 팝업 · IME 고유 서식** | **한글 조합 중 상태가 캔버스에 안 나온다** |
| 캡션/자막 사용자 설정 | — |
| (그려짐) find-in-page 하이라이트, 스크롤바·폼 컨트롤 외형, 캐럿 | — |

한국어 사이트에서는 IME 항목이 치명적이다. **캔버스 안 텍스트 입력을 주요 UX로 쓰지 마라.**

## 7. 완성 코드

### (a) 2D 반사 버튼 — 2D 이펙트의 기본형

`save/restore`로 CTM을 바꿔 같은 요소를 두 번 그리는 패턴.

```html
<style>
  canvas { width: 480px; height: 300px; }
  /* background 는 불투명 색으로. 반투명이면 효과가 비쳐 나온다 */
  #btn { font: 600 20px/1 system-ui, sans-serif; padding: 16px 32px; border: 0;
         border-radius: 999px; color: #fff; cursor: pointer; background: #ff5fa2;
         transition: background .2s, scale .12s; }
  #btn:hover { background: #a05cff; scale: 1.05; }
</style>

<canvas id="canvas" layoutsubtree>
  <button id="btn">Press me</button>
</canvas>

<script>
  const canvas = document.getElementById('canvas');
  const ctx    = canvas.getContext('2d');
  const btn    = document.getElementById('btn');
  const X_CSS = 100, Y_CSS = 90;

  canvas.onpaint = () => {
    const rect = canvas.getBoundingClientRect();
    const s = canvas.width / rect.width;        // 실효 dpr. 좌표는 device pixel 단위다
    const x = X_CSS * s, y = Y_CSS * s, h = btn.offsetHeight * s;

    ctx.reset();
    ctx.save();                                 // 반사본
    ctx.translate(0, 2 * (y + h));
    ctx.scale(1, -1);
    ctx.globalAlpha = 0.3;
    ctx.drawElementImage(btn, x, y);
    ctx.restore();

    const t = ctx.drawElementImage(btn, x, y);  // 본체 — 이 반환값만 동기화한다
    btn.style.transform = t.toString();
  };
  canvas.requestPaint();   // 최초 스냅샷 확보. 없으면 아무것도 안 그려진다

  new ResizeObserver(([e]) => {
    const dpc = e.devicePixelContentBoxSize;
    canvas.width  = dpc ? dpc[0].inlineSize : Math.round(e.contentRect.width  * devicePixelRatio);
    canvas.height = dpc ? dpc[0].blockSize  : Math.round(e.contentRect.height * devicePixelRatio);
    canvas.requestPaint();
  }).observe(canvas, { box: 'device-pixel-content-box' });
</script>
```

애니메이션이 필요하면 `onpaint` 끝에 `canvas.requestPaint()`를 한 줄 더 넣어 프레임 루프를 만든다. `requestAnimationFrame` 안에서 그리면 직전 프레임 스냅샷이 쓰여 1프레임 밀린다.

### (b) three.js HTMLTexture + 폴리필 폴백 — 권장 기본 경로

3D 씬 안에 상호작용 UI를 넣는 경우.

```html
<script type="importmap">
{ "imports": {
    "three": "https://unpkg.com/three@0.184.0/build/three.module.js",
    "three/addons/": "https://unpkg.com/three@0.184.0/examples/jsm/",
    "three-html-render/polyfill": "https://cdn.jsdelivr.net/npm/three-html-render/dist/polyfill.mjs"
}}
</script>

<script type="module">
import * as THREE from 'three';
import { InteractionManager } from 'three/addons/interaction/InteractionManager.js';

// 네이티브 없으면 foreignObject 폴리필로 자동 대체
if (!('requestPaint' in HTMLCanvasElement.prototype)) {
  const { installHtmlInCanvasPolyfill } = await import('three-html-render/polyfill');
  installHtmlInCanvasPolyfill();
}

const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(devicePixelRatio);
renderer.setSize(innerWidth, innerHeight);
document.body.appendChild(renderer.domElement);

const camera = new THREE.PerspectiveCamera(50, innerWidth / innerHeight, 1, 2000);
camera.position.z = 500;
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x0b0b10);

// 텍스처가 될 HTML. document 에 직접 붙이지 않는다 — HTMLTexture 가 캔버스 자식으로 넣는다
const element = document.createElement('div');
element.style.cssText = 'width:600px;padding:30px;background:#12121b;color:#eaeaf2;'
                      + 'font:28px/1.5 system-ui,sans-serif;text-align:center';
element.innerHTML = '진짜 HTML 입니다. 선택·복사·검색이 됩니다.'
                  + '<br><input type="text" placeholder="입력해 보세요"> <button>보내기</button>';

const material = new THREE.MeshBasicMaterial();   // 조명 없이 텍스트 가독성 유지
material.map = new THREE.HTMLTexture(element);    // ← 핵심 한 줄
const mesh = new THREE.Mesh(new THREE.BoxGeometry(200, 200, 200), material);
scene.add(mesh);

const interactions = new InteractionManager();   // 브라우저 히트테스트에 위임, raycast 불필요
interactions.connect(renderer, camera);
interactions.add(mesh);
element.querySelector('button').onclick = (e) => { e.target.textContent = '보냈습니다'; };

const spin = matchMedia('(prefers-reduced-motion: reduce)').matches ? 0 : 1;
renderer.setAnimationLoop((t) => {
  mesh.rotation.x = Math.sin(t * 0.0005) * 0.5 * spin;
  mesh.rotation.y = Math.cos(t * 0.0008) * 0.5 * spin;
  interactions.update();          // 매 프레임 transform 동기화. 빼면 클릭이 어긋난다
  renderer.render(scene, camera);
});

addEventListener('resize', () => {
  camera.aspect = innerWidth / innerHeight;
  camera.updateProjectionMatrix();
  renderer.setSize(innerWidth, innerHeight);
});
</script>
```

`HTMLTexture`는 내부에서 `parent.onpaint`를 구독해 `needsUpdate`를 세우고 `parent.requestPaint()`로 킥스타트한다. `layoutsubtree` 설정과 요소 부모 관리도 대신 해준다.

### (c) 능력 감지 + 폴백 분기 유틸

2D 경로에서 쓴다. 네이티브가 없으면 **캔버스를 걷어내고 자식 HTML을 문서로 승격**시킨다.

```js
// hic.js
export const HIC = {
  get native() {
    return typeof HTMLCanvasElement !== 'undefined'
        && 'requestPaint' in HTMLCanvasElement.prototype;
  },
  get native2D() {
    return this.native && 'drawElementImage' in CanvasRenderingContext2D.prototype;
  },
  get nativeGL() {
    return this.native && typeof WebGL2RenderingContext !== 'undefined'
        && 'texElementImage2D' in WebGL2RenderingContext.prototype;
  },
  get nativeGPU() {
    return typeof GPUQueue !== 'undefined' && 'copyElementImageToTexture' in GPUQueue.prototype;
  },
  /** 2D: 그리고 transform 을 동기화한다. */
  draw(ctx, el, x, y, w, h) {
    const t = (w === undefined) ? ctx.drawElementImage(el, x, y)
                                : ctx.drawElementImage(el, x, y, w, h);
    el.style.transform = t.toString();
    return t;
  },
  // WebGL/WebGPU 업로드는 §5② 의 try/catch 스니펫을 그대로 쓴다.
  /** 캔버스 그리드를 device pixel 에 맞춘다. 안 하면 텍스트가 흐리다. */
  observeSize(canvas) {
    const ro = new ResizeObserver(([e]) => {
      const dpc = e.devicePixelContentBoxSize;
      canvas.width  = dpc ? dpc[0].inlineSize : Math.round(e.contentRect.width  * devicePixelRatio);
      canvas.height = dpc ? dpc[0].blockSize  : Math.round(e.contentRect.height * devicePixelRatio);
      canvas.requestPaint?.();
    });
    const ok = typeof ResizeObserverEntry !== 'undefined'
            && 'devicePixelContentBoxSize' in ResizeObserverEntry.prototype;
    ro.observe(canvas, ok ? { box: 'device-pixel-content-box' } : {});
    return ro;
  },
  /** 진입점. 어느 쪽으로 가든 HTML 콘텐츠는 화면에 남는다. */
  mount(canvas, { enhance, fallback }) {
    if (this.native2D) {
      canvas.setAttribute('layoutsubtree', '');
      this.observeSize(canvas);
      enhance(canvas);
      canvas.requestPaint();
      return 'native';
    }
    const parent = canvas.parentNode;
    canvas.replaceWith(...canvas.childNodes);   // 자식 HTML 을 문서로 승격
    fallback?.(parent);
    return 'fallback';
  },
};
```

```js
import { HIC } from './hic.js';   // 사용부

HIC.mount(document.getElementById('canvas'), {
  enhance: (canvas) => {
    const ctx = canvas.getContext('2d');
    const ui = document.getElementById('ui');
    canvas.onpaint = () => { ctx.reset(); HIC.draw(ctx, ui, 0, 0); };
  },
  fallback: (root) => root.classList.add('fx-css-only'),   // SVG 필터 / CSS 트랜지션 경로
});
```

## 8. 접근성 체크리스트 — 캔버스를 얹은 뒤 매번 확인한다

- [ ] **캔버스를 지워도 콘텐츠가 보이는가.** DevTools에서 `<canvas>`를 삭제해도 페이지가 읽히고 동작해야 한다.
- [ ] **Tab 순서가 시각 순서와 일치하는가.** 캔버스 자식의 DOM 순서가 곧 탭 순서다.
- [ ] **포커스 링이 보이고 위치가 맞는가.** 셰이더가 얇은 아웃라인을 뭉갠다. `:focus-visible`에 두꺼운 링을 주고, 키보드로 이동하며 링 위치가 그려진 픽셀과 맞는지 눈으로 확인한다.
- [ ] **`prefers-reduced-motion: reduce`에서 모션을 끈다.** 왜곡·회전·진동은 정지 상태로 폴백한다.
- [ ] **왜곡 중에는 클릭이 어긋난다.** 큰 전환 동안 `pointer-events: none`을 걸거나 왜곡량을 작게 유지한다.
- [ ] **한글 입력을 캔버스 안에서 요구하지 않는다.** IME 조합 상태가 그려지지 않는다(§6).
- [ ] **텍스트 대비를 셰이더 적용 후에 측정한다.** 블렌딩·색수차가 대비를 떨어뜨린다.
- [ ] **히트테스트가 필요 없는 장식 요소는 `inert`.** WICG 공식 예제도 그렇게 한다.
- [ ] **스크린리더로 한 번 통과시킨다.** 자식은 접근성 트리에 그대로 노출되므로, 이상하면 HTML 구조가 이상한 것이다.

## 9. 흔한 실패와 원인

| 증상 | 원인 |
|---|---|
| 아무것도 안 그려진다 | `canvas.requestPaint()` 최초 호출 누락 |
| `InvalidStateError` | 첫 스냅샷 전에 그렸다. `onpaint` 밖에서 그리고 있다 |
| 텍스트가 흐리다 | 캔버스 그리드를 device pixel로 안 맞췄다 |
| Retina에서만 위치가 어긋난다 | 좌표에 `canvas.width / rect.width`를 안 곱했다 |
| 클릭이 엉뚱한 데 떨어진다 | 반환 `DOMMatrix`를 `style.transform`에 안 넣었다 (§5①) |
| 페이지 아래로 갈수록 오차가 커진다 | 캔버스 자식 크기가 캔버스 CSS 크기와 불일치 |
| 텍스처가 뒤집혀 나온다 | WebGL UV에서 Y를 안 뒤집었다 |
| 효과가 요소 배경을 뚫고 비친다 | 반투명 배경. 불투명 색으로 바꾼다 |
| 3D 텍스트가 뭉개진다 | mipmap 대신 `gl.LINEAR` 필터를 쓴다 |
| iframe/이미지가 검게 나온다, 캔버스가 콘텐츠 높이만큼 안 자란다 | 각각 cross-origin(§6), `<canvas>`는 div가 아니므로 크기 명시 |

> 근거: research/canvas/01-api-spec.md, 02-availability.md (조사일 2026-08-20)
