# 03. 동작하는 코드 예제 > 전제: Chrome Canary 149+ 에서 `chrome://flags/#canvas-draw-element` = **Enabled**, 또는 Origin Trial 토큰 주입. > 모든 예제는 `01-api-spec.md`의 IDL 기준(2026-08 explainer)이다. --- ## 0. 모든 예제가 지키는 4가지 규칙 1. **``에 `layoutsubtree`**, 그리고 그리려는 요소는 **직계 자식**. 2. **그리기는 `onpaint` 안에서만.** 밖에서 그리면 직전 프레임 스냅샷이 쓰이고, 첫 스냅샷 전이면 `InvalidStateError`. 3. **`canvas.requestPaint()`로 킥스타트.** 매 프레임 루프가 필요하면 `onpaint` 끝에서 다시 호출. 4. **반환된 `DOMMatrix`를 `element.style.transform`에 반영.** 안 하면 클릭이 엉뚱한 데 떨어진다. --- ## 1. 최소 예제 (WICG explainer 원문) ```html
``` `devicePixelContentBoxSize`를 못 쓰는 환경까지 챙기는 Chrome 블로그 버전: ```js const observer = new ResizeObserver(([entry]) => { const dpc = entry.devicePixelContentBoxSize; canvas.width = dpc ? dpc[0].inlineSize : Math.round(entry.contentRect.width * devicePixelRatio); canvas.height = dpc ? dpc[0].blockSize : Math.round(entry.contentRect.height * devicePixelRatio); }); const supportsDPCB = typeof ResizeObserverEntry !== 'undefined' && 'devicePixelContentBoxSize' in ResizeObserverEntry.prototype; observer.observe(canvas, supportsDPCB ? { box: 'device-pixel-content-box' } : {}); ``` --- ## 2. 2D 반사(reflection) 이펙트 — 영상 데모의 정확한 재구성 버튼은 **진짜 클릭 가능한 HTML 버튼**이고, 아래쪽 뒤집힌 반사는 캔버스가 그린 픽셀이다. 완성 단일 파일. ```html HTML-in-Canvas: reflection ``` ### 왜 `save()/restore()`와 두 번의 `drawElementImage`인가 - `drawElementImage`는 **캔버스의 현재 CTM을 적용**한다. 그래서 flip/alpha를 CTM+`globalAlpha`로 걸고 한 번 그리면 반사본이, 원복 후 한 번 더 그리면 본체가 나온다. - 소스 요소에 걸린 CSS transform은 **그리기에서 무시**되므로, `btn.style.transform`을 매 프레임 덮어써도 캔버스 그림은 영향받지 않는다 → **무한 루프가 생기지 않는다.** (그리고 explainer가 "transform 변경은 `paint`를 발화시키지 않는다"고 명시) - 반사 좌표 검산: 반사 변환은 `y' = 2(y+h) − y`. 요소 상단 `y` → `y+2h`, 하단 `y+h` → `y+h`. 즉 본체 바로 아래에 위아래 뒤집혀 붙는다. ### 클릭 리플 추가 (영상의 확장분) ```js let ripples = []; btn.addEventListener('click', (e) => { const r = btn.getBoundingClientRect(); ripples.push({ x: e.clientX - r.left, y: e.clientY - r.top, t: performance.now() }); }); ``` `onpaint` 안에서 본체를 그린 뒤 `ctx.getImageData()`로 픽셀을 읽어 반경 기반으로 UV를 밀어내면 된다. 이 부분은 이 API와 무관한 **평범한 캔버스 픽셀 수학**이다 (영상의 표현 그대로). 다만 **`getImageData`는 느리므로 실전에서는 §4의 WebGL 경로가 정답**이다. --- ## 3. `paint` 이벤트 루프 — 두 가지 패턴 ### 패턴 A: 이벤트 구동 (기본값, 저비용) 호버·포커스·입력 등 **HTML이 실제로 바뀔 때만** 다시 그린다. 대부분의 UI 이펙트에 이게 맞다. ```js canvas.onpaint = (event) => { ctx.reset(); for (const el of event.changedElements) { // 바뀐 요소만 알려준다 const t = ctx.drawElementImage(el, 0, 0); el.style.transform = t.toString(); } }; canvas.requestPaint(); // 최초 1회 ``` `PaintEvent.changedElements`는 `FrozenArray`다. 캔버스에 자식이 여럿일 때 부분 갱신에 쓴다. ### 패턴 B: 매 프레임 루프 (애니메이션/셰이더) ```js canvas.onpaint = () => { const now = performance.now(); ctx.reset(); drawEverything(now); canvas.requestPaint(); // 다음 프레임 예약 → requestAnimationFrame 과 같은 역할 }; canvas.requestPaint(); ``` > `requestAnimationFrame`으로 루프를 돌리면서 그 안에서 `drawElementImage`를 부르면 **직전 프레임 스냅샷**이 쓰여 1프레임 지연이 생기고, 첫 프레임에 예외가 난다. **루프는 `requestPaint()`로 도는 게 맞다.** ### 중첩 canvas 주의 `paint`는 **역트리 순서**로 발화한다 (자손 → 조상). 조상 캔버스가 자손 캔버스의 결과에 의존하는 합성을 짤 때 이 순서를 전제할 수 있다. 단, **Chromium Canary는 아직 "요소는 가장 가까운 canvas 조상에만 그릴 수 있다"로 제한**하고 있으므로 (스펙과 구현 불일치) 중첩 구조는 신중히. --- ## 4. WebGL 텍스처화 — 전체 화면 셰이더 왜곡 라이브 HTML을 텍스처로 올려 프래그먼트 셰이더로 왜곡한다. 완성 단일 파일. ```html HTML-in-Canvas: WebGL distortion

