Aplica sombreadores de WebGL al contenido HTML

Funciones web: HTML en canvas

Los sombreadores de WebGL proporcionan potentes efectos visuales acelerados por GPU, lo que habilita funciones avanzadas, como distorsiones dinámicas de ondas, modelos de iluminación, gradación de color y transformaciones de vértices personalizadas. La API de HTML-in-Canvas permite que los desarrolladores apliquen texturas de WebGL al contenido HTML. Esto permite aplicar sombreadores de fragmentos y vértices de alto rendimiento de forma nativa a componentes de IU completamente interactivos, como botones, campos de entrada y texto enriquecido, a la vez que se conserva la accesibilidad nativa, la selección de texto y el control de eventos del DOM.

Cómo implementarlo

  1. Verifica si el navegador admite HTML-in-Canvas:

    if ('content' in HTMLCanvasElement.prototype) {
      // Use HTML in Canvas API
    } else {
      // Use fallback strategy
    }
    
  2. Agrega el atributo content="drawable" al elemento HTML <canvas>.

  3. Coloca tu contenido HTML dentro del elemento <canvas> y agrega el atributo drawable a cada elemento que dibujes. Un elemento con drawable captura su subárbol, excepto los descendientes anidados que también son drawable.

    <canvas id="canvas" content="drawable">
      <div id="html-content" drawable></div>
    </canvas>
    
  4. Ajusta la cuadrícula del lienzo para que coincida con el factor de escala del dispositivo y evitar que se vea borroso:

    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. Primero, asigna previamente el respaldo de la textura con el método texImage2D. Los fragmentos de esta guía usan un contexto de WebGL 2 (canvas.getContext("webgl2")).

Primero, asigna almacenamiento de texturas llamando a texImage2D() con null en lugar de datos de píxeles, lo que reserva memoria de la GPU del tamaño y formato dados sin subir ningún píxel. Siga estas reglas:

  • Establece el tamaño de la textura desde canvas.captureElementImage(element). Sus propiedades width y height se expresan en píxeles de la cuadrícula del lienzo (almacén de copia de seguridad), por lo que debes redondearlas con Math.ceil(). captureElementImage() arroja un error si el elemento aún no tiene un registro de pintura: llama a requestPaint() y vuelve a intentarlo en el próximo evento paint.
  • Asigna el nivel 0 solo en la primera carga o cuando cambie el tamaño, ya que la reasignación borra la textura. Usa almacenamiento mutable: No se puede cambiar el tamaño del almacenamiento de texStorage2D() inmutable cuando cambia el tamaño del elemento.
// 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 };
  1. Renderiza el contenido HTML en la textura dentro de un controlador de eventos canvas.onpaint con el método texElementSubImage2D. Siga estas reglas:

    • Un elemento solo se puede subir después de que el lienzo lo haya pintado. Sube desde el evento paint y llama a canvas.requestPaint() para solicitar uno.
    • Pasa { width, height } en la configuración. Hace que la copia complete la textura asignada, independientemente de cómo se escale el lienzo.
    • Vuelve a asignar la textura si cambió el tamaño, como se muestra en el paso 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);
  }
};

Cuando uses un bucle requestAnimationFrame para renderizar la escena, llama a canvas.requestPaint() dentro del bucle para asegurarte de que el contenido HTML se renderice en el lienzo. event.changedElements enumera los elementos cuya renderización cambió. Asegúrate de volver a subir la textura solo si se actualizaron los elementos HTML descendientes:

  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
  };
  1. Sincroniza la geometría del elemento.

Calcula un DOMMatrix que asigne el cuadro de borde del elemento, en píxeles de CSS, al lienzo, en píxeles de CSS. El navegador debe asignar el espacio de coordenadas 3D al espacio de coordenadas CSS con una transformación de viewport. Para facilitar este proceso, haz lo siguiente:

  • Normaliza el elemento HTML. Los elementos HTML se miden en píxeles (por ejemplo, 200 px de ancho). Sin embargo, WebGL suele tratar los objetos como "cuadrados unitarios", por ejemplo, con un rango de -0.5 a 0.5. Si no lo haces, el botón de 200 px se verá 200 veces más grande. En este paso, también se invierte el eje Y, ya que, en CSS, hacia abajo es positivo, pero en WebGL, hacia arriba es positivo.
  • Convierte la matriz de MVP de WebGL en una matriz de DOM.
  • Asigna el mapa al viewport del lienzo. Este paso es la fase de "cambio de escala": extiende esa matemática de espacio de unidades para que coincida con las dimensiones de píxeles de CSS de tu elemento <canvas> en la pantalla y vuelve a invertir el eje Y.
  • Calcula la transformación final. Multiplica las matrices en orden: Viewport * MVP * Normalización. Si se combinan en una transformación final, se produce un "mapa" que le indica al navegador exactamente dónde debe ubicarse ese elemento HTML para alinearse con el dibujo en 3D. El navegador realiza la división 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);
    }
    

