Interactieve HTML-content aanzetten in 3D-scènes

Webfuncties: HTML in canvas

Met de HTML-in-Canvas API kun je echte DOM rechtstreeks in een canvaselement renderen. Als je het kenmerk content="drawable" toevoegt aan het canvas en het kenmerk drawable aan de onderliggende HTML-elementen, worden deze elementen naadloos geprojecteerd in de 3D-scène. Dit geldt voor 3D-renderingcontexten zoals WebGL, WebGPU of Three.js. Omdat de HTML-elementen deel blijven uitmaken van de actieve DOM-indeling, behouden ze hun volledige interactiviteit. Gebruikers kunnen op knoppen klikken, tekst selecteren en focusstatussen activeren zonder dat er complexe raycasting of aangepaste gebeurtenisverwerking nodig is.

Hoe wordt dit geïmplementeerd

WebGL en WebGPU

Volg deze stappen als je WebGL of WebGPU gebruikt:

  1. Controleer of HTML-in-Canvas wordt ondersteund in de browser:

    if ('content' in HTMLCanvasElement.prototype) {
      // Use HTML in Canvas API
    } else {
      // Use fallback strategy
    }
    
  2. Initialiseer <canvas> om onderliggende HTML-elementen te ondersteunen door het kenmerk content="drawable" toe te voegen aan het HTML-element <canvas>. Plaats uw HTML-content in het element <canvas> en voeg het kenmerk drawable toe aan elk element dat u tekent. Een element met drawable legt de substructuur vast, behalve geneste onderliggende elementen die ook drawable zijn:

    <canvas id="canvas" content="drawable">
      <div id="html-content" drawable></div>
    </canvas>
    
  3. Schaal je canvasraster zodat het overeenkomt met de schaalfactor van het apparaat om wazigheid te voorkomen:

    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. Geef de HTML-content weer in een textuur in een canvas.onpaint-gebeurtenishandler. Een element kan pas worden geüpload nadat het canvas het heeft getekend: roep canvas.requestPaint() aan om een paint-gebeurtenis aan te vragen.

Pas de grootte van de textuur aan vanaf canvas.captureElementImage(element). De width en height zijn in canvasrasterpixels (backing store), dus rond ze naar boven af met Math.ceil(). captureElementImage() genereert een fout als het element nog geen tekenrecord heeft: roep requestPaint() aan en probeer het opnieuw bij de volgende paint-gebeurtenis.

// 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;
}
  • Wijs in de WebGL-context eerst de textuurbacking vooraf toe met de methode texImage2D. De fragmenten in deze handleiding gebruiken een WebGL 2-context (canvas.getContext("webgl2")). Wijs textuuropslag toe door texImage2D() aan te roepen met null in plaats van pixelgegevens. Hiermee wordt GPU-geheugen van de opgegeven grootte en indeling gereserveerd zonder pixels te uploaden. Volg deze regels:
    • Pas de grootte van de textuur aan vanaf captureElementImage(), zoals hierboven getoond.
    • Wijs niveau 0 alleen toe bij de eerste upload of als de grootte verandert, omdat de textuur wordt gewist als je deze opnieuw toewijst. Gebruik veranderlijke opslag: de grootte van onveranderlijke texStorage2D()-opslag kan niet worden aangepast als de grootte van het element verandert.
// 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 };
  • Geef daarna in de WebGL-context de HTML-content weer in de textuur met de methode texElementSubImage2D. Volg deze regels:
    • Geef { width, height } door in de configuratie. De kopie vult de toegewezen textuur, ongeacht hoe het canvas wordt geschaald.
    • Wijs de textuur opnieuw toe als de grootte is gewijzigd, zoals hierboven getoond.
