Expor o conteúdo do canvas aos recursos do navegador

Recursos da Web: HTML em tela

O conteúdo normal de <canvas> não é exposto a recursos do navegador, como leitores de tela, indexação, ferramentas de tradução, ferramentas de acessibilidade, localização na página, impressão etc. Com HTML in canvas, é possível renderizar o DOM real diretamente em um elemento de tela. Adicionar o atributo content="drawable" a um elemento HTML <canvas> define o layout dos elementos HTML descendentes, e o atributo drawable marca os elementos que podem ser renderizados no contexto de renderização da tela. Você pode usá-la para estilizar e organizar o texto em uma tela, expor o conteúdo da tela aos recursos do navegador (como acessibilidade, tradução ou localização na página) e aplicar efeitos 2D e 3D ao HTML.

Como fazer a implementação

  1. 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
    }
    
  2. Adicione o atributo content="drawable" ao elemento HTML <canvas>.

  3. Coloque o conteúdo HTML dentro do elemento <canvas> e adicione o atributo drawable a cada elemento que você desenhar. Um elemento com drawable captura a subárvore dele, exceto os descendentes aninhados que também são drawable.

    <canvas id="canvas" content="drawable">
      <div id="html-content" drawable></div>
    </canvas>
    
  4. 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);
    
  5. Renderize o conteúdo HTML na tela dentro de um manipulador de eventos canvas.onpaint. Um elemento só pode ser desenhado depois que a tela o pinta: chame canvas.requestPaint() para solicitar um evento paint.

  • No contexto 2D, use o método drawElementImage. Ela desenha o último snapshot de um elemento drawable e a subárvore desenhável dele, começando na caixa de borda do elemento, antes das transformações CSS:
canvas.onpaint = () => {
  ctx.reset();
  // Draw the form element at x:0, y:0
  ctx.drawElementImage(form_element, 0, 0);
};
  • Nos contextos WebGL e WebGPU, você copia o elemento em uma textura. 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 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 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.
// 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.
// 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_DST e RENDER_ATTACHMENT e adicione TEXTURE_BINDING para 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. Sem size, um elemento maior é cortado para a textura. Com size, 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.
// 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. Só redesenhe o elemento ou faça o reenviar da textura dele 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) {
    // Redraw the element, or update its texture, as shown above
  }
  // In WebGL and WebGPU, render the scene, and sync the element geometry as shown in step 6
};
  1. Sincronize a geometria do elemento.

O navegador precisa saber onde o elemento HTML é desenhado para que a posição do DOM corresponda aos pixels desenhados. O teste de hit, por exemplo, para cliques e seleção de texto, e a acessibilidade usam essa geometria: um elemento drawable com geometria não atualizada ainda é exposto à árvore de acessibilidade, mas sem informações de geometria. Uma tela só faz testes de hit nos elementos desenhados com o método 2D drawElementImage() ou registrados com updateElementGeometry().

  • No caso do contexto 2D, o navegador sincroniza o elemento automaticamente. drawElementImage() alinha os limites do teste de hit do DOM e os anéis de foco do leitor de tela com as coordenadas desenhadas, então não é necessário definir style.transform. Para gerenciar a geometria manualmente, por exemplo, ao desenhar o mesmo elemento mais de uma vez, transmita { preserveElementGeometry: true } para drawElementImage() e aplique transformações com canvas.updateElementGeometry().

  • No caso 3D com WebGL ou WebGPU, o canvas não consegue identificar onde o elemento HTML é desenhado, então você precisa informar isso. 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.

Exemplo de código

Tela 2D

<canvas id="canvas" content="drawable" style="width: 400px; height: 200px;">
  <div id="ui-element" drawable>
    <p>
      This text is rendered inside the canvas but is present in the DOM tree.
    </p>
    <input type="email" name="email" placeholder="enter your email" />
    <button type="button">Submit</button>
  </div>
</canvas>

<script>
  const canvas = document.getElementById("canvas");
  const ctx = canvas.getContext("2d");
  const uiElement = document.getElementById("ui-element");

  canvas.onpaint = () => {
    ctx.reset();
    // Draw the HTML element at x:0, y:0. The browser syncs the DOM location
    // with the drawn location.
    ctx.drawElementImage(uiElement, 0, 0);
  };

  // Handle resizing to match device pixels
  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);
    canvas.requestPaint();
  });

  const supportsDevicePixelContentBox =
    typeof ResizeObserverEntry !== "undefined" &&
    "devicePixelContentBoxSize" in ResizeObserverEntry.prototype;
  const options = supportsDevicePixelContentBox
    ? { box: "device-pixel-content-box" }
    : {};
  observer.observe(canvas, options);
</script>

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 5.
  // computeCanvasTransform() is defined in step 6.

  // 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 5.
  // computeCanvasTransform() is defined in step 6.

  // 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>

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: adicione o atributo drawable a cada elemento que você desenhar, incluindo os filhos diretos da tela.
  • OBRIGATÓRIO: use um manipulador de eventos onpaint para renderizar o conteúdo HTML na tela.
  • OBRIGATÓRIO: use o método drawElementImage em 2D ou drawElementImageToTexture na WebGPU para renderizar o conteúdo HTML na tela.
  • OBRIGATÓRIO: no 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 defina TEXTURE_MIN_FILTER como LINEAR.
  • OBRIGATÓRIO: no WebGL, use o método texElementSubImage2D para renderizar o conteúdo HTML em uma textura pré-alocada com texImage2D(), dimensionada de captureElementImage().
  • OBRIGATÓRIO: na WebGPU, dimensione a textura de captureElementImage() e recrie-a apenas quando o tamanho mudar. Crie a textura com o uso de COPY_DST e RENDER_ATTACHMENT e transmita o tamanho da textura como size.
  • OBRIGATÓRIO: sincronize a geometria do elemento HTML com o conteúdo renderizado. Em 2D, o drawElementImage faz isso automaticamente. No WebGL e na WebGPU, chame canvas.updateElementGeometry(element, { canvasTransform }).
  • OBRIGATÓRIO: use ResizeObserver para 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 ResizeObserver no manipulador de eventos onpaint, 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.

Polyfills de 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.

  1. 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>
    
  2. Execute o método installHtmlInCanvasPolyfill() para traduzir o HTML-in-Canvas.