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)
This commit is contained in:
Yun Chan 2026-08-20 10:48:00 +09:00
commit 8808c672dc
135 changed files with 38838 additions and 0 deletions

View file

@ -0,0 +1,700 @@
# 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` 등으로 처리 |
| ☐ 스크롤 콘텐츠는 신중히 | 캔버스 안에서는 컴포지터 스레드 스크롤이 불가. 캔버스 전체를 스크롤시키는 편이 낫다 |