Activer le contenu HTML interactif dans les scènes 3D

Fonctionnalités Web : HTML dans un canevas

L'API HTML-in-Canvas permet d'afficher un DOM réel directement dans un élément canvas. Lorsqu'il est appliqué à des contextes de rendu 3D tels que WebGL, WebGPU ou Three.js, l'ajout de l'attribut content="drawable" au canevas et de l'attribut drawable à ses éléments HTML descendants permet de projeter ces éléments de manière fluide dans la scène 3D. Surtout, comme les éléments HTML font toujours partie de l'arborescence de mise en page DOM active, ils conservent une interactivité totale. Les utilisateurs peuvent ainsi cliquer sur des boutons, sélectionner du texte et déclencher des états de sélection de manière native, sans nécessiter de raycasting complexe ni de gestion d'événements personnalisée.

Mise en œuvre

WebGL et WebGPU

Lorsque vous utilisez WebGL ou WebGPU, procédez comme suit :

  1. Vérifiez si le navigateur est compatible avec HTML-in-Canvas :

    if ('content' in HTMLCanvasElement.prototype) {
      // Use HTML in Canvas API
    } else {
      // Use fallback strategy
    }
    
  2. Initialisez <canvas> pour qu'il accepte les éléments HTML descendants en ajoutant l'attribut content="drawable" à l'élément HTML <canvas>. Placez votre contenu HTML dans l'élément <canvas> et ajoutez l'attribut drawable à chaque élément que vous dessinez. Un élément avec drawable capture sa sous-arborescence, à l'exception des descendants imbriqués qui sont également drawable :

    <canvas id="canvas" content="drawable">
      <div id="html-content" drawable></div>
    </canvas>
    
  3. Mettez à l'échelle la grille de votre canevas pour qu'elle corresponde au facteur de scaling de l'appareil afin d'éviter le flou :

    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. Affichez le contenu HTML dans une texture à l'intérieur d'un gestionnaire d'événements canvas.onpaint. Un élément ne peut être importé qu'après avoir été peint par le canevas : appelez canvas.requestPaint() pour demander un événement paint.

Définissez la taille de la texture à partir de canvas.captureElementImage(element). Ses valeurs width et height sont exprimées en pixels de la grille du canevas (backing store). Arrondissez-les donc avec Math.ceil(). captureElementImage() génère une exception si l'élément n'a pas encore d'enregistrement de peinture : appelez requestPaint(), puis réessayez lors de l'événement paint suivant.

// 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;
}
  • Dans le contexte WebGL, préallouez d'abord la texture à l'aide de la méthode texImage2D. Les extraits de ce guide utilisent un contexte WebGL 2 (canvas.getContext("webgl2")). Allouez de l'espace de stockage de texture en appelant texImage2D() avec null au lieu de données de pixels, ce qui réserve de la mémoire GPU de la taille et du format donnés sans importer de pixels. Suivez les règles ci-dessous :
    • Définissez la taille de la texture à partir de captureElementImage(), comme indiqué ci-dessus.
    • N'allouez le niveau 0 que lors de la première importation ou lorsque la taille change, car la réallocation efface la texture. Utilisez un stockage modifiable : le stockage texStorage2D() immuable ne peut pas être redimensionné lorsque l'élément change de taille.
// 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 };
  • Ensuite, dans le contexte WebGL, affichez le contenu HTML dans la texture à l'aide de la méthode texElementSubImage2D. Suivez les règles ci-dessous :
    • Transmettez { width, height } dans la configuration. La copie remplit la texture allouée, quelle que soit la mise à l'échelle du canevas.
    • Réallouez la texture si la taille a changé, comme indiqué ci-dessus.