Real HTML, real shader.

이 텍스트는 선택·복사·find-in-page가 되고, 아래 입력창은 진짜 입력됩니다.

``` ### 이 예제의 히트테스트 풀스크린 쿼드가 요소를 1:1로 매핑하므로 **DOM 위치가 그려진 위치와 이미 일치**한다 → `style.transform` 조작이 필요 없다. 다만 셰이더가 픽셀을 왜곡하는 만큼 **클릭 지점과 보이는 지점이 왜곡량만큼 어긋난다.** 왜곡을 작게 유지하거나, 왜곡이 큰 순간(전환 애니메이션)에는 포인터 이벤트를 잠깐 무시하는 식으로 다뤄야 한다. (Codrops가 지적한 트레이드오프) ### 두 텍스처 블렌딩 — 이 API의 진짜 킬러 패턴 Matt Rothenberg의 "Burn Transition"(영상의 다크모드 불타는 전환)이 쓰는 구조: ```html
...
...
``` ```js canvas.onpaint = () => { gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, texLight); gl.texElementImage2D(gl.TEXTURE_2D, gl.RGBA8, lightPage); gl.activeTexture(gl.TEXTURE1); gl.bindTexture(gl.TEXTURE_2D, texDark); gl.texElementImage2D(gl.TEXTURE_2D, gl.RGBA8, darkPage); // 프래그먼트 셰이더에서 FBM 노이즈로 burn front 를 만들어 두 텍스처를 픽셀 단위 합성 ... }; ``` **두 개의 라이브 렌더를 임의의 GLSL 함수로 합성하는 것** — View Transitions는 두 개의 *스냅샷*을 CSS 애니메이션으로 넘기는 게 전부라 이건 CSS에 등가물이 없다. --- ## 5. three.js 연동 — `THREE.HTMLTexture` (r184+) **가장 실용적인 3D 경로.** three.js가 `layoutsubtree` 설정, 요소 부모 관리, 매 프레임 `matrix3d` 계산, 히트테스트 위임까지 다 해준다. 레이캐스팅이 필요 없다. ```html ``` ### `HTMLTexture`가 내부에서 하는 일 (three.js 소스 원문 요지) ```js class HTMLTexture extends Texture { constructor(element, ...) { super(element, ...); this.isHTMLTexture = true; this.generateMipmaps = false; this.needsUpdate = true; const parent = element ? element.parentNode : null; if (parent !== null && 'requestPaint' in parent) { parent.onpaint = () => { this.needsUpdate = true; }; // paint 마다 갱신 플래그 parent.requestPaint(); // 킥스타트 } } dispose() { /* onpaint 해제 후 super.dispose() */ } } ``` → `Texture`를 거의 그대로 쓰되 **`paint` 이벤트를 구독해 `needsUpdate`를 세우는 것**이 전부다. 업로드 자체는 렌더러가 `texElementImage2D` / `copyElementImageToTexture`로 처리한다. **WebGLRenderer와 WebGPURenderer 양쪽 지원.** ### React Three Fiber 조합 (Codrops 패턴) ```jsx const texture = new HTMLTexture(document.getElementById('computer_screen')); material.uniforms.map.value = texture; material.map = texture; const interactions = new InteractionManager(); interactions.connect(gl, camera); // useThree() 의 gl, camera interactions.add(screenMeshRef.current); useFrame(({ clock }) => { material.uniforms.uTime.value = clock.elapsedTime; interactions.update(); }); ``` --- ## 6. WebGPU — `copyElementImageToTexture()` WICG 젤리 슬라이더 데모의 실제 구조. ```js const canvas = document.getElementById('canvas'); // const element = document.getElementById('value'); // 캔버스 직계 자식 const targetTexture = device.createTexture({ size: [width, height, 1], format: 'rgba8unorm', usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | GPUTextureUsage.RENDER_ATTACHMENT, }); canvas.onpaint = () => { const source = { source: element }; // GPUCopyElementImageSource const dest = { // GPUCopyElementImageDestination destination: { texture: targetTexture }, width, height, }; try { device.queue.copyElementImageToTexture(source, dest); // 현재 IDL } catch (e) { device.queue.copyElementImageToTexture(element, width, height, // 구 시그니처 { texture: targetTexture }); } // 히트테스트 동기화 — 3D 배치면 canvas.getElementTransform() 사용 element.style.transform = `translate(${x}px, ${y}px)`; }; canvas.requestPaint(); ``` **소스 사각형 크롭**이 필요하면 `source`에 `sx / sy / swidth / sheight`를 추가한다. > ⚠️ WICG 젤리 슬라이더 소스에는 `// TODO(pdr): Calculate this correctly using getElementTransform. For now, the transform is just hard-coded.` 라는 주석이 남아 있다. **공식 WebGPU 데모조차 transform 동기화를 하드코딩 중**이라는 뜻으로, 이 경로가 아직 가장 덜 다듬어진 부분이다. --- ## 7. 기능 감지 + 어댑터 레이어 (권장 래퍼) 시그니처가 계속 바뀌므로 호출부를 한 곳에 격리한다. ```js // hic.js export const HIC = { get supported2D() { return 'requestPaint' in HTMLCanvasElement.prototype && 'drawElementImage' in CanvasRenderingContext2D.prototype; }, get supportedGL() { return 'requestPaint' in HTMLCanvasElement.prototype && (('texElementImage2D' in WebGL2RenderingContext.prototype) || ('texElementImage2D' in WebGLRenderingContext.prototype)); }, get supportedGPU() { return typeof GPUQueue !== 'undefined' && 'copyElementImageToTexture' in GPUQueue.prototype; }, /** 2D: 그리고 transform 을 동기화. 반환 DOMMatrix. */ 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: 신/구 시그니처 흡수 */ uploadGL(gl, el, internalformat = gl.RGBA8) { try { gl.texElementImage2D(gl.TEXTURE_2D, internalformat, el); } catch (_) { gl.texElementImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, el); } }, /** WebGPU: 신/구 시그니처 흡수 */ uploadGPU(device, el, texture, width, height) { try { device.queue.copyElementImageToTexture( { source: el }, { destination: { texture }, width, height }); } catch (_) { device.queue.copyElementImageToTexture(el, width, height, { texture }); } }, /** 캔버스 그리드를 device pixel 에 맞추고 리사이즈마다 repaint */ observeSize(canvas) { const ro = new ResizeObserver(([entry]) => { const dpc = entry.devicePixelContentBoxSize; canvas.width = dpc ? dpc[0].inlineSize : Math.round(entry.contentRect.width * devicePixelRatio); canvas.height = dpc ? dpc[0].blockSize : Math.round(entry.contentRect.height * devicePixelRatio); canvas.requestPaint?.(); }); const supportsDPCB = typeof ResizeObserverEntry !== 'undefined' && 'devicePixelContentBoxSize' in ResizeObserverEntry.prototype; ro.observe(canvas, supportsDPCB ? { box: 'device-pixel-content-box' } : {}); return ro; }, }; ``` 사용 시 **미지원이면 캔버스를 아예 만들지 말고 HTML을 그대로 노출**하는 것이 폴백의 기본형이다: ```js if (HIC.supported2D) { canvas.setAttribute('layoutsubtree', ''); mountEffect(canvas); } else { canvas.replaceWith(...canvas.childNodes); // 자식 HTML 을 그대로 문서에 승격 } ``` --- ## 8. OffscreenCanvas + Worker (explainer 원문) 무거운 2D 합성을 워커로 넘기는 경로. ```html
``` - 워커에서도 `drawElementImage(elementImage, ...)`가 `DOMMatrix`를 돌려주고, 그걸 `postMessage`로 메인에 되돌려 적용한다. - 위치가 **동적**이면 왕복 지연 때문에 어긋나므로, **메인 스레드에서 위치를 미리 계산해 `ElementImage` 전송과 동시에 `style.transform`을 적용**하라고 explainer가 권한다. - 다 쓴 `ElementImage`는 `close()`로 해제. --- ## 9. 실전 체크리스트 (커뮤니티가 실제로 데인 것들) | 항목 | 내용 | |---|---| | ☐ `requestPaint()` 최초 1회 | 안 하면 아무것도 안 그려지고, 첫 그리기에서 `InvalidStateError` | | ☐ 그리기는 `onpaint` 안에서 | 밖이면 1프레임 지연 | | ☐ 캔버스 그리드 = device pixel | `ResizeObserver` + `device-pixel-content-box`. 안 하면 텍스트가 흐리다 | | ☐ 좌표는 device pixel 단위 | CSS px 값에 `canvas.width / rect.width`를 곱하라. 안 하면 Retina에서 어긋난다 | | ☐ 캔버스 자식 크기 = 캔버스 CSS 크기 | 불일치하면 텍스처가 늘어나고 좌표 오차가 페이지 아래로 갈수록 누적 | | ☐ WebGL UV Y 뒤집기 | `vUv = vec2(x*0.5+0.5, 0.5 - y*0.5)` | | ☐ 배경은 불투명 색 | 반투명 배경은 셰이더 효과가 비쳐 나온다 | | ☐ `style.transform` 동기화 | 안 하면 클릭·포커스·find-in-page 하이라이트 위치가 전부 어긋난다 | | ☐ 텍스트에는 `LINEAR` 필터 | mipmap보다 결과가 낫다 (WICG 예제 주석) | | ☐ ``는 div가 아니다 | `width:100%` 기본값도 없고 콘텐츠 높이로 자라지도 않는다. 크기를 명시하라 | | ☐ 히트테스트가 필요 없으면 `inert` | WICG WebGL 예제가 `
`로 히트테스트를 끈다 | | ☐ 왜곡이 크면 클릭이 어긋남을 인지 | 큰 전환 중에는 `pointer-events: none` 등으로 처리 | | ☐ 스크롤 콘텐츠는 신중히 | 캔버스 안에서는 컴포지터 스레드 스크롤이 불가. 캔버스 전체를 스크롤시키는 편이 낫다 |