HTML-in-Canvas API를 사용하면 캔버스 요소 내에서 직접 실제 DOM을 렌더링할 수 있습니다. WebGL, WebGPU, Three.js와 같은 3D 렌더링 컨텍스트에 적용할 때 캔버스에 content="drawable" 속성을 추가하고 하위 HTML 요소에 drawable 속성을 추가하면 이러한 요소를 3D 장면에 원활하게 투사할 수 있습니다. 중요한 점은 HTML 요소가 활성 DOM 레이아웃 트리의 일부로 유지되므로 완전한 상호작용성을 유지한다는 것입니다. 즉, 사용자는 복잡한 레이캐스팅이나 맞춤 이벤트 처리를 하지 않고도 기본적으로 버튼을 클릭하고, 텍스트를 선택하고, 포커스 상태를 트리거할 수 있습니다.
구현 방법
WebGL 및 WebGPU
WebGL 또는 WebGPU를 사용하는 경우 다음 단계를 따르세요.
브라우저에서 HTML-in-Canvas가 지원되는지 확인합니다.
if ('content' in HTMLCanvasElement.prototype) { // Use HTML in Canvas API } else { // Use fallback strategy }<canvas>HTML 요소에content="drawable"속성을 추가하여 하위 HTML 요소를 지원하도록<canvas>를 초기화합니다.<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);canvas.onpaint이벤트 핸들러 내에서 HTML 콘텐츠를 텍스처로 렌더링합니다. 캔버스가 요소를 그린 후에만 요소를 업로드할 수 있습니다.canvas.requestPaint()를 호출하여paint이벤트를 요청하세요.
canvas.captureElementImage(element)에서 텍스처 크기를 조정합니다. width 및 height는 캔버스 그리드 (백업 저장소) 픽셀에 있으므로 Math.ceil()로 반올림합니다. 요소에 아직 페인트 레코드가 없으면 captureElementImage()가 발생합니다. requestPaint()를 호출하고 다음 paint 이벤트에서 다시 시도하세요.
// 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;
}
- WebGL 컨텍스트에서
texImage2D메서드를 사용하여 텍스처 백업을 먼저 미리 할당합니다. 이 가이드의 스니펫은 WebGL 2 컨텍스트 (canvas.getContext("webgl2"))를 사용합니다. 픽셀 데이터 대신null를 사용하여texImage2D()를 호출하여 텍스처 저장소를 할당합니다. 그러면 픽셀을 업로드하지 않고도 지정된 크기와 형식의 GPU 메모리가 예약됩니다. 다음과 같은 규칙을 따르세요.- 위와 같이
captureElementImage()에서 텍스처 크기를 조정합니다. - 재할당하면 텍스처가 지워지므로 첫 번째 업로드 시 또는 크기가 변경될 때만 수준 0을 할당합니다. 변경 가능한 저장소 사용: 요소의 크기가 변경되면 변경 불가능한
texStorage2D()저장소의 크기를 조절할 수 없습니다.
- 위와 같이
// 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 };
- 그런 다음 WebGL 컨텍스트에서
texElementSubImage2D메서드를 사용하여 HTML 콘텐츠를 텍스처로 렌더링합니다. 다음과 같은 규칙을 따르세요.- 구성에서
{ width, height }을 전달합니다. 캔버스의 크기 조정 방식과 관계없이 복사본이 할당된 텍스처를 채우도록 합니다. - 위와 같이 크기가 변경된 경우 텍스처를 재할당합니다.
- 구성에서
// 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 (see above).
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);
}
};
- WebGPU 컨텍스트에서
drawElementImageToTexture메서드를 사용합니다. 다음과 같은 규칙을 따르세요.COPY_DST및RENDER_ATTACHMENT사용으로 텍스처를 만들고TEXTURE_BINDING를 추가하여 셰이더에서 샘플링합니다. 잘못된 사용은 예외가 아닌 WebGPU 유효성 검사 오류로 보고됩니다.- 텍스처 크기를
size로 전달합니다.size가 없으면 더 큰 요소가 텍스처에 맞게 잘립니다.size를 사용하면 요소가 채우도록 조정됩니다. - WebGPU 텍스처의 크기를 조절할 수 없습니다. 요소 크기가 변경되면 텍스처를 다시 만들고 이를 참조하는 바인드 그룹을 다시 빌드합니다.
// WebGPU textures can't be resized. Create the texture once, and recreate it
// when the element size changes.
function ensureElementTexture(device, state, width, height) {
if (state.texture?.width === width && state.texture?.height === height) {
return state.texture;
}
state.texture?.destroy();
state.texture = device.createTexture({
size: { width, height },
format: "rgba8unorm",
usage:
GPUTextureUsage.TEXTURE_BINDING |
GPUTextureUsage.COPY_DST |
GPUTextureUsage.RENDER_ATTACHMENT,
});
return state.texture; // Recreate the bind groups that used the previous texture.
}
const textureState = { texture: null };
canvas.onpaint = () => {
const size = getElementImageSize(canvas, uiElement);
if (!size) return;
const previousTexture = textureState.texture;
const texture = ensureElementTexture(device, textureState, size.width, size.height);
if (texture !== previousTexture) {
// New texture: create, or rebuild, the bind groups that reference it.
}
try {
device.queue.drawElementImageToTexture({ source: uiElement }, { texture, size });
} catch (err) {
console.error('drawElementImageToTexture 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 as shown above
}
// Render the scene, and sync the element geometry as shown in step 5
};
- 요소 도형을 동기화합니다.
요소의 테두리 상자(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에서 요소를 삭제합니다(예: 요소를 숨길 때).
Three.js
브라우저에서 HTML-in-Canvas가 지원되는지 확인합니다.
if ('content' in HTMLCanvasElement.prototype) { // Use HTML in Canvas API } else { // Use fallback strategy }HTML 콘텐츠의 맞춤 도형과 재질을 만듭니다.
DOM 요소를 THREE.HTMLTexture에 전달합니다. 렌더러는 요소를 캔버스에 추가하고 필수 속성을 설정합니다.
material.map = new THREE.HTMLTexture(element); mesh = new THREE.Mesh( geometry, material ); scene.add( mesh );InteractionManager에 메시를 등록하여 HTML 요소를 대화형으로 만듭니다. 요소를 메시와 정렬된 상태로 유지하고 요소 도형을 캔버스와 동기화하여 클릭과 텍스트 선택이 실제 HTML 요소에 적용되도록 합니다. 렌더링하기 전에 애니메이션 루프에서interactions.update()를 호출합니다.
import { InteractionManager } from 'three/addons/interaction/InteractionManager.js';
const interactions = new InteractionManager();
interactions.connect( renderer, camera );
interactions.add( mesh );
// In the animation loop
interactions.update();
renderer.render( scene, camera );
예시 코드
WebGL 캔버스
<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(), allocateElementTexture(), and uploadElementWebGL()
// are defined in step 4.
// computeCanvasTransform() is defined in step 5.
// 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>
WebGPU 캔버스
<canvas id="canvas" content="drawable" style="width: 400px; height: 400px;">
<div id="ui-element" drawable>
<p>WebGPU UI Element</p>
</div>
</canvas>
<script>
const canvas = document.getElementById("canvas");
const context = canvas.getContext("webgpu");
const uiElement = document.getElementById("ui-element");
// getElementImageSize() and ensureElementTexture() are defined in step 4.
// computeCanvasTransform() is defined in step 5.
// Setup WebGPU...
// const device = ...
const textureState = { texture: null };
canvas.onpaint = () => {
// 1. Copy HTML content to a texture that matches the element size
const size = getElementImageSize(canvas, uiElement);
if (size) {
const previousTexture = textureState.texture;
const texture = ensureElementTexture(device, textureState, size.width, size.height);
if (texture !== previousTexture) {
// New texture: create, or rebuild, the bind groups that reference it.
}
try {
device.queue.drawElementImageToTexture({ source: uiElement }, { texture, size });
} catch (err) {
console.error('drawElementImageToTexture copy failed:', err);
}
}
// ... Render your 3D scene here, calculating htmlElementMVP matrix ...
// 2. Sync DOM position, hit testing, and accessibility bounds (same matrix math as WebGL)
canvas.updateElementGeometry(uiElement, {
canvasTransform: computeCanvasTransform(canvas, uiElement, htmlElementMVP),
});
};
</script>
Three.js
// 1. Initialize Three.js camera, scene, renderer, mesh, interactions;
// 2. Ensure HTML-in-Canvas feature support
if (!('content' in HTMLCanvasElement.prototype)) {
// Use a fallback strategy
}
// 3. Initialize the source HTML DOM element
const element = document.createElement('div');
element.innerHTML = '<h1>Hello World</h1>';
// 4. Create geometry and material
const geometry = new RoundedBoxGeometry( 100, 100, 100, 10, 10 );
const material = new THREE.MeshStandardMaterial( { roughness: 0, metalness: 0.5 } );
// 5. Pass the DOM element into THREE.HTMLTexture
material.map = new THREE.HTMLTexture(element);
mesh = new THREE.Mesh( geometry, material );
scene.add( mesh );
// 6. Make the HTML element interactive
interactions = new InteractionManager();
interactions.connect( renderer, camera );
interactions.add( mesh );
// 7. Render Loop
function animate() {
// Keep the HTML element aligned with the mesh, so it receives input
interactions.update();
renderer.render(scene, camera);
}
권장사항
- 필수: HTML-in-Canvas API를 사용하기 전에 브라우저 지원을 확인합니다.
- 필수: WebGL 또는 WebGPU를 사용하는 경우 항상
<canvas>요소에content="drawable"속성을 추가합니다. - 필수: WebGL 또는 WebGPU를 사용하는 경우 캔버스의 직접 하위 요소를 포함하여 그리는 모든 요소에
drawable속성을 추가합니다. - 필수: WebGL 또는 WebGPU를 사용하는 경우
onpaint이벤트 핸들러를 사용하여 HTML 콘텐츠를 캔버스에 렌더링합니다. - 필수: WebGL을 사용하는 경우
texImage2D()를 사용하여 텍스처 버퍼를 한 번 사전 할당합니다 (초기화 시 또는 요소 크기가 변경될 때). 크기가 변경될 때만 텍스처를 재할당하고TEXTURE_MIN_FILTER를LINEAR로 설정합니다. - 필수: WebGL을 사용하는 경우
texElementSubImage2D메서드를 사용하여texImage2D()로 미리 할당한 텍스처에 HTML 콘텐츠를 렌더링합니다(크기:captureElementImage()). - 필수: WebGPU를 사용하는 경우
drawElementImageToTexture메서드를 사용하여 HTML 콘텐츠를 캔버스에 렌더링합니다.captureElementImage()에서 텍스처 크기를 지정하고 크기가 변경될 때만 다시 만듭니다.COPY_DST및RENDER_ATTACHMENT사용으로 텍스처를 만들고 텍스처 크기를size로 전달합니다. - 필수: WebGL 또는 WebGPU를 사용하는 경우
canvas.updateElementGeometry(element, { canvasTransform })를 호출하여 HTML 요소의 도형을 렌더링된 콘텐츠와 동기화합니다. - 필수: Three.js를 사용하는 경우
InteractionManager로 메시를 등록하고 애니메이션 루프에서interactions.update()를 호출하여 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를 번역합니다.