// 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);
  }
};
  • Dans le contexte WebGPU, utilisez la méthode drawElementImageToTexture. Suivez les règles ci-dessous :
    • Créez la texture avec les utilisations COPY_DST et RENDER_ATTACHMENT, puis ajoutez TEXTURE_BINDING pour l'échantillonner dans les nuanceurs. Une utilisation incorrecte est signalée comme une erreur de validation WebGPU, et non comme une exception.
    • Transmettez la taille de la texture en tant que size. Sans size, un élément plus grand est découpé selon la texture. Avec size, l'élément est redimensionné pour le remplir.
    • Les textures WebGPU ne peuvent pas être redimensionnées. Recréez la texture lorsque la taille de l'élément change, puis recréez les groupes de liaisons qui la référencent.
// 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);
  }
};

Lorsque vous utilisez une boucle requestAnimationFrame pour afficher la scène, appelez canvas.requestPaint() dans la boucle pour vous assurer que le contenu HTML est affiché sur le canevas. event.changedElements liste les éléments dont l'affichage a changé. Veillez à ne réimporter la texture que si les éléments HTML descendants ont été mis à jour :

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. Synchronisez la géométrie de l'élément.

Calculez un DOMMatrix qui mappe la zone de bordure de l'élément, en pixels CSS, au canevas, en pixels CSS. Le navigateur doit mapper l'espace de coordonnées 3D vers l'espace de coordonnées CSS à l'aide d'une transformation de fenêtre d'affichage. Pour ce faire, procédez comme suit :

  • Normalisez l'élément HTML. Les éléments HTML sont dimensionnés en pixels (par exemple, 200 px de large). Cependant, WebGL traite généralement les objets comme des "carrés unitaires", par exemple, allant de -0,5 à 0,5. Si vous ne normalisez pas, votre bouton de 200 px sera 200 fois plus grand. Cette étape inverse également l'axe Y, car en CSS, le bas est positif, mais en WebGL, le haut est positif.
  • Convertissez la matrice MVP WebGL en matrice DOM.
  • Mappez sur la fenêtre d'affichage du canevas. Cette étape correspond à la phase de "redimensionnement" : elle étire à nouveau les calculs de l'espace unitaire pour qu'ils correspondent aux dimensions en pixels CSS de votre élément <canvas> à l'écran, et inverse l'axe Y.
  • Calculez la transformation finale. Multipliez les matrices dans l'ordre : Viewport * MVP * Normalisation. En les combinant en une seule transformation finale, on obtient une "carte" qui indique au navigateur l'emplacement exact de l'élément HTML pour qu'il s'aligne sur le dessin 3D. Le navigateur effectue la division de perspective.

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

Transmettez ensuite la transformation à canvas.updateElementGeometry() en tant que canvasTransform. L'élément HTML est alors placé directement au-dessus de ses pixels affichés. Cela garantit que lorsqu'un utilisateur clique sur un bouton ou sélectionne du texte, il interagit réellement avec l'élément HTML réel. Le navigateur utilise également la transformation pour les limites d'accessibilité de l'élément, par exemple pour les anneaux de sélection du lecteur d'écran :

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

Utilisez ces options pour contrôler les tests de positionnement :

  • Chaque appel updateElementGeometry() déplace l'élément en haut de l'ordre de test de positionnement. Transmettez { preserveHitTestOrder: true } pour conserver sa position.
  • Appelez canvas.clearElementGeometry(element) pour supprimer un élément du test de positionnement, par exemple lorsque vous le masquez.

Three.js

  1. Vérifiez si le navigateur est compatible avec HTML-in-Canvas :

    if ('content' in HTMLCanvasElement.prototype) {
      // Use HTML in Canvas API
    } else {
      // Use fallback strategy
    }
    
  2. Créez une géométrie et un matériau personnalisés pour le contenu HTML.

  3. Transmettez l'élément DOM à THREE.HTMLTexture. Le moteur de rendu ajoute l'élément à son canevas et définit les attributs requis :

      material.map = new THREE.HTMLTexture(element);
      mesh = new THREE.Mesh( geometry, material );
      scene.add( mesh );
    
  4. Enregistrez le maillage avec InteractionManager pour rendre l'élément HTML interactif. Il maintient l'élément aligné sur le maillage et synchronise la géométrie de l'élément avec le canevas, de sorte que les clics et la sélection de texte atteignent l'élément HTML réel. Appelez interactions.update() dans la boucle d'animation, avant l'affichage :

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