// 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);
  }
};
  • Gebruik in de WebGPU-context de drawElementImageToTexture-methode. Volg deze regels:
    • Maak de textuur met COPY_DST- en RENDER_ATTACHMENT-gebruik en voeg TEXTURE_BINDING toe om deze te samplen in shaders. Onjuist gebruik wordt gerapporteerd als een WebGPU-validatiefout, niet als een uitzondering.
    • Geef de textuurgrootte door als size. Zonder size wordt een groter element bijgesneden tot de textuur. Met size wordt het element geschaald om het te vullen.
    • Het formaat van WebGPU-texturen kan niet worden aangepast. Maak de textuur opnieuw als de elementgrootte verandert en maak de bindingsgroepen die ernaar verwijzen opnieuw.
// 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);
  }
};

Als je een requestAnimationFrame-lus gebruikt om de scène te renderen, roep je canvas.requestPaint() aan binnen de lus om te zorgen dat de HTML-content wordt gerenderd naar het canvas. event.changedElements vermeldt de elementen waarvan de weergave is gewijzigd. Zorg dat je de textuur alleen opnieuw uploadt als er een update is geweest voor de onderliggende HTML-elementen:

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. Synchroniseer de elementgeometrie.

Bereken een DOMMatrix die het randvak van het element, in CSS-pixels, toewijst aan het canvas, in CSS-pixels. De browser moet de 3D-coördinatenruimte toewijzen aan de CSS-coördinatenruimte met een viewport-transformatie. Doe hiervoor het volgende:

  • Normaliseer het HTML-element. HTML-elementen worden gemeten in pixels (bijvoorbeeld 200 pixels breed). WebGL behandelt objecten meestal als eenheidsvierkanten, bijvoorbeeld van -0,5 tot 0,5. Als je niet normaliseert, ziet je knop van 200 px er 200 keer groter uit. In deze stap wordt ook de Y-as omgedraaid, omdat in CSS omlaag positief is, maar in WebGL omhoog positief is.
  • Zet de WebGL MVP-matrix om in een DOM-matrix.
  • Toewijzen aan het kijkvenster van het canvas. Deze stap is de fase van opnieuw schalen: de wiskunde van de eenheidsruimte wordt weer uitgerekt zodat deze overeenkomt met de CSS-pixelafmetingen van je <canvas>-element op het scherm en de Y-as wordt weer omgedraaid.
  • Bereken de uiteindelijke transformatie. Vermenigvuldig de matrices in de volgorde: Viewport * MVP * Normalization. Als je ze combineert tot één uiteindelijke transformatie, krijg je een 'kaart' die de browser precies vertelt waar dat HTML-element moet staan om uit te lijnen met de 3D-tekening. De browser voert de perspectiefdeling uit.

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

Geef de transformatie daarna door aan canvas.updateElementGeometry() als canvasTransform. Hiermee wordt het HTML-element rechtstreeks boven de gerenderde pixels geplaatst. Zo weet u zeker dat wanneer een gebruiker op een knop klikt of tekst selecteert, deze ook echt het echte HTML-element raakt. De browser gebruikt de transformatie ook voor de toegankelijkheidsgrenzen van het element, bijvoorbeeld voor focusringen van schermlezers:

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

Gebruik deze opties om hit-tests te beheren:

  • Elke updateElementGeometry()-aanroep verplaatst het element naar de bovenkant van de hit-testvolgorde. Kaart { preserveHitTestOrder: true } behoudt de positie.
  • Roep canvas.clearElementGeometry(element) aan om een element uit de hit-test te verwijderen, bijvoorbeeld als je het verbergt.

Three.js

  1. Controleer of HTML-in-Canvas wordt ondersteund in de browser:

    if ('content' in HTMLCanvasElement.prototype) {
      // Use HTML in Canvas API
    } else {
      // Use fallback strategy
    }
    
  2. Maak een aangepaste geometrie en materiaal voor de HTML-content.

  3. Geef het DOM-element door aan THREE.HTMLTexture. De renderer voegt het element toe aan het canvas en stelt de vereiste kenmerken in:

      material.map = new THREE.HTMLTexture(element);
      mesh = new THREE.Mesh( geometry, material );
      scene.add( mesh );
    
  4. Registreer de mesh bij InteractionManager om het HTML-element interactief te maken. Het element blijft uitgelijnd met de mesh en de elementgeometrie wordt gesynchroniseerd met het canvas, zodat klikken en tekstselectie het echte HTML-element raken. Roep interactions.update() aan in de animatielus voordat u gaat renderen:

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

