designpaca/research/canvas/03-code-examples.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

700 lines
28 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 03. 동작하는 코드 예제
> 전제: Chrome Canary 149+ 에서 `chrome://flags/#canvas-draw-element` = **Enabled**, 또는 Origin Trial 토큰 주입.
> 모든 예제는 `01-api-spec.md`의 IDL 기준(2026-08 explainer)이다.
---
## 0. 모든 예제가 지키는 4가지 규칙
1. **`<canvas>``layoutsubtree`**, 그리고 그리려는 요소는 **직계 자식**.
2. **그리기는 `onpaint` 안에서만.** 밖에서 그리면 직전 프레임 스냅샷이 쓰이고, 첫 스냅샷 전이면 `InvalidStateError`.
3. **`canvas.requestPaint()`로 킥스타트.** 매 프레임 루프가 필요하면 `onpaint` 끝에서 다시 호출.
4. **반환된 `DOMMatrix`를 `element.style.transform`에 반영.** 안 하면 클릭이 엉뚱한 데 떨어진다.
---
## 1. 최소 예제 (WICG explainer 원문)
```html
<canvas id="canvas" style="width: 400px; height: 200px;" layoutsubtree>
<form id="form_element">
<label for="name">name:</label>
<input id="name">
</form>
</canvas>
<script>
const ctx = document.getElementById('canvas').getContext('2d');
canvas.onpaint = () => {
ctx.reset();
const transform = ctx.drawElementImage(form_element, 100, 0);
form_element.style.transform = transform.toString();
};
// 흐릿함 방지: 캔버스 그리드를 device scale factor에 맞춘다.
const observer = new ResizeObserver(([entry]) => {
canvas.width = entry.devicePixelContentBoxSize[0].inlineSize;
canvas.height = entry.devicePixelContentBoxSize[0].blockSize;
});
observer.observe(canvas, {box: 'device-pixel-content-box'});
</script>
```
`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
<!doctype html>
<meta charset="utf-8">
<title>HTML-in-Canvas: reflection</title>
<style>
body { margin: 0; background: #0b0b10; display: grid; place-items: center; height: 100vh; }
canvas { width: 480px; height: 300px; }
/* 캔버스 자식은 평범한 HTML/CSS 그대로 */
#btn {
font: 600 20px/1 system-ui, sans-serif;
padding: 16px 32px;
border: 0;
border-radius: 999px;
background: #ff5fa2; /* 반투명 배경은 피할 것 (셰이더/블렌딩이 비쳐 나온다) */
color: #fff;
cursor: pointer;
transition: background .2s, scale .12s;
}
#btn:hover { background: #a05cff; scale: 1.05; }
#btn:active { scale: .95; }
</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');
// 캔버스 좌표(= device pixel) 기준 배치 위치. CSS px 로 정의하고 dpr 로 곱한다.
const X_CSS = 100, Y_CSS = 90;
canvas.onpaint = () => {
// 캔버스 그리드 / CSS 크기 비율 = 실효 dpr
const rect = canvas.getBoundingClientRect();
const s = canvas.width / rect.width;
const x = X_CSS * s;
const y = Y_CSS * s;
const h = btn.offsetHeight * s; // 버튼 높이(캔버스 좌표)
ctx.reset();
// ── 1) 반사본: y = (Y+H) 축을 기준으로 뒤집고 흐리게
ctx.save();
ctx.translate(0, 2 * (y + h));
ctx.scale(1, -1);
ctx.globalAlpha = 0.3;
ctx.drawElementImage(btn, x, y); // 반환값은 버리는 게 맞다 (이건 "복사본")
ctx.restore();
// ── 2) 실제 본체: 이 호출의 반환 transform 만 DOM 에 반영한다
const t = ctx.drawElementImage(btn, x, y);
btn.style.transform = t.toString();
// ── 3) 매 프레임 루프 (rAF 대응). 정적이면 이 줄을 빼라.
canvas.requestPaint();
};
// 최초 스냅샷 확보 + 파이프라인 킥스타트
canvas.requestPaint();
// 캔버스 그리드를 device pixel 에 맞춤 (흐림 방지)
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();
}).observe(canvas, { box: 'device-pixel-content-box' });
</script>
```
### 왜 `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<Element>`다. 캔버스에 자식이 여럿일 때 부분 갱신에 쓴다.
### 패턴 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
<!doctype html>
<meta charset="utf-8">
<title>HTML-in-Canvas: WebGL distortion</title>
<style>
html, body { margin: 0; height: 100%; background: #07070c; }
#gl { display: block; width: 100vw; height: 100vh; }
#ui {
width: 100vw; height: 100vh; box-sizing: border-box;
padding: 12vh 10vw;
font: 16px/1.6 system-ui, sans-serif; color: #eaeaf2;
background: #12121b; /* 불투명하게 */
}
#ui h1 { font-size: 56px; margin: 0 0 24px; letter-spacing: -.03em; }
#ui input, #ui button {
font: inherit; padding: 12px 16px; border-radius: 10px; border: 1px solid #3a3a52;
background: #1c1c29; color: inherit;
}
#ui input:focus { outline: 2px solid #7b6cff; }
</style>
<canvas id="gl" layoutsubtree>
<div id="ui">
<h1>Real HTML, real shader.</h1>
<p>이 텍스트는 선택·복사·find-in-page가 되고, 아래 입력창은 진짜 입력됩니다.</p>
<p><input id="name" placeholder="이름"> <button>보내기</button></p>
</div>
</canvas>
<script type="module">
const canvas = document.getElementById('gl');
const ui = document.getElementById('ui');
const gl = canvas.getContext('webgl2', { antialias: true, premultipliedAlpha: false });
// ── 셰이더 ────────────────────────────────────────────────
const VS = `#version 300 es
in vec2 aPos;
out vec2 vUv;
void main() {
// 텍스처는 top-down, WebGL 클립공간은 bottom-up → Y 뒤집기
vUv = vec2(aPos.x * 0.5 + 0.5, 0.5 - aPos.y * 0.5);
gl_Position = vec4(aPos, 0.0, 1.0);
}`;
const FS = `#version 300 es
precision highp float;
in vec2 vUv;
out vec4 outColor;
uniform sampler2D uTex;
uniform vec2 uMouse; // 0..1
uniform float uTime;
uniform float uAspect;
void main() {
vec2 uv = vUv;
// 커서 주변을 가우시안 감쇠로 끌어당기는 자기장 왜곡
vec2 d = (uv - uMouse) * vec2(uAspect, 1.0);
float dist = length(d);
float pull = exp(-dist * dist * 90.0) * 0.06;
vec2 warped = uv - normalize(d + 1e-6) * pull * vec2(1.0 / uAspect, 1.0);
// 살짝 흐르는 물결
warped.x += sin(uv.y * 30.0 + uTime * 1.6) * 0.0015;
// 경계에서 색수차
float ca = pull * 0.35;
vec4 c;
c.r = texture(uTex, warped + vec2( ca, 0.0)).r;
c.g = texture(uTex, warped).g;
c.b = texture(uTex, warped - vec2( ca, 0.0)).b;
c.a = texture(uTex, warped).a;
outColor = c;
}`;
function compile(type, src) {
const s = gl.createShader(type);
gl.shaderSource(s, src); gl.compileShader(s);
if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(s));
return s;
}
const prog = gl.createProgram();
gl.attachShader(prog, compile(gl.VERTEX_SHADER, VS));
gl.attachShader(prog, compile(gl.FRAGMENT_SHADER, FS));
gl.linkProgram(prog);
if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(prog));
gl.useProgram(prog);
// ── 풀스크린 삼각형 2개 ────────────────────────────────────
const vao = gl.createVertexArray();
gl.bindVertexArray(vao);
const vbo = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, vbo);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1,-1, 3,-1, -1,3]), gl.STATIC_DRAW);
const loc = gl.getAttribLocation(prog, 'aPos');
gl.enableVertexAttribArray(loc);
gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0);
// ── 텍스처 ────────────────────────────────────────────────
const tex = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, tex);
// 텍스트에는 mipmap 보다 LINEAR 가 결과가 낫다 (WICG 예제 주석 그대로)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
/** 신/구 texElementImage2D 시그니처를 모두 지원 (WICG 공식 예제 패턴) */
function uploadElement(el) {
gl.bindTexture(gl.TEXTURE_2D, tex);
try {
gl.texElementImage2D(gl.TEXTURE_2D, gl.RGBA8, el); // 현재 IDL
} catch (e) {
gl.texElementImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, // 구 시그니처
gl.UNSIGNED_BYTE, el);
}
}
const uTex = gl.getUniformLocation(prog, 'uTex');
const uMouse = gl.getUniformLocation(prog, 'uMouse');
const uTime = gl.getUniformLocation(prog, 'uTime');
const uAspect = gl.getUniformLocation(prog, 'uAspect');
let mouse = [0.5, 0.5];
addEventListener('pointermove', (e) => {
mouse = [e.clientX / innerWidth, e.clientY / innerHeight];
});
// ── paint 루프 ────────────────────────────────────────────
canvas.onpaint = () => {
uploadElement(ui); // ← 반드시 paint 안에서
gl.viewport(0, 0, canvas.width, canvas.height);
gl.useProgram(prog);
gl.bindVertexArray(vao);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, tex);
gl.uniform1i(uTex, 0);
gl.uniform2f(uMouse, mouse[0], mouse[1]);
gl.uniform1f(uTime, performance.now() * 0.001);
gl.uniform1f(uAspect, canvas.width / canvas.height);
gl.drawArrays(gl.TRIANGLES, 0, 3);
canvas.requestPaint(); // 매 프레임
};
canvas.requestPaint();
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();
}).observe(canvas, { box: 'device-pixel-content-box' });
</script>
```
### 이 예제의 히트테스트
풀스크린 쿼드가 요소를 1:1로 매핑하므로 **DOM 위치가 그려진 위치와 이미 일치**한다 → `style.transform` 조작이 필요 없다. 다만 셰이더가 픽셀을 왜곡하는 만큼 **클릭 지점과 보이는 지점이 왜곡량만큼 어긋난다.** 왜곡을 작게 유지하거나, 왜곡이 큰 순간(전환 애니메이션)에는 포인터 이벤트를 잠깐 무시하는 식으로 다뤄야 한다. (Codrops가 지적한 트레이드오프)
### 두 텍스처 블렌딩 — 이 API의 진짜 킬러 패턴
Matt Rothenberg의 "Burn Transition"(영상의 다크모드 불타는 전환)이 쓰는 구조:
```html
<canvas layoutsubtree>
<div id="lightPage">...</div>
<div id="darkPage">...</div>
</canvas>
```
```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
<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 { RoundedBoxGeometry } from 'three/addons/geometries/RoundedBoxGeometry.js';
import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js';
import { InteractionManager } from 'three/addons/interaction/InteractionManager.js';
// 네이티브 API 없으면 foreignObject 폴리필로 대체 (three.js 공식 예제와 동일한 판정)
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);
renderer.toneMapping = THREE.NeutralToneMapping;
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(0xaaaaaa);
scene.environment = new THREE.PMREMGenerator(renderer)
.fromScene(new RoomEnvironment(), 0.02).texture;
// ── 텍스처가 될 HTML. document 에 붙일 필요 없다 — HTMLTexture 가 캔버스 자식으로 넣어준다.
const element = document.createElement('div');
element.style.cssText = 'width:600px;padding:30px;background:#aaa;color:#000;'
+ 'font:30px/1.5 sans-serif;text-align:center';
element.innerHTML = `
Hello world! <b>formatted</b> 텍스트, 이모지 😀, RTL <span dir="rtl">من فارسی</span>
<br><input type="text" placeholder="입력해 보세요">
<button>Click me</button>`;
const material = new THREE.MeshStandardMaterial({ roughness: 0, metalness: 0.5 });
material.map = new THREE.HTMLTexture(element); // ← 핵심 한 줄
const mesh = new THREE.Mesh(new RoundedBoxGeometry(200, 200, 200, 10, 10), material);
scene.add(mesh);
// ── 네이티브 포인터 상호작용 (raycast 불필요, 브라우저 히트테스트에 위임)
const interactions = new InteractionManager();
interactions.connect(renderer, camera);
interactions.add(mesh);
element.querySelector('button').addEventListener('click', function () {
this.textContent = 'Clicked!'; // 3D 표면 위에서 그대로 동작
});
renderer.setAnimationLoop((t) => {
mesh.rotation.x = Math.sin(t * 0.0005) * 0.5;
mesh.rotation.y = Math.cos(t * 0.0008) * 0.5;
interactions.update(); // 매 프레임 transform 동기화
renderer.render(scene, camera);
});
addEventListener('resize', () => {
camera.aspect = innerWidth / innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(innerWidth, innerHeight);
});
</script>
```
### `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'); // <canvas layoutsubtree>
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
<canvas id="canvas" style="width: 400px; height: 200px;" layoutsubtree>
<form id="form_element">
<label for="name">name:</label>
<input id="name">
</form>
</canvas>
<script>
const workerCode = `
let ctx;
self.onmessage = (e) => {
if (e.data.canvas) ctx = e.data.canvas.getContext('2d');
if (e.data.width && e.data.height) {
ctx.canvas.width = e.data.width;
ctx.canvas.height = e.data.height;
}
if (e.data.elementImage) {
ctx.reset();
const transform = ctx.drawElementImage(e.data.elementImage, 100, 0);
self.postMessage({transform: transform});
}
};
`;
const worker = new Worker(URL.createObjectURL(new Blob([workerCode])));
const offscreen = canvas.transferControlToOffscreen();
worker.postMessage({ canvas: offscreen }, [offscreen]);
canvas.onpaint = () => {
const elementImage = canvas.captureElementImage(form_element); // Transferable
worker.postMessage({ elementImage }, [elementImage]);
};
worker.onmessage = ({ data }) => {
form_element.style.transform = data.transform.toString(); // 메인에서 동기화
};
new ResizeObserver(([entry]) => {
worker.postMessage({
width: entry.devicePixelContentBoxSize[0].inlineSize,
height: entry.devicePixelContentBoxSize[0].blockSize
});
canvas.requestPaint();
}).observe(canvas, { box: 'device-pixel-content-box' });
</script>
```
- 워커에서도 `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 예제 주석) |
| ☐ `<canvas>`는 div가 아니다 | `width:100%` 기본값도 없고 콘텐츠 높이로 자라지도 않는다. 크기를 명시하라 |
| ☐ 히트테스트가 필요 없으면 `inert` | WICG WebGL 예제가 `<div id="draw_element" inert>`로 히트테스트를 끈다 |
| ☐ 왜곡이 크면 클릭이 어긋남을 인지 | 큰 전환 중에는 `pointer-events: none` 등으로 처리 |
| ☐ 스크롤 콘텐츠는 신중히 | 캔버스 안에서는 컴포지터 스레드 스크롤이 불가. 캔버스 전체를 스크롤시키는 편이 낫다 |