A API HTML-in-Canvas permite renderizar o DOM real diretamente em um elemento de tela. Quando aplicado a contextos de renderização 3D, como WebGL, WebGPU ou Three.js, adicionar o atributo content="drawable" à tela e o atributo drawable aos elementos HTML descendentes permite que esses elementos sejam projetados perfeitamente na cena 3D. Como os elementos HTML continuam fazendo parte da árvore de layout do DOM ativo, eles mantêm a interatividade total, permitindo que os usuários cliquem em botões, selecionem texto e acionem estados de foco de forma nativa, sem precisar de raycasting complexo ou processamento de eventos personalizado.
Como fazer a implementação
WebGL e WebGPU
Ao usar o WebGL ou a WebGPU, siga estas etapas:
Verifique se o HTML-in-Canvas é compatível com o navegador:
if ('content' in HTMLCanvasElement.prototype) { // Use HTML in Canvas API } else { // Use fallback strategy }Inicialize
<canvas>para oferecer suporte a elementos HTML descendentes adicionando o atributocontent="drawable"ao elemento HTML<canvas>. Coloque o conteúdo HTML dentro do elemento<canvas>e adicione o atributodrawablea cada elemento que você desenhar. Um elemento comdrawablecaptura a subárvore dele, exceto os descendentes aninhados que também sãodrawable:<canvas id="canvas" content="drawable"> <div id="html-content" drawable></div> </canvas>Dimensione a grade da tela para corresponder ao fator de escalonamento do dispositivo e evitar que fique desfocada:
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);Renderize o conteúdo HTML em uma textura dentro de um manipulador de eventos
canvas.onpaint. Um elemento só pode ser enviado depois que a tela o renderiza: chamecanvas.requestPaint()para solicitar um eventopaint.
Defina o tamanho da textura de canvas.captureElementImage(element). Os valores de width e height estão em pixels da grade da tela (armazenamento de backup). Portanto, arredonde-os com Math.ceil(). captureElementImage() vai gerar uma exceção se o elemento ainda não tiver um registro de pintura: chame requestPaint() e tente novamente no próximo evento 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;
}
- No contexto do WebGL, pré-aloque o suporte de textura primeiro usando o método
texImage2D. Os snippets neste guia usam um contexto WebGL 2 (canvas.getContext("webgl2")). Aloque o armazenamento de textura chamandotexImage2D()comnullem vez de dados de pixel, o que reserva a memória da GPU do tamanho e formato fornecidos sem fazer upload de pixels. Siga estas regras:- Defina o tamanho da textura de
captureElementImage(), conforme mostrado acima. - Aloque o nível 0 apenas no primeiro upload ou quando o tamanho mudar, porque a realocação limpa a textura. Use o armazenamento mutável: o armazenamento imutável de
texStorage2D()não pode ser redimensionado quando o elemento muda de tamanho.
- Defina o tamanho da textura de
// 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 };
- Em seguida, no contexto do WebGL, renderize o conteúdo HTML na textura usando o método
texElementSubImage2D. Siga estas regras:- Transmita
{ width, height }na configuração. Ele faz com que a cópia preencha a textura alocada, independentemente de como a tela é dimensionada. - Realocar a textura se o tamanho mudar, conforme mostrado acima.
- Transmita
// 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);
}
};
- No contexto da WebGPU, use o método
drawElementImageToTexture. Siga estas regras:- Crie a textura com o uso de
COPY_DSTeRENDER_ATTACHMENTe adicioneTEXTURE_BINDINGpara fazer a amostragem em sombreadores. Um uso incorreto é relatado como um erro de validação da WebGPU, não como uma exceção. - Transmita o tamanho da textura como
size. Semsize, um elemento maior é cortado para a textura. Comsize, o elemento é dimensionado para preenchê-lo. - Não é possível redimensionar as texturas da WebGPU. Recrie a textura quando o tamanho do elemento mudar e recrie os grupos de vinculação que fazem referência a ela.
- Crie a textura com o uso de
// 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);
}
};
Ao usar um loop requestAnimationFrame para renderizar a cena, chame canvas.requestPaint() dentro do loop para garantir que o conteúdo HTML seja renderizado na tela. event.changedElements lista os elementos que tiveram a renderização alterada. Certifique-se de reenviar a textura apenas se houver uma atualização nos elementos HTML descendentes:
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
};
- Sincronize a geometria do elemento.
Calcule um DOMMatrix que mapeie a caixa de borda do elemento, em pixels CSS, para a tela, em pixels CSS. O navegador precisa mapear o espaço de coordenadas 3D para o espaço de coordenadas CSS usando uma transformação de janela de visualização. Para facilitar, faça o seguinte:
- Normalizar o elemento HTML. Os elementos HTML são dimensionados em pixels (por exemplo, 200 px de largura). No entanto, o WebGL geralmente trata os objetos como "quadrados unitários", por exemplo, de -0,5 a 0,5. Se você não normalizar, o botão de 200 px vai parecer 200 vezes maior. Essa etapa também inverte o eixo Y, porque no CSS, para baixo é positivo, mas no WebGL, para cima é positivo.
- Converta a matriz MVP do WebGL em uma matriz DOM.
- Mapear para a janela de visualização da tela. Essa etapa é a fase de "redimensionamento": ela estende o cálculo do espaço unitário para corresponder às dimensões de pixel CSS do elemento
<canvas>na tela e inverte o eixo Y. Calcule a transformação final. Multiplique as matrizes na ordem: Janela de visualização * MVP * Normalização. Combiná-las em uma transformação final produz um "mapa" que informa ao navegador exatamente onde esse elemento HTML deve ficar para se alinhar ao desenho 3D. O navegador realiza a divisão de perspectiva.
// 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); }
Em seguida, transmita a transformação para canvas.updateElementGeometry() como canvasTransform. Isso move o elemento HTML para ficar diretamente sobre os pixels renderizados. Isso garante que, quando um usuário clicar em um botão ou selecionar um texto, ele esteja realmente interagindo com o elemento HTML real. O navegador também usa a transformação para os limites de acessibilidade do elemento, por exemplo, para anéis de foco do leitor de tela:
canvas.updateElementGeometry(targetHTMLElement, {
canvasTransform: computeCanvasTransform(canvas, targetHTMLElement, htmlElementMVP),
});
Use estas opções para controlar o teste de hit:
- Cada chamada
updateElementGeometry()move o elemento para o topo da ordem de teste de hit. Passe{ preserveHitTestOrder: true }para manter a posição. - Chame
canvas.clearElementGeometry(element)para remover um elemento do teste de hit, por exemplo, ao ocultá-lo.
Three.js
Verifique se o HTML-in-Canvas é compatível com o navegador:
if ('content' in HTMLCanvasElement.prototype) { // Use HTML in Canvas API } else { // Use fallback strategy }Crie uma geometria e um material personalizados para o conteúdo HTML.
Transmita o elemento DOM para THREE.HTMLTexture. O renderizador adiciona o elemento à tela e define os atributos necessários:
material.map = new THREE.HTMLTexture(element); mesh = new THREE.Mesh( geometry, material ); scene.add( mesh );Registre a malha com
InteractionManagerpara tornar o elemento HTML interativo. Ele mantém o elemento alinhado com a malha e sincroniza a geometria do elemento com a tela, para que os cliques e a seleção de texto atinjam o elemento HTML real. Chameinteractions.update()no loop de animação antes da renderização:
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 );
Exemplo de código
Tela do 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>
Tela da 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);
}
Práticas recomendadas
- OBRIGATÓRIO: verifique se o navegador é compatível com a API HTML-in-Canvas antes de usá-la.
- OBRIGATÓRIO: ao usar WebGL ou WebGPU, sempre adicione o atributo
content="drawable"ao elemento<canvas>. - OBRIGATÓRIO: ao usar WebGL ou WebGPU, adicione o atributo
drawablea cada elemento que você desenhar, incluindo filhos diretos da tela. - OBRIGATÓRIO: ao usar WebGL ou WebGPU, use um manipulador de eventos
onpaintpara renderizar o conteúdo HTML na tela. - OBRIGATÓRIO: ao usar o WebGL, pré-aloque o buffer de textura com
texImage2D()uma vez (quando ele for inicializado ou quando o tamanho do elemento mudar). Realoque a textura apenas quando o tamanho mudar e definaTEXTURE_MIN_FILTERcomoLINEAR. - OBRIGATÓRIO: ao usar o WebGL, use o método
texElementSubImage2Dpara renderizar o conteúdo HTML em uma textura pré-alocada comtexImage2D(), dimensionada decaptureElementImage(). - OBRIGATÓRIO: ao usar a WebGPU, use o método
drawElementImageToTexturepara renderizar o conteúdo HTML na tela. Defina o tamanho da textura decaptureElementImage()e recrie-a somente quando o tamanho mudar. Crie a textura com o uso deCOPY_DSTeRENDER_ATTACHMENTe transmita o tamanho da textura comosize. - OBRIGATÓRIO: ao usar o WebGL ou a WebGPU, sincronize a geometria do elemento HTML com o conteúdo renderizado chamando
canvas.updateElementGeometry(element, { canvasTransform }). - OBRIGATÓRIO: ao usar o Three.js, registre a malha com
InteractionManagere chameinteractions.update()no loop de animação para que o elemento HTML receba entrada. - OBRIGATÓRIO: observe o tamanho da tela e atualize o tamanho da tela para corresponder aos pixels do dispositivo, por exemplo, usando
ResizeObserver. - NÃO incorpore conteúdo de origem cruzada em uma tela, porque isso não é permitido.
- NÃO inicialize
ResizeObserverno manipulador de eventosonpaint, porque isso pode causar vazamentos de memória.
Estratégias de fallback
A API HTML-in-Canvas ainda não é compatível com todos os navegadores mais recentes. Por isso, geralmente é necessária uma estratégia de fallback.
No entanto, considerando os benefícios de desempenho dessa API, o HTML-in-Canvas deve ser usado se o navegador for compatível.
A estratégia de fallback depende do caso de uso. Por exemplo, para um conteúdo HTML interativo em canvas, se o HTML-in-Canvas não for compatível, coloque o conteúdo HTML sobre o canvas usando CSS.
Polyfill HTML-in-Canvas
Use o script de polyfill a seguir para simular a API HTML-in-Canvas em navegadores que não oferecem suporte a ela.
Instale ou incorpore a biblioteca:
# Install npm install three-html-render# Embed <script src="https://cdn.jsdelivr.net/npm/three-html-render/dist/polyfill.js"></script>Execute o método
installHtmlInCanvasPolyfill()para traduzir o HTML-in-Canvas.