Voorbeeldcode

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

Praktische tips

  • VERPLICHT: Controleer de browserondersteuning voor de HTML-in-Canvas API voordat u deze gebruikt.
  • VERPLICHT: Als je WebGL of WebGPU gebruikt, voeg je altijd het kenmerk content="drawable" toe aan het element <canvas>.
  • VERPLICHT: Als je WebGL of WebGPU gebruikt, voeg je het kenmerk drawable toe aan elk element dat je tekent, waaronder directe onderliggende elementen van het canvas.
  • VERPLICHT: Als je WebGL of WebGPU gebruikt, gebruik je een onpaint-gebeurtenishandler om de HTML-content op het canvas weer te geven.
  • VERPLICHT: Als u WebGL gebruikt, wijst u de textuurbuffer vooraf één keer toe met texImage2D() (wanneer deze wordt geïnitialiseerd of wanneer de elementgrootte verandert). Wijs de textuur alleen opnieuw toe als de grootte verandert en stel TEXTURE_MIN_FILTER in op LINEAR.
  • VERPLICHT: Als je WebGL gebruikt, gebruik je de methode texElementSubImage2D om de HTML-content te renderen naar een textuur die je vooraf toewijst met texImage2D(), met de grootte van captureElementImage().
  • VERPLICHT: Als je WebGPU gebruikt, gebruik je de methode drawElementImageToTexture om de HTML-content weer te geven op het canvas. Pas de grootte van de textuur aan vanuit captureElementImage() en maak deze alleen opnieuw als de grootte verandert. Maak de textuur met COPY_DST- en RENDER_ATTACHMENT-gebruik en geef de textuurgrootte door als size.
  • VERPLICHT: Als u WebGL of WebGPU gebruikt, synchroniseert u de geometrie van het HTML-element met de gerenderde content door canvas.updateElementGeometry(element, { canvasTransform }) aan te roepen.
  • VERPLICHT: Als je Three.js gebruikt, registreer je de mesh met InteractionManager en roep je interactions.update() aan in de animatielus, zodat het HTML-element invoer krijgt.
  • VERPLICHT: Let op de schermgrootte en update de canvasgrootte zodat deze overeenkomt met de apparaatpixels, bijvoorbeeld door ResizeObserver te gebruiken.
  • Sluit GEEN cross-origin content in een canvas in, want dat wordt niet ondersteund.
  • Initialiseer ResizeObserver NIET binnen de gebeurtenishandler onpaint, omdat dit kan leiden tot geheugenlekken.

Reservestrategieën

De HTML-in-Canvas API wordt op dit moment niet in alle moderne browsers ondersteund. Daarom is meestal een reservestrategie vereist.

Vanwege de verbeterde prestaties van deze API moet HTML-in-Canvas worden gebruikt als de browser dit ondersteunt.

De fallback-strategie is afhankelijk van de use case. Als HTML-in-Canvas bijvoorbeeld niet wordt ondersteund voor interactieve HTML-content in canvas, plaatst u de HTML-content boven op het canvas met CSS.

HTML-in-Canvas polyfill

Gebruik het volgende polyfill-script om de HTML-in-Canvas API na te bootsen in browsers die deze niet ondersteunen.

  1. Installeer of sluit de bibliotheek in:

    # Install
    npm install three-html-render
    
    # Embed
    <script src="https://cdn.jsdelivr.net/npm/three-html-render/dist/polyfill.js"></script>
    
  2. Voer de methode installHtmlInCanvasPolyfill() uit om HTML-in-Canvas te vertalen.