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

28 KiB
Raw Permalink Blame History

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. 반환된 DOMMatrixelement.style.transform에 반영. 안 하면 클릭이 엉뚱한 데 떨어진다.

1. 최소 예제 (WICG explainer 원문)

<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 블로그 버전:

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 버튼이고, 아래쪽 뒤집힌 반사는 캔버스가 그린 픽셀이다. 완성 단일 파일.

<!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. 요소 상단 yy+2h, 하단 y+hy+h. 즉 본체 바로 아래에 위아래 뒤집혀 붙는다.

클릭 리플 추가 (영상의 확장분)

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 이펙트에 이게 맞다.

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.changedElementsFrozenArray<Element>다. 캔버스에 자식이 여럿일 때 부분 갱신에 쓴다.

패턴 B: 매 프레임 루프 (애니메이션/셰이더)

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을 텍스처로 올려 프래그먼트 셰이더로 왜곡한다. 완성 단일 파일.

<!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"(영상의 다크모드 불타는 전환)이 쓰는 구조:

<canvas layoutsubtree>
  <div id="lightPage">...</div>
  <div id="darkPage">...</div>
</canvas>
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 계산, 히트테스트 위임까지 다 해준다. 레이캐스팅이 필요 없다.

<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 소스 원문 요지)

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 패턴)

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 젤리 슬라이더 데모의 실제 구조.

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();

소스 사각형 크롭이 필요하면 sourcesx / sy / swidth / sheight를 추가한다.

⚠️ WICG 젤리 슬라이더 소스에는 // TODO(pdr): Calculate this correctly using getElementTransform. For now, the transform is just hard-coded. 라는 주석이 남아 있다. 공식 WebGPU 데모조차 transform 동기화를 하드코딩 중이라는 뜻으로, 이 경로가 아직 가장 덜 다듬어진 부분이다.


7. 기능 감지 + 어댑터 레이어 (권장 래퍼)

시그니처가 계속 바뀌므로 호출부를 한 곳에 격리한다.

// 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을 그대로 노출하는 것이 폴백의 기본형이다:

if (HIC.supported2D) {
  canvas.setAttribute('layoutsubtree', '');
  mountEffect(canvas);
} else {
  canvas.replaceWith(...canvas.childNodes);   // 자식 HTML 을 그대로 문서에 승격
}

8. OffscreenCanvas + Worker (explainer 원문)

무거운 2D 합성을 워커로 넘기는 경로.

<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가 권한다.
  • 다 쓴 ElementImageclose()로 해제.

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