Włączanie interaktywnych treści HTML w scenach 3D

Funkcje internetowe: HTML w elemencie canvas

Interfejs HTML-in-Canvas API umożliwia renderowanie rzeczywistego modelu DOM bezpośrednio w elemencie canvas. W przypadku kontekstów renderowania 3D, takich jak WebGL, WebGPU czy Three.js, dodanie atrybutu content="drawable" do elementu canvas i atrybutu drawable do jego elementów podrzędnych HTML umożliwia płynne wyświetlanie tych elementów w scenie 3D. Co ważne, ponieważ elementy HTML pozostają częścią aktywnego drzewa układu DOM, zachowują pełną interaktywność – umożliwiają użytkownikom klikanie przycisków, zaznaczanie tekstu i wywoływanie stanów aktywnych w sposób natywny, bez konieczności stosowania złożonego rzutowania promieni czy niestandardowej obsługi zdarzeń.

Jak to zrobić?

WebGL i WebGPU

Jeśli używasz WebGL lub WebGPU, wykonaj te czynności:

  1. Sprawdź, czy przeglądarka obsługuje HTML-in-Canvas:

    if ('content' in HTMLCanvasElement.prototype) {
      // Use HTML in Canvas API
    } else {
      // Use fallback strategy
    }
    
  2. Zainicjuj <canvas>, aby obsługiwać elementy podrzędne HTML, dodając atrybut content="drawable" do elementu HTML <canvas>. Umieść zawartość HTML w elemencie <canvas> i dodaj atrybut drawable do każdego elementu, który rysujesz. Element z atrybutem drawable przechwytuje swoje poddrzewo, z wyjątkiem zagnieżdżonych elementów podrzędnych, które również mają atrybut drawable:

    <canvas id="canvas" content="drawable">
      <div id="html-content" drawable></div>
    </canvas>
    
  3. Aby zapobiec rozmyciu, przeskaluj siatkę obszaru roboczego, aby dopasować ją do współczynnika skalowania urządzenia:

    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. Wyrenderuj zawartość HTML do tekstury w module obsługi zdarzeń canvas.onpaint. Element można przesłać dopiero po jego narysowaniu na obszarze roboczym: wywołaj canvas.requestPaint(), aby poprosić o zdarzenie paint.

Rozmiar tekstury: canvas.captureElementImage(element). Wartości width i height są podane w pikselach siatki obszaru roboczego (magazynu zapasowego), więc zaokrąglij je w górę za pomocą Math.ceil(). captureElementImage() zgłasza błąd, jeśli element nie ma jeszcze rekordu renderowania: wywołaj requestPaint() i spróbuj ponownie przy następnym zdarzeniu 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;
}
  • W kontekście WebGL najpierw wstępnie przydziel podkład tekstury za pomocą metody texImage2D. We fragmentach kodu w tym przewodniku używany jest kontekst WebGL 2 (canvas.getContext("webgl2")). Przydziel miejsce na tekstury, wywołując texImage2D() z null zamiast danych pikseli, co spowoduje zarezerwowanie pamięci GPU o określonym rozmiarze i formacie bez przesyłania pikseli. Przestrzegaj tych zasad:
    • Zmień rozmiar tekstury na captureElementImage(), jak pokazano powyżej.
    • Przydzielaj poziom 0 tylko przy pierwszym przesłaniu lub gdy zmieni się rozmiar, ponieważ ponowne przydzielenie powoduje wyczyszczenie tekstury. Używaj pamięci zmiennej: rozmiaru niezmiennej pamięci texStorage2D() nie można zmienić, gdy zmienia się rozmiar elementu.
// 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 };
  • Następnie w kontekście WebGL wyrenderuj zawartość HTML do tekstury za pomocą metody texElementSubImage2D. Przestrzegaj tych reguł:
    • Przekaż { width, height } w konfiguracji. Sprawia, że kopia wypełnia przydzieloną teksturę niezależnie od tego, jak skalowany jest obszar roboczy.
    • Jeśli rozmiar się zmienił, ponownie przydziel teksturę, jak pokazano powyżej.