Exemple de code

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

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

Bonnes pratiques

  • OBLIGATOIRE : Vérifiez la compatibilité du navigateur avec l'API HTML-in-Canvas avant de l'utiliser.
  • OBLIGATOIRE : Lorsque vous utilisez WebGL ou WebGPU, ajoutez toujours l'attribut content="drawable" à l'élément <canvas>.
  • OBLIGATOIRE : Lorsque vous utilisez WebGL ou WebGPU, ajoutez l'attribut drawable à chaque élément que vous dessinez, y compris les enfants directs du canevas.
  • OBLIGATOIRE : Lorsque vous utilisez WebGL ou WebGPU, utilisez un gestionnaire d'événements onpaint pour afficher le contenu HTML sur le canevas.
  • OBLIGATOIRE : Lorsque vous utilisez WebGL, pré-allouez le tampon de texture avec texImage2D() une fois (lorsqu'il est initialisé ou lorsque la taille de l'élément change). Réallouez la texture uniquement lorsque la taille change et définissez TEXTURE_MIN_FILTER sur LINEAR.
  • OBLIGATOIRE : lorsque vous utilisez WebGL, utilisez la méthode texElementSubImage2D pour afficher le contenu HTML dans une texture que vous préallouez avec texImage2D(), dimensionnée à partir de captureElementImage().
  • OBLIGATOIRE : Lorsque vous utilisez WebGPU, utilisez la méthode drawElementImageToTexture pour afficher le contenu HTML sur le canevas. Définissez la taille de la texture à partir de captureElementImage() et ne la recréez que lorsque la taille change. Créez la texture avec l'utilisation COPY_DST et RENDER_ATTACHMENT, puis transmettez la taille de la texture en tant que size.
  • OBLIGATOIRE : Lorsque vous utilisez WebGL ou WebGPU, synchronisez la géométrie de l'élément HTML avec le contenu affiché en appelant canvas.updateElementGeometry(element, { canvasTransform }).
  • OBLIGATOIRE : Lorsque vous utilisez Three.js, enregistrez le maillage avec InteractionManager et appelez interactions.update() dans la boucle d'animation pour que l'élément HTML reçoive l'entrée.
  • OBLIGATOIRE : Observez la taille de l'écran et modifiez la taille du canevas pour qu'elle corresponde aux pixels de l'appareil, par exemple en utilisant ResizeObserver.
  • NE PAS intégrer de contenu interorigine dans un canevas, car cela n'est pas possible.
  • N'INITIALISEZ PAS ResizeObserver dans le gestionnaire d'événements onpaint, car cela peut entraîner des fuites de mémoire.

Stratégies de remplacement

L'API HTML-in-Canvas n'est actuellement pas compatible avec tous les navigateurs récents. Une stratégie de remplacement est donc généralement nécessaire.

Toutefois, compte tenu des avantages de cette API en termes de performances, il est préférable d'utiliser HTML-in-Canvas si le navigateur le permet.

La stratégie de remplacement dépend du cas d'utilisation. Par exemple, pour un contenu HTML interactif dans un canevas, si le HTML dans un canevas n'est pas pris en charge, placez le contenu HTML au-dessus du canevas à l'aide de CSS.

Polyfill HTML-in-Canvas

Utilisez le script polyfill suivant pour imiter l'API HTML-in-Canvas dans les navigateurs qui ne la prennent pas en charge.

  1. Installez ou intégrez la bibliothèque :

    # Install
    npm install three-html-render
    
    # Embed
    <script src="https://cdn.jsdelivr.net/npm/three-html-render/dist/polyfill.js"></script>
    
  2. Exécutez la méthode installHtmlInCanvasPolyfill() pour traduire du code HTML dans Canvas.