WebGL 셰이더는 강력한 GPU 가속 시각 효과를 제공하여 동적 물결 왜곡, 조명 모델, 색상 그레이딩, 맞춤 꼭짓점 변환과 같은 고급 기능을 지원합니다. HTML-in-Canvas API를 사용하면 개발자가 HTML 콘텐츠에 WebGL 텍스처를 적용할 수 있습니다. 이를 통해 기본 접근성, 텍스트 선택, DOM 이벤트 처리를 유지하면서 버튼, 입력란, 서식 있는 텍스트와 같은 완전한 대화형 UI 구성요소에 고성능 프래그먼트 및 꼭짓점 셰이더를 기본적으로 적용할 수 있습니다.
구현 방법
브라우저에서 HTML-in-Canvas가 지원되는지 확인합니다.
if ('content' in HTMLCanvasElement.prototype) { // Use HTML in Canvas API } else { // Use fallback strategy }<canvas>HTML 요소에content="drawable"속성을 추가합니다.<canvas>요소 내에 HTML 콘텐츠를 배치하고 그리는 모든 요소에drawable속성을 추가합니다.drawable가 있는 요소는 하위 트리(drawable인 중첩된 하위 요소 제외)를 캡처합니다.<canvas id="canvas" content="drawable"> <div id="html-content" drawable></div> </canvas>흐릿함을 방지하려면 기기 배율에 맞게 캔버스 그리드를 조정합니다.
const observer = new ResizeObserver(([entry]) => { const dpc = entry.devicePixelContentBoxSize; canvas.width = dpc ? dpc[0].inlineSize : Math.round(entry.contentRect.width * window.devicePixelRatio); canvas.height = dpc ? dpc[0].blockSize : Math.round(entry.contentRect.height * window.devicePixelRatio); }); const supportsDevicePixelContentBox = typeof ResizeObserverEntry !== "undefined" && "devicePixelContentBoxSize" in ResizeObserverEntry.prototype; const options = supportsDevicePixelContentBox ? { box: "device-pixel-content-box" } : {}; observer.observe(canvas, options);먼저
texImage2D메서드를 사용하여 텍스처 백업을 미리 할당합니다. 이 가이드의 스니펫은 WebGL 2 컨텍스트 (canvas.getContext("webgl2"))를 사용합니다.
픽셀 데이터를 업로드하지 않고 지정된 크기와 형식의 GPU 메모리를 예약하는 null를 사용하여 texImage2D()를 호출하여 텍스처 스토리지를 먼저 할당합니다. 다음과 같은 규칙을 따르세요.
canvas.captureElementImage(element)에서 텍스처 크기를 조정합니다.width및height는 캔버스 그리드 (백업 저장소) 픽셀에 있으므로Math.ceil()로 반올림합니다. 요소에 아직 페인트 레코드가 없으면captureElementImage()가 발생합니다.requestPaint()를 호출하고 다음paint이벤트에서 다시 시도하세요.- 재할당하면 텍스처가 지워지므로 첫 번째 업로드 시 또는 크기가 변경될 때만 수준 0을 할당합니다. 변경 가능한 저장소 사용: 요소의 크기가 변경되면 변경 불가능한
texStorage2D()저장소의 크기를 조절할 수 없습니다.
// Returns the element's size in canvas grid pixels, or null if the element
// has no paint record yet.
function getElementImageSize(canvas, element) {
let elementImage;
try {
elementImage = canvas.captureElementImage(element);
} catch (err) {
canvas.requestPaint(); // No paint record yet: retry on the next paint event.
return null;
}
const width = Math.ceil(elementImage.width);
const height = Math.ceil(elementImage.height);
elementImage.close();
return width > 0 && height > 0 ? { width, height } : null;
}
// Binds the texture, and allocates its storage on the first upload, and when
// the element size changes. `state` stores the allocated size.
function allocateElementTexture(gl, texture, state, width, height) {
gl.bindTexture(gl.TEXTURE_2D, texture);
if (state.width === width && state.height === height) return;
gl.texImage2D(
gl.TEXTURE_2D,
0, // Mipmap level
gl.RGBA8, // Internal format: 8 bits per RGBA channel
width, // Size in canvas grid pixels
height,
0, // Border: must be 0
gl.RGBA, // Format and type that match RGBA8
gl.UNSIGNED_BYTE,
null // null reserves GPU memory without uploading pixels
);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
state.width = width;
state.height = height;
}
const texture = gl.createTexture();
const textureState = { width: 0, height: 0 };
texElementSubImage2D메서드를 사용하여canvas.onpaint이벤트 핸들러 내에서 HTML 콘텐츠를 텍스처로 렌더링합니다. 다음과 같은 규칙을 따르세요.- 요소는 캔버스가 페인트한 후에만 업로드할 수 있습니다.
paint이벤트에서 업로드하고canvas.requestPaint()를 호출하여 요청합니다. - 구성에서
{ width, height }을 전달합니다. 캔버스의 크기 조정 방식과 관계없이 복사본이 할당된 텍스처를 채우도록 합니다. - 5단계와 같이 크기가 변경된 경우 텍스처를 재할당합니다.
- 요소는 캔버스가 페인트한 후에만 업로드할 수 있습니다.
// Uploads the element into the texture. Returns false if the element has no
// paint record yet. In that case, it's uploaded on the next paint event.
function uploadElementWebGL(gl, canvas, texture, element, state) {
const size = getElementImageSize(canvas, element);
if (!size) return false;
const { width, height } = size;
// Bind the texture, and reallocate it if the size changed (step 5).
allocateElementTexture(gl, texture, state, width, height);
gl.texElementSubImage2D(gl.TEXTURE_2D, 0, 0, 0, element, { width, height });
return true;
}
canvas.onpaint = () => {
try {
uploadElementWebGL(gl, canvas, texture, uiElement, textureState);
} catch (err) {
console.error('texElementSubImage2D copy failed:', err);
}
};
requestAnimationFrame 루프를 사용하여 장면을 렌더링할 때 루프 내에서 canvas.requestPaint()를 호출하여 HTML 콘텐츠가 캔버스에 렌더링되도록 합니다. event.changedElements는 렌더링이 변경된 요소를 나열합니다. 하위 HTML 요소가 업데이트된 경우에만 텍스처를 다시 업로드해야 합니다.
function render() {
// Request to update the canvas
canvas.requestPaint();
requestAnimationFrame(render);
}
requestAnimationFrame(render);
canvas.onpaint = (event) => {
if (event.changedElements && event.changedElements.length > 0) {
// Update the texture with uploadElementWebGL()
}
// Render the scene, and sync the element geometry as shown in step 7
};
- 요소 도형을 동기화합니다.
요소의 테두리 상자(CSS 픽셀 단위)를 캔버스(CSS 픽셀 단위)에 매핑하는 DOMMatrix를 계산합니다. 브라우저는 표시 영역 변환을 사용하여 3D 좌표 공간에서 CSS 좌표 공간으로 매핑해야 합니다. 이를 위해 다음을 수행합니다.
- HTML 요소를 정규화합니다. HTML 요소의 크기는 픽셀 단위로 지정됩니다 (예: 너비 200px). 하지만 WebGL은 일반적으로 객체를 -0.5에서 0.5 사이의 '단위 정사각형'으로 취급합니다. 정규화하지 않으면 200px 버튼이 200배 더 크게 표시됩니다. 이 단계에서는 Y축도 뒤집습니다. CSS에서는 아래쪽이 양수이지만 WebGL에서는 위쪽이 양수이기 때문입니다.
- WebGL MVP 행렬을 DOM 행렬로 변환합니다.
- 캔버스 표시 영역에 매핑합니다. 이 단계는 '재조정' 단계입니다. 단위 공간 수학을 다시 확장하여 화면에 있는
<canvas>요소의 CSS 픽셀 크기와 일치시키고 Y축을 다시 뒤집습니다. 최종 변환을 계산합니다. Viewport * MVP * Normalization 순서로 행렬을 곱합니다. 이들을 하나의 최종 변환으로 결합하면 브라우저에 HTML 요소가 3D 드로잉과 정렬되기 위해 정확히 어디에 위치해야 하는지 알려주는 '지도'가 생성됩니다. 브라우저가 원근 분할을 수행합니다.
// Maps the element's border box (CSS pixels) to the canvas (CSS pixels), for a // quad that spans -0.5 to 0.5 in model space and is drawn with the `mvp` matrix. function computeCanvasTransform(canvas, element, mvp) { const width = element.offsetWidth; const height = element.offsetHeight; // 1. Normalize the HTML element (CSS pixels -> WebGL Model Space) const toGLModel = new DOMMatrix() // Scale pixels to 1 unit, flip Y (as in CSS it points down, and in WebGL it points up) .scale(1 / width, -1 / height, 1 / height) // Center the origin: (0,0) becomes (-width/2, -height/2) before scaling .translate(-width / 2, -height / 2); // 2. Convert WebGL MVP Matrix to DOM Matrix const mvpDOM = new DOMMatrix(Array.from(mvp)); // 3. Map to the canvas viewport, in CSS pixels const clipToCanvasViewport = new DOMMatrix() // Move center (0,0) to center of canvas .translate(canvas.clientWidth / 2, canvas.clientHeight / 2) // Scale normalized clip (-1..1) to viewport size, and flip Y back .scale(canvas.clientWidth / 2, -canvas.clientHeight / 2, canvas.clientHeight / 2); // 4. Multiply: (Clip -> Pixels) * (MVP) * (pixels -> unit square) return clipToCanvasViewport.multiply(mvpDOM).multiply(toGLModel); }
그런 다음 변환을 canvas.updateElementGeometry()에 canvasTransform로 전달합니다. 이렇게 하면 HTML 요소가 렌더링된 픽셀 바로 위에 배치됩니다. 이렇게 하면 사용자가 버튼을 클릭하거나 텍스트를 선택할 때 실제 HTML 요소를 클릭하게 됩니다. 브라우저는 요소의 접근성 경계(예: 스크린 리더 포커스 링)에도 변환을 사용합니다.
canvas.updateElementGeometry(targetHTMLElement, {
canvasTransform: computeCanvasTransform(canvas, targetHTMLElement, htmlElementMVP),
});
다음 옵션을 사용하여 Hit Test를 제어합니다.
updateElementGeometry()를 호출할 때마다 요소가 히트 테스트 순서의 맨 위로 이동합니다.{ preserveHitTestOrder: true }을(를) 추월하여 순위를 유지하세요.canvas.clearElementGeometry(element)를 호출하여 Hit Test에서 요소를 삭제합니다(예: 요소를 숨길 때).canvasTransform의 좌표 공간에 있는DOMRectInit인{ canvasClip }를 전달하여 히트 테스트 가능한 영역을 제한합니다(예: 요소의 일부만 그려진 경우).
예시 코드
<canvas id="canvas" content="drawable" style="width: 400px; height: 400px;">
<div id="ui-element" drawable>
<p>WebGL UI Element</p>
<button>Action</button>
</div>
</canvas>
<script>
const canvas = document.getElementById("canvas");
const gl = canvas.getContext("webgl2");
const uiElement = document.getElementById("ui-element");
// getElementImageSize() and allocateElementTexture() are defined in step 5.
// uploadElementWebGL() is defined in step 6.
// computeCanvasTransform() is defined in step 7.
// Setup WebGL texture...
const texture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, texture);
const textureState = { width: 0, height: 0 };
canvas.onpaint = () => {
// 1. Update texture with HTML content
try {
uploadElementWebGL(gl, canvas, texture, uiElement, textureState);
} catch (err) {
console.error('texElementSubImage2D copy failed:', err);
}
// ... Render your 3D scene here, calculating htmlElementMVP matrix ...
// 2. Sync DOM position, hit testing, and accessibility bounds with 3D scene
canvas.updateElementGeometry(uiElement, {
canvasTransform: computeCanvasTransform(canvas, uiElement, htmlElementMVP),
});
};
</script>
권장사항
- 필수: HTML-in-Canvas API를 사용하기 전에 브라우저 지원을 확인합니다.
- 필수: 항상
content="drawable"속성을<canvas>요소에 추가합니다. - 필수: 항상
drawable속성을 캔버스의 직계 하위 요소에 추가합니다. - 필수: 상위 요소와 독립적으로 그릴 수 있는 모든 요소에
drawable속성을 추가합니다. - 필수:
onpaint이벤트 핸들러를 사용하여 HTML 콘텐츠를 캔버스에 렌더링합니다. - 필수:
texImage2D()를 사용하여 텍스처 버퍼를 한 번 사전 할당합니다 (초기화되거나 요소 크기가 변경될 때). 크기가 변경될 때만 텍스처를 재할당하고TEXTURE_MIN_FILTER를LINEAR로 설정합니다. - 필수:
texElementSubImage2D메서드를 사용하여texImage2D()로 미리 할당한 텍스처에 HTML 콘텐츠를 렌더링합니다. 크기는captureElementImage()입니다. - 필수:
canvas.updateElementGeometry(element, { canvasTransform })를 호출하여 HTML 요소의 도형을 렌더링된 콘텐츠와 동기화합니다. - 필수:
ResizeObserver를 사용하여 화면 크기를 관찰하고 기기 픽셀과 일치하도록 캔버스 크기를 업데이트합니다. - 교차 출처 콘텐츠는 지원되지 않으므로 캔버스에 삽입하지 마세요.
onpaint이벤트 핸들러 내에서ResizeObserver를 초기화하지 마세요. 메모리 누수가 발생할 수 있습니다.
대체 전략
HTML-in-Canvas API는 현재 모든 최신 브라우저에서 지원되지 않으므로 일반적으로 대체 전략이 필요합니다.
하지만 이 API의 향상된 성능 이점을 고려할 때 브라우저에서 지원하는 경우 HTML-in-Canvas를 사용해야 합니다.
대체 전략은 사용 사례에 따라 다릅니다. 예를 들어 캔버스의 대화형 HTML 콘텐츠의 경우 HTML-in-Canvas가 지원되지 않으면 CSS를 사용하여 HTML 콘텐츠를 캔버스 위에 배치합니다.
HTML-in-Canvas 폴리필
HTML-in-Canvas API를 지원하지 않는 브라우저에서 이를 모방하려면 다음 폴리필 스크립트를 사용하세요.
라이브러리를 설치하거나 삽입합니다.
# Install npm install three-html-render# Embed <script src="https://cdn.jsdelivr.net/npm/three-html-render/dist/polyfill.js"></script>installHtmlInCanvasPolyfill()메서드를 실행하여 HTML-in-Canvas를 번역합니다.