Interaktive HTML-Inhalte in 3D-Szenen aktivieren

Webfunktionen: HTML in Canvas

Mit der HTML-in-Canvas API kann echtes DOM direkt in einem Canvas-Element gerendert werden. Wenn Sie das content="drawable"-Attribut auf das Canvas-Element und das drawable-Attribut auf die untergeordneten HTML-Elemente anwenden, können diese Elemente nahtlos in die 3D-Szene projiziert werden. Dies gilt für 3D-Rendering-Kontexte wie WebGL, WebGPU oder Three.js. Da die HTML-Elemente Teil des aktiven DOM-Layoutbaums bleiben, behalten sie ihre volle Interaktivität. Nutzer können auf Schaltflächen klicken, Text auswählen und Fokusstatus nativ auslösen, ohne dass komplexes Raycasting oder benutzerdefinierte Ereignisbehandlung erforderlich sind.

Implementierung

WebGL und WebGPU

Wenn Sie WebGL oder WebGPU verwenden, gehen Sie so vor:

  1. Prüfen Sie, ob HTML-in-Canvas im Browser unterstützt wird:

    if ('content' in HTMLCanvasElement.prototype) {
      // Use HTML in Canvas API
    } else {
      // Use fallback strategy
    }
    
  2. Initialisieren Sie <canvas>, um untergeordnete HTML-Elemente zu unterstützen, indem Sie dem HTML-Element <canvas> das Attribut content="drawable" hinzufügen. Platziere deinen HTML-Inhalt innerhalb des <canvas>-Elements und füge jedem Element, das du zeichnest, das Attribut drawable hinzu. Ein Element mit drawable erfasst seinen Unterbaum, mit Ausnahme von verschachtelten untergeordneten Elementen, die ebenfalls drawable sind:

    <canvas id="canvas" content="drawable">
      <div id="html-content" drawable></div>
    </canvas>
    
  3. Skalieren Sie das Canvas-Raster so, dass es dem Skalierungsfaktor des Geräts entspricht, um Unschärfe zu vermeiden:

    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. Rendern Sie den HTML-Inhalt in einem canvas.onpaint-Ereignis-Handler in eine Textur. Ein Element kann erst hochgeladen werden, nachdem es auf dem Canvas dargestellt wurde. Rufen Sie canvas.requestPaint() auf, um ein paint-Ereignis anzufordern.

Größe der Textur von canvas.captureElementImage(element) festlegen. Die Werte für width und height werden in Pixeln des Zeichenflächenrasters (Backing Store) angegeben. Runden Sie sie daher mit Math.ceil() auf. captureElementImage() löst einen Fehler aus, wenn das Element noch keinen Paint-Eintrag hat: Rufen Sie requestPaint() auf und versuchen Sie es beim nächsten paint-Ereignis noch einmal.

// 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;
}
  • Weisen Sie im WebGL-Kontext zuerst die Texturunterstützung mit der Methode texImage2D vorab zu. Die Snippets in diesem Leitfaden verwenden einen WebGL 2-Kontext (canvas.getContext("webgl2")). Weisen Sie Texturspeicher zu, indem Sie texImage2D() mit null anstelle von Pixeldaten aufrufen. Dadurch wird GPU-Speicher der angegebenen Größe und des angegebenen Formats reserviert, ohne dass Pixel hochgeladen werden. Beachten Sie dabei folgende Regeln:
    • Passen Sie die Größe der Textur von captureElementImage() an, wie oben gezeigt.
    • Weisen Sie Ebene 0 nur beim ersten Upload oder bei einer Größenänderung zu, da die Textur durch die Neuzuweisung gelöscht wird. Veränderlichen Speicher verwenden: Die Größe von unveränderlichem texStorage2D()-Speicher kann nicht geändert werden, wenn sich die Größe des Elements ändert.
// 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 };
  • Rendern Sie dann im WebGL-Kontext den HTML-Inhalt mit der Methode texElementSubImage2D in die Textur. Gehen Sie dabei so vor:
    • Übergeben Sie { width, height } in der Konfiguration. Die Kopie füllt die zugewiesene Textur aus, unabhängig davon, wie der Canvas skaliert wird.
    • Weisen Sie die Textur neu zu, wenn sich die Größe geändert hat, wie oben gezeigt.