// 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);
  }
};
  • W kontekście WebGPU użyj metody drawElementImageToTexture. Przestrzegaj tych zasad:
    • Utwórz teksturę z użyciem COPY_DST i RENDER_ATTACHMENT, a następnie dodaj TEXTURE_BINDING, aby próbkować ją w programach do cieniowania. Nieprawidłowe użycie jest zgłaszane jako błąd weryfikacji WebGPU, a nie jako wyjątek.
    • Przekaż rozmiar tekstury jako size. Bez size większy element jest przycinany do tekstury. W przypadku wartości size element jest skalowany, aby wypełnić obszar.
    • Nie można zmienić rozmiaru tekstur WebGPU. Odtwórz teksturę, gdy zmieni się rozmiar elementu, i ponownie utwórz grupy powiązań, które się do niej odwołują.
// 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);
  }
};

Jeśli do renderowania sceny używasz pętli requestAnimationFrame, wywołaj w niej funkcję canvas.requestPaint(), aby mieć pewność, że treść HTML zostanie wyrenderowana na elemencie canvas. event.changedElements zawiera listę elementów, których renderowanie uległo zmianie. Pamiętaj, aby ponownie przesłać teksturę tylko wtedy, gdy nastąpiła aktualizacja elementów HTML potomnych:

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. Zsynchronizuj geometrię elementu.

Oblicz DOMMatrix, który mapuje obramowanie elementu w pikselach CSS na obszar roboczy w pikselach CSS. Przeglądarka musi zmapować przestrzeń współrzędnych 3D na przestrzeń współrzędnych CSS za pomocą przekształcenia widocznego obszaru. Aby to zrobić:

  • Znormalizuj element HTML. Rozmiary elementów HTML są podawane w pikselach (np. szerokość 200 pikseli). WebGL zwykle traktuje obiekty jako „kwadraty jednostkowe”, na przykład w zakresie od -0,5 do 0,5. Jeśli nie znormalizujesz, przycisk o rozmiarze 200 pikseli będzie wyglądał na 200 razy większy. Ten krok odwraca też oś Y, ponieważ w CSS kierunek w dół jest dodatni, a w WebGL – w górę.
  • Przekształć macierz MVP WebGL w macierz DOM.
  • Mapowanie na widoczny obszar obszaru roboczego. Ten krok to faza „ponownego skalowania”: rozciąga matematykę przestrzeni jednostkowej z powrotem, aby dopasować ją do wymiarów pikseli CSS elementu <canvas> na ekranie, i odwraca oś Y.
  • Oblicz ostateczne przekształcenie. Pomnóż macierze w kolejności: widoczny obszar * MVP * normalizacja. Połączenie ich w jedną ostateczną transformację tworzy „mapę”, która dokładnie wskazuje przeglądarce, gdzie powinien znajdować się element HTML, aby był wyrównany z rysunkiem 3D. Przeglądarka wykonuje podział perspektywy.

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

Następnie przekaż przekształcenie do canvas.updateElementGeometry() jako canvasTransform. Spowoduje to przeniesienie elementu HTML bezpośrednio na wyrenderowane piksele. Dzięki temu, gdy użytkownik kliknie przycisk lub zaznaczy tekst, faktycznie trafi na rzeczywisty element HTML. Przeglądarka używa też przekształcenia do określenia granic ułatwień dostępu elementu, na przykład w przypadku pierścieni zaznaczenia czytnika ekranu:

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

Do kontrolowania testu pozycji wskaźnika służą te opcje:

  • Każde wywołanie updateElementGeometry() przenosi element na górę kolejności testu pozycji wskaźnika. Przekaż { preserveHitTestOrder: true }, aby utrzymać pozycję.
  • Wywołaj canvas.clearElementGeometry(element), aby usunąć element z testu pozycji wskaźnika, np. gdy go ukryjesz.

Three.js

  1. Sprawdź, czy przeglądarka obsługuje HTML-in-Canvas:

    if ('content' in HTMLCanvasElement.prototype) {
      // Use HTML in Canvas API
    } else {
      // Use fallback strategy
    }
    
  2. Utwórz niestandardową geometrię i materiał dla treści HTML.

  3. Przekaż element DOM do THREE.HTMLTexture. Moduł renderujący dodaje element do obszaru roboczego i ustawia wymagane atrybuty:

      material.map = new THREE.HTMLTexture(element);
      mesh = new THREE.Mesh( geometry, material );
      scene.add( mesh );
    
  4. Zarejestruj siatkę za pomocą InteractionManager, aby element HTML był interaktywny. Utrzymuje element wyrównany z siatką i synchronizuje geometrię elementu z obszarem roboczym, dzięki czemu kliknięcia i zaznaczenie tekstu trafiają w rzeczywisty element HTML. Wywołaj interactions.update() w pętli animacji przed renderowaniem:

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