Luego, pasa la transformación a canvas.updateElementGeometry() como canvasTransform. Esto mueve el elemento HTML para que se ubique directamente sobre sus píxeles renderizados. Esto garantiza que, cuando un usuario hace clic en un botón o selecciona texto, realmente esté interactuando con el elemento HTML real. El navegador también usa la transformación para los límites de accesibilidad del elemento, por ejemplo, para los anillos de enfoque del lector de pantalla:

  canvas.updateElementGeometry(targetHTMLElement, {
    canvasTransform: computeCanvasTransform(canvas, targetHTMLElement, htmlElementMVP),
  });

Usa estas opciones para controlar la prueba de posicionamiento:

  • Cada llamada a updateElementGeometry() mueve el elemento a la parte superior del orden de prueba de posicionamiento. Pasa { preserveHitTestOrder: true } para mantener su posición.
  • Llama a canvas.clearElementGeometry(element) para quitar un elemento de la prueba de posicionamiento, por ejemplo, cuando lo ocultas.
  • Pasa { canvasClip }, un DOMRectInit en el espacio de coordenadas de canvasTransform, para limitar el área de prueba de posicionamiento, por ejemplo, cuando solo se dibuja una parte del elemento.

Ejemplo 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ácticas recomendadas

  • OBLIGATORIO: Verifica la compatibilidad del navegador con la API de HTML-in-Canvas antes de usarla.
  • OBLIGATORIO: Agrega siempre el atributo content="drawable" al elemento <canvas>.
  • OBLIGATORIO: Agrega SIEMPRE el atributo drawable a los elementos secundarios directos del lienzo.
  • OBLIGATORIO: Agrega el atributo drawable a cada elemento que quieras dibujar de forma independiente de sus elementos superiores.
  • OBLIGATORIO: Usa un controlador de eventos onpaint para renderizar el contenido HTML en el lienzo.
  • OBLIGATORIO: Asigna previamente el búfer de textura con texImage2D() una vez (cuando se inicializa o cuando cambia el tamaño del elemento). Vuelve a asignar la textura solo cuando cambie el tamaño y establece TEXTURE_MIN_FILTER en LINEAR.
  • OBLIGATORIO: Usa el método texElementSubImage2D para renderizar el contenido HTML en una textura que asignes previamente con texImage2D(), con el tamaño de captureElementImage().
  • OBLIGATORIO: Sincroniza la geometría del elemento HTML con el contenido renderizado llamando a canvas.updateElementGeometry(element, { canvasTransform }).
  • OBLIGATORIO: Usa ResizeObserver para observar el tamaño de la pantalla y actualizar el tamaño del lienzo para que coincida con los píxeles del dispositivo.
  • NO incorpores contenido de origen cruzado en un lienzo, ya que no se admite.
  • NO inicialices ResizeObserver dentro del controlador de eventos onpaint, ya que esto puede provocar fugas de memoria.

Estrategias de resguardo

Actualmente, la API de HTML-in-Canvas no es compatible con todos los navegadores modernos, por lo que, por lo general, se requiere una estrategia de resguardo.

Sin embargo, dadas las ventajas de rendimiento mejorado de esta API, se debe usar HTML-in-Canvas si el navegador lo admite.

La estrategia de resguardo depende del caso de uso. Por ejemplo, para un contenido HTML interactivo en un lienzo, si no se admite HTML-in-Canvas, coloca el contenido HTML sobre el lienzo con CSS.

Polyfill de HTML-in-Canvas

Usa la siguiente secuencia de comandos de polyfill para imitar la API de HTML en Canvas en los navegadores que no la admiten.

  1. Instala o incorpora la biblioteca:

    # Install
    npm install three-html-render
    
    # Embed
    <script src="https://cdn.jsdelivr.net/npm/three-html-render/dist/polyfill.js"></script>
    
  2. Ejecuta el método installHtmlInCanvasPolyfill() para traducir HTML-in-Canvas.