// 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);
  }
};
  • Verwenden Sie im WebGPU-Kontext die Methode drawElementImageToTexture. Gehen Sie dabei so vor:
    • Erstelle die Textur mit der Verwendung COPY_DST und RENDER_ATTACHMENT und füge TEXTURE_BINDING hinzu, um sie in Shadern zu sampeln. Eine falsche Verwendung wird als WebGPU-Validierungsfehler und nicht als Ausnahme gemeldet.
    • Übergeben Sie die Texturgröße als size. Ohne size wird ein größeres Element auf die Textur zugeschnitten. Bei size wird das Element so skaliert, dass es den Bereich ausfüllt.
    • Die Größe von WebGPU-Texturen kann nicht geändert werden. Die Textur wird neu erstellt, wenn sich die Elementgröße ändert, und die Bindungsgruppen, die darauf verweisen, werden neu erstellt.
// 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);
  }
};

Wenn Sie eine requestAnimationFrame-Schleife zum Rendern der Szene verwenden, rufen Sie canvas.requestPaint() innerhalb der Schleife auf, damit der HTML-Inhalt auf dem Canvas gerendert wird. event.changedElements listet die Elemente auf, deren Rendering sich geändert hat. Laden Sie die Textur nur dann noch einmal hoch, wenn die untergeordneten HTML-Elemente aktualisiert wurden:

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. Synchronisieren Sie die Elementgeometrie.

Berechnen Sie eine DOMMatrix, die die Rahmenbox des Elements in CSS-Pixeln dem Canvas in CSS-Pixeln zuordnet. Der Browser muss den 3D-Koordinatenraum mithilfe einer Darstellungsbereichstransformation in den CSS-Koordinatenraum umwandeln. Gehen Sie dazu so vor:

  • Normalisieren Sie das HTML-Element. Die Größe von HTML-Elementen wird in Pixeln angegeben (z. B. 200 px breit). WebGL behandelt Objekte jedoch normalerweise als „Einheitsquadrate“, z.B.im Bereich von -0,5 bis 0,5. Wenn Sie die Werte nicht normalisieren, wird die 200 Pixel große Schaltfläche 200-mal größer dargestellt. In diesem Schritt wird auch die Y-Achse umgedreht, da in CSS nach unten positiv ist, in WebGL jedoch nach oben.
  • Konvertieren Sie die WebGL-MVP-Matrix in eine DOM-Matrix.
  • Ordnen Sie die Daten dem Darstellungsbereich des Canvas zu. Dieser Schritt ist die „Neuskalierungsphase“: Die mathematischen Werte des Einheitsraums werden wieder so gestreckt, dass sie den CSS-Pixelabmessungen des <canvas>-Elements auf dem Bildschirm entsprechen, und die Y-Achse wird wieder umgedreht.
  • Berechnen Sie die endgültige Transformation. Multiplizieren Sie die Matrizen in der folgenden Reihenfolge: Darstellungsbereich * MVP * Normalisierung. Wenn Sie sie zu einer endgültigen Transformation kombinieren, entsteht eine „Karte“, die dem Browser genau mitteilt, wo sich das HTML-Element befinden soll, damit es mit der 3D-Zeichnung übereinstimmt. Der Browser führt die perspektivische Teilung durch.

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

Übergeben Sie dann die Transformation als canvasTransform an canvas.updateElementGeometry(). Dadurch wird das HTML-Element direkt über den gerenderten Pixeln platziert. So wird sichergestellt, dass Nutzer, die auf eine Schaltfläche klicken oder Text auswählen, tatsächlich das echte HTML-Element treffen. Der Browser verwendet die Transformation auch für die Grenzen der Barrierefreiheit des Elements, z. B. für Fokusringe von Screenreadern:

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

Mit diesen Optionen können Sie die Trefferprüfung steuern:

  • Mit jedem updateElementGeometry()-Aufruf wird das Element an den Anfang der Trefferprüfungsreihenfolge verschoben. Karte/Ticket „{ preserveHitTestOrder: true }“ behält die Position.
  • Rufen Sie canvas.clearElementGeometry(element) auf, um ein Element aus der Trefferprüfung zu entfernen, z. B. wenn Sie es ausblenden.

Three.js

  1. Prüfen Sie, ob HTML-in-Canvas im Browser unterstützt wird:

    if ('content' in HTMLCanvasElement.prototype) {
      // Use HTML in Canvas API
    } else {
      // Use fallback strategy
    }
    
  2. Erstellen Sie eine benutzerdefinierte Geometrie und ein benutzerdefiniertes Material für den HTML-Inhalt.

  3. Übergeben Sie das DOM-Element an THREE.HTMLTexture. Der Renderer fügt das Element seinem Canvas hinzu und legt die erforderlichen Attribute fest:

      material.map = new THREE.HTMLTexture(element);
      mesh = new THREE.Mesh( geometry, material );
      scene.add( mesh );
    
  4. Registrieren Sie das Mesh mit InteractionManager, um das HTML-Element interaktiv zu machen. Das Element wird am Mesh ausgerichtet und die Elementgeometrie mit dem Canvas synchronisiert, sodass Klicks und Textauswahl das tatsächliche HTML-Element treffen. Rufen Sie interactions.update() in der Animationsschleife vor dem Rendern auf:

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