Przykładowy kod

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

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

Sprawdzone metody

  • OBOWIĄZKOWO: przed użyciem interfejsu HTML-in-Canvas API sprawdź, czy przeglądarka go obsługuje.
  • OBOWIĄZKOWO: gdy używasz WebGL lub WebGPU, zawsze dodawaj atrybut content="drawable" do elementu <canvas>.
  • OBOWIĄZKOWO: jeśli używasz WebGL lub WebGPU, dodaj atrybut drawable do każdego elementu, który rysujesz, w tym do elementów podrzędnych obszaru roboczego.
  • OBOWIĄZKOWO: podczas korzystania z WebGL lub WebGPU używaj modułu obsługi zdarzeń onpaint do renderowania zawartości HTML na elemencie canvas.
  • OBOWIĄZKOWO: podczas korzystania z WebGL wstępnie przydziel bufor tekstury za pomocą texImage2D() (podczas inicjowania lub zmiany rozmiaru elementu). Zmieniaj przydział tekstury tylko wtedy, gdy zmienia się rozmiar, i ustaw TEXTURE_MIN_FILTER na LINEAR.
  • OBOWIĄZKOWO: podczas korzystania z WebGL używaj metody texElementSubImage2D do renderowania treści HTML do tekstury, którą wstępnie przydzielasz za pomocą texImage2D(), o rozmiarze od captureElementImage().
  • OBOWIĄZKOWO: jeśli używasz WebGPU, do renderowania treści HTML na elemencie canvas używaj metody drawElementImageToTexture. Ustaw rozmiar tekstury na captureElementImage() i twórz ją ponownie tylko wtedy, gdy rozmiar się zmieni. Utwórz teksturę z użyciem COPY_DST i RENDER_ATTACHMENT, a następnie przekaż rozmiar tekstury jako size.
  • OBOWIĄZKOWO: jeśli używasz WebGL lub WebGPU, zsynchronizuj geometrię elementu HTML z renderowaną treścią, wywołując canvas.updateElementGeometry(element, { canvasTransform }).
  • OBOWIĄZKOWE: jeśli używasz Three.js, zarejestruj siatkę za pomocą InteractionManager i wywołaj interactions.update() w pętli animacji, aby element HTML otrzymywał dane wejściowe.
  • OBOWIĄZKOWO: sprawdź rozmiar ekranu i zaktualizuj rozmiar obszaru roboczego, aby odpowiadał pikselom urządzenia, np. za pomocą ResizeObserver.
  • NIE umieszczaj treści z innych domen w obszarze roboczym, ponieważ nie jest to obsługiwane.
  • NIE inicjuj ResizeObserver w module obsługi zdarzeń onpaint, ponieważ może to prowadzić do wycieków pamięci.

Strategie zastępcze

Interfejs HTML-in-Canvas API nie jest obecnie obsługiwany we wszystkich nowoczesnych przeglądarkach, dlatego zwykle wymagana jest strategia zastępcza.

Jednak ze względu na lepszą wydajność tego interfejsu API należy używać HTML-in-Canvas, jeśli przeglądarka go obsługuje.

Strategia zastępcza zależy od przypadku użycia. Jeśli na przykład interaktywna treść HTML w elemencie canvas nie jest obsługiwana, umieść ją nad elementem canvas za pomocą CSS.

kod polyfill HTML-in-Canvas

Użyj poniższego skryptu polyfill, aby naśladować interfejs HTML-in-Canvas API w przeglądarkach, które go nie obsługują.

  1. Zainstaluj lub umieść bibliotekę:

    # Install
    npm install three-html-render
    
    # Embed
    <script src="https://cdn.jsdelivr.net/npm/three-html-render/dist/polyfill.js"></script>
    
  2. Uruchom metodę installHtmlInCanvasPolyfill(), aby przetłumaczyć HTML-in-Canvas.