Habilita el contenido HTML interactivo en escenas 3D

Funciones web: HTML en canvas

La API de HTML-in-Canvas permite renderizar el DOM real directamente dentro de un elemento canvas. Cuando se aplica a contextos de renderización en 3D, como WebGL, WebGPU o Three.js, agregar el atributo content="drawable" al lienzo y el atributo drawable a sus elementos HTML descendientes permite que esos elementos se proyecten sin problemas en la escena en 3D. Fundamentalmente, como los elementos HTML siguen formando parte del árbol de diseño del DOM activo, conservan toda la interactividad, lo que permite a los usuarios hacer clic en botones, seleccionar texto y activar estados de enfoque de forma nativa sin necesidad de realizar un trazado de rayos complejo ni un control de eventos personalizado.

Cómo implementarlo

WebGL y WebGPU

Cuando uses WebGL o WebGPU, sigue estos pasos:

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

    if ('content' in HTMLCanvasElement.prototype) {
      // Use HTML in Canvas API
    } else {
      // Use fallback strategy
    }
    
  2. Inicializa <canvas> para admitir elementos HTML descendientes agregando el atributo content="drawable" al elemento HTML <canvas>. 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>
    
  3. 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);
    
  4. Renderiza el contenido HTML en una textura dentro de un controlador de eventos canvas.onpaint. Un elemento solo se puede subir después de que el lienzo lo haya pintado: llama a canvas.requestPaint() para solicitar un evento paint.

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.

// 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;
}
  • En el contexto de WebGL, 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")). Llama a texImage2D() con null en lugar de datos de píxeles para asignar almacenamiento de texturas, lo que reserva memoria de la GPU del tamaño y formato dados sin subir ningún píxel. Sigue estas reglas:
    • Ajusta el tamaño de la textura de captureElementImage(), como se muestra más arriba.
    • 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.
// 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 };
  • Luego, en el contexto de WebGL, renderiza el contenido HTML en la textura con el método texElementSubImage2D. Sigue estas reglas:
    • 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 más arriba.
// 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);
  }
};
  • En el contexto de WebGPU, usa el método drawElementImageToTexture. Sigue estas reglas:
    • Crea la textura con el uso de COPY_DST y RENDER_ATTACHMENT, y agrega TEXTURE_BINDING para muestrearla en sombreadores. Un uso incorrecto se informa como un error de validación de WebGPU, no como una excepción.
    • Pasa el tamaño de la textura como size. Sin size, un elemento más grande se recorta a la textura. Con size, el elemento se escala para llenarlo.
    • No se puede cambiar el tamaño de las texturas de WebGPU. Vuelve a crear la textura cuando cambie el tamaño del elemento y vuelve a compilar los grupos de vinculaciones que hacen referencia a ella.
// 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);
  }
};

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 as shown above
  }
  // Render the scene, and sync the element geometry as shown in step 5
};
  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.

Three.js

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

    if ('content' in HTMLCanvasElement.prototype) {
      // Use HTML in Canvas API
    } else {
      // Use fallback strategy
    }
    
  2. Crea una geometría y un material personalizados para el contenido HTML.

  3. Pasa el elemento del DOM a THREE.HTMLTexture. El renderizador agrega el elemento a su lienzo y establece los atributos obligatorios:

      material.map = new THREE.HTMLTexture(element);
      mesh = new THREE.Mesh( geometry, material );
      scene.add( mesh );
    
  4. Registra la malla con InteractionManager para que el elemento HTML sea interactivo. Mantiene el elemento alineado con la malla y sincroniza la geometría del elemento con el lienzo, de modo que los clics y la selección de texto lleguen al elemento HTML real. Llama a interactions.update() en el bucle de animación antes de renderizar:

  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 );

Ejemplo de código

Canvas de 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>

Lienzo de 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ácticas recomendadas

  • OBLIGATORIO: Verifica la compatibilidad del navegador con la API de HTML-in-Canvas antes de usarla.
  • OBLIGATORIO: Cuando uses WebGL o WebGPU, agrega siempre el atributo content="drawable" al elemento <canvas>.
  • OBLIGATORIO: Cuando uses WebGL o WebGPU, agrega el atributo drawable a cada elemento que dibujes, incluidos los elementos secundarios directos del lienzo.
  • OBLIGATORIO: Cuando uses WebGL o WebGPU, utiliza un controlador de eventos onpaint para renderizar el contenido HTML en el lienzo.
  • OBLIGATORIO: Cuando uses WebGL, asigna previamente el búfer de textura con texImage2D() una vez (cuando se inicialice o cuando cambie el tamaño del elemento). Vuelve a asignar la textura solo cuando cambie el tamaño y establece TEXTURE_MIN_FILTER en LINEAR.
  • OBLIGATORIO: Cuando uses WebGL, utiliza el método texElementSubImage2D para renderizar el contenido HTML en una textura que asignes previamente con texImage2D(), con el tamaño de captureElementImage().
  • OBLIGATORIO: Cuando uses WebGPU, utiliza el método drawElementImageToTexture para renderizar el contenido HTML en el lienzo. Asigna un tamaño a la textura desde captureElementImage() y vuelve a crearla solo cuando cambie el tamaño. Crea la textura con el uso de COPY_DST y RENDER_ATTACHMENT, y pasa el tamaño de la textura como size.
  • OBLIGATORIO: Cuando uses WebGL o WebGPU, sincroniza la geometría del elemento HTML con el contenido renderizado llamando a canvas.updateElementGeometry(element, { canvasTransform }).
  • OBLIGATORIO: Cuando uses Three.js, registra la malla con InteractionManager y llama a interactions.update() en el bucle de animación para que el elemento HTML reciba la entrada.
  • OBLIGATORIO: Observa el tamaño de la pantalla y actualiza el tamaño del lienzo para que coincida con los píxeles del dispositivo, por ejemplo, con ResizeObserver.
  • 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.