Beispielcode

WebGL-Canvas

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

WebGPU-Canvas

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

Best Practices

  • OBLIGATORISCH: Prüfen Sie die Browserunterstützung für die HTML-in-Canvas API, bevor Sie sie verwenden.
  • OBLIGATORISCH: Wenn Sie WebGL oder WebGPU verwenden, müssen Sie dem <canvas>-Element immer das Attribut content="drawable" hinzufügen.
  • ERFORDERLICH: Wenn Sie WebGL oder WebGPU verwenden, fügen Sie jedem Element, das Sie zeichnen, das Attribut drawable hinzu, einschließlich direkter untergeordneter Elemente des Canvas.
  • OBLIGATORISCH: Wenn Sie WebGL oder WebGPU verwenden, müssen Sie einen onpaint-Event-Handler verwenden, um den HTML-Inhalt auf dem Canvas zu rendern.
  • OBLIGATORISCH: Wenn Sie WebGL verwenden, weisen Sie dem Texturpuffer einmalig texImage2D() zu (bei der Initialisierung oder wenn sich die Elementgröße ändert). Weisen Sie die Textur nur dann neu zu, wenn sich die Größe ändert, und setzen Sie TEXTURE_MIN_FILTER auf LINEAR.
  • VERPFLICHTEND: Wenn Sie WebGL verwenden, müssen Sie die Methode texElementSubImage2D verwenden, um den HTML-Inhalt in einer Textur zu rendern, die Sie vorab mit texImage2D() zuweisen und die die Größe captureElementImage() hat.
  • VERBINDLICH: Wenn Sie WebGPU verwenden, müssen Sie die Methode drawElementImageToTexture verwenden, um den HTML-Inhalt auf dem Canvas zu rendern. Legen Sie die Größe der Textur über captureElementImage() fest und erstellen Sie sie nur neu, wenn sich die Größe ändert. Erstellen Sie die Textur mit der Verwendung COPY_DST und RENDER_ATTACHMENT und übergeben Sie die Texturgröße als size.
  • OBLIGATORISCH: Wenn Sie WebGL oder WebGPU verwenden, müssen Sie die Geometrie des HTML-Elements mit den gerenderten Inhalten synchronisieren, indem Sie canvas.updateElementGeometry(element, { canvasTransform }) aufrufen.
  • OBLIGATORISCH: Wenn Sie Three.js verwenden, registrieren Sie das Mesh mit InteractionManager und rufen Sie interactions.update() in der Animationsschleife auf, damit das HTML-Element Eingaben empfängt.
  • OBLIGATORISCH: Ermitteln Sie die Bildschirmgröße und aktualisieren Sie die Canvas-Größe entsprechend den Gerätepixeln, z. B. mit ResizeObserver.
  • Betten Sie KEINE ursprungsübergreifenden Inhalte in einen Canvas ein, da dies nicht unterstützt wird.
  • Initialisieren Sie ResizeObserver NICHT im onpaint-Event-Handler, da dies zu Speicherlecks führen kann.

Fallback-Strategien

Die HTML-in-Canvas API wird derzeit nicht in allen modernen Browsern unterstützt. Daher ist in der Regel eine Fallback-Strategie erforderlich.

Aufgrund der verbesserten Leistung dieser API sollte HTML-in-Canvas jedoch verwendet werden, wenn der Browser dies unterstützt.

Die Fallback-Strategie hängt vom Anwendungsfall ab. Wenn HTML-in-Canvas beispielsweise nicht unterstützt wird, platzieren Sie den HTML-Inhalt mithilfe von CSS über dem Canvas.

HTML-in-Canvas-Polyfill

Verwenden Sie das folgende Polyfill-Skript, um die HTML-in-Canvas API in Browsern zu simulieren, die sie nicht unterstützen.

  1. Installieren oder binden Sie die Bibliothek ein:

    # Install
    npm install three-html-render
    
    # Embed
    <script src="https://cdn.jsdelivr.net/npm/three-html-render/dist/polyfill.js"></script>
    
  2. Führen Sie die Methode installHtmlInCanvasPolyfill() aus, um HTML-in-Canvas zu übersetzen.