Gli shader WebGL forniscono potenti effetti visivi con accelerazione GPU, consentendo funzionalità avanzate come distorsioni dinamiche delle onde, modelli di illuminazione, correzione cromatica e trasformazioni dei vertici personalizzate. L'API HTML-in-Canvas consente agli sviluppatori di applicare texture WebGL ai contenuti HTML. In questo modo, è possibile applicare in modo nativo shader di frammenti e vertici ad alte prestazioni a componenti UI completamente interattivi, come pulsanti, campi di immissione e RTF, mantenendo l'accessibilità nativa, la selezione del testo e la gestione degli eventi DOM.
Come implementare
Verifica se HTML-in-Canvas è supportato nel browser:
if ('content' in HTMLCanvasElement.prototype) { // Use HTML in Canvas API } else { // Use fallback strategy }Aggiungi l'attributo
content="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);Pre-alloca prima il backing della texture utilizzando il metodo
texImage2D. Gli snippet di questa guida utilizzano un contesto WebGL 2 (canvas.getContext("webgl2")).
Alloca prima lo spazio di archiviazione delle texture chiamando texImage2D() con null anziché i dati dei pixel, in modo da riservare la memoria della GPU delle dimensioni e del formato specificati senza caricare alcun pixel. Segui queste regole:
- Dimensiona la texture da
canvas.captureElementImage(element). I valoriwidtheheightsono espressi in pixel della griglia del canvas (backing store), quindi arrotondali per eccesso conMath.ceil().captureElementImage()genera un errore se l'elemento non ha ancora un record di visualizzazione: chiamarequestPaint()e riprova al successivo eventopaint. - 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.
// 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;
}
// 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 };
Esegui il rendering dei contenuti HTML nella texture all'interno di un gestore di eventi
canvas.onpaintutilizzando il metodotexElementSubImage2D. Segui queste regole:- Un elemento può essere caricato solo dopo che il canvas lo ha disegnato. Carica dall'evento
painte chiamacanvas.requestPaint()per richiederne uno. - 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 nel passaggio 5.
- Un elemento può essere caricato solo dopo che il canvas lo ha disegnato. Carica dall'evento
// 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 (step 5).
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);
}
};
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 with uploadElementWebGL()
}
// Render the scene, and sync the element geometry as shown in step 7
};
- 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. - Passa
{ canvasClip }, unDOMRectInitnello spazio delle coordinate dicanvasTransform, per limitare l'area testabile, ad esempio quando viene disegnata solo una parte dell'elemento.
Esempio di codice
<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() and allocateElementTexture() are defined in step 5.
// uploadElementWebGL() is defined in step 6.
// computeCanvasTransform() is defined in step 7.
// 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>
Best practice
- OBBLIGATORIO: verifica il supporto del browser per l'API HTML-in-Canvas prima di utilizzarla.
- OBBLIGATORIO: aggiungi sempre l'attributo
content="drawable"all'elemento<canvas>. - OBBLIGATORIO: aggiungi SEMPRE l'attributo
drawableagli elementi secondari diretti del canvas. - OBBLIGATORIO: aggiungi l'attributo
drawablea ogni elemento che vuoi poter disegnare indipendentemente dai relativi elementi principali. - OBBLIGATORIO: utilizza un gestore di eventi
onpaintper eseguire il rendering dei contenuti HTML nel canvas. - OBBLIGATORIO: 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: utilizza il metodo
texElementSubImage2Dper eseguire il rendering dei contenuti HTML in una texture preallocata contexImage2D(), con dimensioni dacaptureElementImage(). - OBBLIGATORIO: sincronizza la geometria dell'elemento HTML con i contenuti visualizzati chiamando
canvas.updateElementGeometry(element, { canvasTransform }). - OBBLIGATORIO: utilizza
ResizeObserverper osservare le dimensioni dello schermo e aggiornare le dimensioni del canvas in modo che corrispondano ai pixel del dispositivo. - 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.