Os sombreadores do WebGL oferecem efeitos visuais avançados acelerados por GPU, permitindo recursos como distorções dinâmicas de ondulação, modelos de iluminação, gradação de cores e transformações de vértices personalizadas. A API HTML-in-Canvas permite que os desenvolvedores apliquem texturas WebGL ao conteúdo HTML. Isso permite aplicar sombreadores de fragmentos e vértices de alto desempenho de forma nativa a componentes de interface totalmente interativos, como botões, campos de entrada e rich text, mantendo a acessibilidade nativa, a seleção de texto e o processamento de eventos do DOM.
Como fazer a implementação
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 }Adicione o atributo
content="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);Pré-aloque o suporte de textura primeiro usando o método
texImage2D. Os snippets neste guia usam um contexto WebGL 2 (canvas.getContext("webgl2")).
Primeiro, aloque o armazenamento de textura chamando texImage2D() com null em 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
canvas.captureElementImage(element). Os valores dewidtheheightestão em pixels da grade da tela (armazenamento de backup). Portanto, arredonde-os comMath.ceil().captureElementImage()vai gerar uma exceção se o elemento ainda não tiver um registro de pintura: chamerequestPaint()e tente novamente no próximo eventopaint. - 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.
// 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 };
Renderize o conteúdo HTML na textura dentro de um manipulador de eventos
canvas.onpaintusando o métodotexElementSubImage2D. Siga estas regras:- Um elemento só pode ser enviado depois de ser pintado pela tela. Faça upload do evento
painte chamecanvas.requestPaint()para solicitar um. - 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 na etapa 5.
- Um elemento só pode ser enviado depois de ser pintado pela tela. Faça upload do evento
// 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);
}
};
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 with uploadElementWebGL()
}
// Render the scene, and sync the element geometry as shown in step 7
};
- 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. - Transmita
{ canvasClip }, umDOMRectInitno espaço de coordenadas decanvasTransform, para limitar a área de teste de hit, por exemplo, quando apenas parte do elemento é desenhada.
Exemplo de código
<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>
Práticas recomendadas
- OBRIGATÓRIO: verifique se o navegador é compatível com a API HTML-in-Canvas antes de usá-la.
- OBRIGATÓRIO: sempre adicione o atributo
content="drawable"ao elemento<canvas>. - OBRIGATÓRIO: SEMPRE adicione o atributo
drawableaos filhos diretos da tela. - OBRIGATÓRIO: adicione o atributo
drawablea cada elemento que você quer que seja desenhado de forma independente dos elementos pai. - OBRIGATÓRIO: use um manipulador de eventos
onpaintpara renderizar o conteúdo HTML na tela. - OBRIGATÓRIO: 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: use o método
texElementSubImage2Dpara renderizar o conteúdo HTML em uma textura pré-alocada comtexImage2D(), dimensionada decaptureElementImage(). - OBRIGATÓRIO: sincronize a geometria do elemento HTML com o conteúdo renderizado chamando
canvas.updateElementGeometry(element, { canvasTransform }). - OBRIGATÓRIO: use
ResizeObserverpara observar o tamanho da tela e atualizar o tamanho da tela de pintura para corresponder aos pixels do dispositivo. - 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.