L'API HTML-in-Canvas consente di eseguire il rendering del DOM reale direttamente all'interno di un elemento canvas. Se applicato a contesti di rendering 3D come WebGL, WebGPU o Three.js, l'aggiunta dell'attributo content="drawable" al canvas e dell'attributo drawable ai relativi elementi HTML discendenti consente di proiettare questi elementi senza problemi nella scena 3D. Inoltre, poiché gli elementi HTML rimangono parte dell'albero di layout DOM attivo, mantengono la piena interattività, consentendo agli utenti di fare clic sui pulsanti, selezionare il testo e attivare gli stati di attivazione in modo nativo senza richiedere raycasting complessi o gestione degli eventi personalizzata.
Come implementare
WebGL e WebGPU
Quando utilizzi WebGL o WebGPU, segui questi passaggi:
Verifica se HTML-in-Canvas è supportato nel browser:
if ('content' in HTMLCanvasElement.prototype) { // Use HTML in Canvas API } else { // Use fallback strategy }Inizializza
<canvas>per supportare gli elementi HTML discendenti aggiungendo l'attributocontent="drawable"all'elemento HTML<canvas>. Inserisci i contenuti HTML all'interno dell'elemento<canvas>e aggiungi l'attributodrawablea ogni elemento che disegni. Un elemento condrawableacquisisce il relativo sottoalbero, ad eccezione dei discendenti nidificati che sono anchedrawable:<canvas id="canvas" content="drawable"> <div id="html-content" drawable></div> </canvas>Scala la griglia del canvas in modo che corrisponda al fattore di scalabilità del dispositivo per evitare sfocature:
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);Esegui il rendering dei contenuti HTML in una texture all'interno di un gestore di eventi
canvas.onpaint. Un elemento può essere caricato solo dopo che il canvas lo ha disegnato: chiamacanvas.requestPaint()per richiedere un eventopaint.
Dimensiona la texture da canvas.captureElementImage(element). I valori width e height sono espressi in pixel della griglia del canvas (backing store), quindi arrotondali per eccesso con Math.ceil(). captureElementImage() genera un errore se l'elemento non ha ancora un record di visualizzazione: chiama requestPaint() e riprova al successivo 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;
}
- Nel contesto WebGL, prealloca prima il supporto della texture utilizzando il metodo
texImage2D. Gli snippet di questa guida utilizzano un contesto WebGL 2 (canvas.getContext("webgl2")). Alloca lo spazio di archiviazione delle texture chiamandotexImage2D()connullanziché con i dati dei pixel, in modo da riservare la memoria GPU delle dimensioni e del formato specificati senza caricare alcun pixel. Segui queste regole:- Ridimensiona la texture da
captureElementImage(), come mostrato sopra. - Alloca il livello 0 solo al primo caricamento o quando le dimensioni cambiano, perché la riallocazione cancella la texture. Utilizza lo spazio di archiviazione modificabile: lo spazio di archiviazione
texStorage2D()immutabile non può essere ridimensionato quando l'elemento cambia dimensione.
- Ridimensiona la texture da
// 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 };
- Poi, nel contesto WebGL, esegui il rendering dei contenuti HTML nella texture utilizzando il metodo
texElementSubImage2D. Segui queste regole:- Passa
{ width, height }nella configurazione. In questo modo, la copia riempie la texture allocata indipendentemente dalla scalabilità dell'area di lavoro. - Riassegna la texture se le dimensioni sono cambiate, come mostrato sopra.
- Passa
// 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);
}
};
- Nel contesto WebGPU, utilizza il metodo
drawElementImageToTexture. Segui queste regole:- Crea la texture con l'utilizzo di
COPY_DSTeRENDER_ATTACHMENTe aggiungiTEXTURE_BINDINGper campionarla negli shader. Un utilizzo errato viene segnalato come errore di convalida WebGPU, non come eccezione. - Passa le dimensioni della texture come
size. Senzasize, un elemento più grande viene ritagliato in base alla texture. Consize, l'elemento viene ridimensionato per riempirlo. - Le texture WebGPU non possono essere ridimensionate. Ricrea la texture quando le dimensioni dell'elemento cambiano e ricrea i gruppi di associazione che vi fanno riferimento.
- Crea la texture con l'utilizzo di
// 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);
}
};
Quando utilizzi un loop requestAnimationFrame per eseguire il rendering della scena, chiama canvas.requestPaint() all'interno del loop per assicurarti che il rendering dei contenuti HTML venga eseguito nel canvas. event.changedElements elenca gli elementi il cui rendering è cambiato. Assicurati di ricaricare la texture solo se è stato eseguito un aggiornamento degli elementi HTML discendenti:
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
};
- Sincronizza la geometria dell'elemento.
Calcola un DOMMatrix che mappa il bordo dell'elemento, in pixel CSS, al canvas, in pixel CSS. Il browser deve mappare lo spazio delle coordinate 3D nello spazio delle coordinate CSS utilizzando una trasformazione dell'area visibile. Per facilitare questa operazione, segui questi passaggi:
- Normalizza l'elemento HTML. Le dimensioni degli elementi HTML sono espresse in pixel (ad esempio, 200 px di larghezza). WebGL, tuttavia, di solito tratta gli oggetti come "quadrati unitari", ad esempio con un intervallo compreso tra -0,5 e 0,5. Se non normalizzi, il pulsante da 200 px apparirà 200 volte più grande. Questo passaggio inverte anche l'asse Y, perché in CSS il valore positivo è verso il basso, mentre in WebGL è verso l'alto.
- Converti la matrice MVP WebGL in una matrice DOM.
- Mappa all'area visibile del canvas. Questo passaggio è la fase di "ridimensionamento": estende nuovamente la matematica dello spazio unitario per adattarla alle dimensioni in pixel CSS dell'elemento
<canvas>sullo schermo e capovolge l'asse Y. Calcola la trasformazione finale. Moltiplica le matrici nell'ordine: Viewport * MVP * Normalization. La loro combinazione in un'unica trasformazione finale produce una "mappa" che indica al browser esattamente dove deve trovarsi l'elemento HTML per allinearsi al disegno 3D. Il browser esegue la divisione prospettica.
// 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); }
Poi, passa la trasformazione a canvas.updateElementGeometry() come canvasTransform. In questo modo, l'elemento HTML viene posizionato direttamente sopra i pixel visualizzati. In questo modo, quando un utente fa clic su un pulsante o seleziona un testo, interagisce effettivamente con l'elemento HTML reale. Il browser utilizza la trasformazione anche per i limiti di accessibilità dell'elemento, ad esempio per gli anelli di messa a fuoco dello screen reader:
canvas.updateElementGeometry(targetHTMLElement, {
canvasTransform: computeCanvasTransform(canvas, targetHTMLElement, htmlElementMVP),
});
Utilizza queste opzioni per controllare l'hit test:
- Ogni chiamata
updateElementGeometry()sposta l'elemento in cima all'ordine di hit test. Passa{ preserveHitTestOrder: true }per mantenere la sua posizione. - Chiama
canvas.clearElementGeometry(element)per rimuovere un elemento dall'hit test, ad esempio quando lo nascondi.
Three.js
Verifica se HTML-in-Canvas è supportato nel browser:
if ('content' in HTMLCanvasElement.prototype) { // Use HTML in Canvas API } else { // Use fallback strategy }Crea una geometria e un materiale personalizzati per i contenuti HTML.
Passa l'elemento DOM a THREE.HTMLTexture. Il renderer aggiunge l'elemento al canvas e imposta gli attributi richiesti:
material.map = new THREE.HTMLTexture(element); mesh = new THREE.Mesh( geometry, material ); scene.add( mesh );Registra la mesh con
InteractionManagerper rendere interattivo l'elemento HTML. Mantiene l'elemento allineato alla mesh e sincronizza la geometria dell'elemento con il canvas, in modo che i clic e la selezione del testo colpiscano l'elemento HTML reale. Chiamainteractions.update()nel loop di animazione, prima del rendering:
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 );
Esempio di codice
Canvas 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>
Canvas 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);
}
Best practice
- OBBLIGATORIO: verifica il supporto del browser per l'API HTML-in-Canvas prima di utilizzarla.
- OBBLIGATORIO: quando utilizzi WebGL o WebGPU, aggiungi sempre l'attributo
content="drawable"all'elemento<canvas>. - OBBLIGATORIO: quando utilizzi WebGL o WebGPU, aggiungi l'attributo
drawablea ogni elemento che disegni, inclusi gli elementi secondari diretti del canvas. - OBBLIGATORIO: quando utilizzi WebGL o WebGPU, utilizza un gestore di eventi
onpaintper eseguire il rendering dei contenuti HTML nel canvas. - OBBLIGATORIO: quando utilizzi WebGL, prealloca il buffer della texture con
texImage2D()una volta (quando viene inizializzato o quando cambiano le dimensioni dell'elemento). Riassegna la texture solo quando le dimensioni cambiano e impostaTEXTURE_MIN_FILTERsuLINEAR. - OBBLIGATORIO: quando utilizzi WebGL, usa il metodo
texElementSubImage2Dper eseguire il rendering dei contenuti HTML in una texture preallocata contexImage2D(), con dimensioni dacaptureElementImage(). - OBBLIGATORIO: quando utilizzi WebGPU, usa il metodo
drawElementImageToTextureper eseguire il rendering dei contenuti HTML nel canvas. Dimensiona la texture dacaptureElementImage()e ricreala solo quando le dimensioni cambiano. Crea la texture con l'utilizzo diCOPY_DSTeRENDER_ATTACHMENTe passa la dimensione della texture comesize. - OBBLIGATORIO: quando utilizzi WebGL o WebGPU, sincronizza la geometria dell'elemento HTML con i contenuti visualizzati chiamando
canvas.updateElementGeometry(element, { canvasTransform }). - OBBLIGATORIO: quando utilizzi Three.js, registra la mesh con
InteractionManagere chiamainteractions.update()nel loop di animazione, in modo che l'elemento HTML riceva l'input. - OBBLIGATORIO: osserva le dimensioni dello schermo e aggiorna le dimensioni del canvas in modo che corrispondano ai pixel del dispositivo, ad esempio utilizzando
ResizeObserver. - NON incorporare contenuti multiorigine in un canvas, poiché non sono supportati.
- NON inizializzare
ResizeObserverall'interno del gestore di eventionpaint, in quanto ciò potrebbe causare perdite di memoria.
Strategie di riserva
L'API HTML-in-Canvas non è attualmente supportata in tutti i browser moderni, pertanto in genere è necessaria una strategia di riserva.
Tuttavia, dati i vantaggi in termini di prestazioni di questa API, è consigliabile utilizzare HTML-in-Canvas se il browser lo supporta.
La strategia di riserva dipende dal caso d'uso. Ad esempio, per un contenuto HTML interattivo in un canvas, se l'HTML nel canvas non è supportato, posiziona il contenuto HTML sopra il canvas utilizzando CSS.
Polyfill HTML-in-Canvas
Utilizza il seguente script polyfill per simulare l'API HTML-in-Canvas nei browser che non la supportano.
Installa o incorpora la libreria:
# Install npm install three-html-render# Embed <script src="https://cdn.jsdelivr.net/npm/three-html-render/dist/polyfill.js"></script>Esegui il metodo
installHtmlInCanvasPolyfill()per tradurre l'HTML in Canvas.