HTML-in-Canvas API מאפשר להציג DOM אמיתי ישירות בתוך רכיב קנבס. כשמחילים את המאפיין content="drawable" על הקנבס ואת המאפיין drawable על רכיבי ה-HTML הצאצאים שלו בהקשרים של רינדור תלת-ממדי כמו WebGL, WebGPU או Three.js, הרכיבים האלה מוצגים בצורה חלקה בסצנה התלת-ממדית. חשוב לציין שמכיוון שרכיבי ה-HTML נשארים חלק מעץ הפריסה הפעיל של ה-DOM, הם שומרים על אינטראקטיביות מלאה. כך המשתמשים יכולים ללחוץ על כפתורים, לבחור טקסט ולהפעיל מצבי התמקדות באופן מקורי, בלי צורך בהטלת קרניים (raycasting) מורכבת או בטיפול באירועים בהתאמה אישית.
איך מטמיעים
WebGL ו-WebGPU
כשמשתמשים ב-WebGL או ב-WebGPU, פועלים לפי השלבים הבאים:
בודקים אם הדפדפן תומך ב-HTML-in-Canvas:
if ('content' in HTMLCanvasElement.prototype) { // Use HTML in Canvas API } else { // Use fallback strategy }מאתחלים את
<canvas>כדי לתמוך ברכיבי HTML צאצאים על ידי הוספת המאפייןcontent="drawable"לרכיב ה-HTML<canvas>. ממקמים את תוכן ה-HTML בתוך הרכיב<canvas>, ומוסיפים את המאפייןdrawableלכל רכיב שמשרטטים. רכיב עםdrawableמתעד את עץ המשנה שלו, חוץ מצאצאים מקוננים שהם גםdrawable:<canvas id="canvas" content="drawable"> <div id="html-content" drawable></div> </canvas>כדי למנוע טשטוש, משנים את קנה המידה של רשת לוח הציור כך שיתאים לגורם לקביעת קנה המידה של המכשיר:
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);מעבדים את תוכן ה-HTML למרקם בתוך גורם מטפל באירועים מסוג
canvas.onpaint. אפשר להעלות רכיב רק אחרי שהקנבס צייר אותו: צריך להפעיל אתcanvas.requestPaint()כדי לבקש אירועpaint.
משנים את גודל המרקם מ-canvas.captureElementImage(element). הערכים של width ו-height הם בפיקסלים של רשת ה-canvas (מאגר הגיבוי), אז צריך לעגל אותם כלפי מעלה באמצעות Math.ceil(). captureElementImage() זורק אם לאלמנט אין עדיין רשומת צביעה: קוראים ל-requestPaint() ומנסים שוב באירוע 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;
}
- בהקשר של WebGL, קודם צריך להקצות מראש את הגיבוי של הטקסטורה באמצעות השיטה
texImage2D. קטעי הקוד במדריך הזה משתמשים בהקשר של WebGL 2 (canvas.getContext("webgl2")). כדי להקצות נפח אחסון לטקסטורות, צריך להפעיל אתtexImage2D()עםnullבמקום נתוני פיקסלים. הפעולה הזו שומרת זיכרון GPU בגודל ובפורמט הנתונים בלי להעלות פיקסלים. פועלים לפי הכללים הבאים:- משנים את גודל המרקם מ-
captureElementImage(), כמו שמוצג למעלה. - צריך להקצות את רמה 0 רק בהעלאה הראשונה או כשהגודל משתנה, כי הקצאה מחדש מנקה את הטקסטורה. שימוש באחסון שניתן לשינוי: אי אפשר לשנות את הגודל של אחסון
texStorage2D()שלא ניתן לשינוי כשהגודל של הרכיב משתנה.
- משנים את גודל המרקם מ-
// 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 };
- לאחר מכן, בהקשר של WebGL, מעבדים את תוכן ה-HTML למרקם באמצעות השיטה
texElementSubImage2D. פועלים לפי הכללים הבאים:- מעבירים את
{ width, height }בהגדרה. הוא גורם לעותק למלא את הטקסטורה שהוקצתה, בלי קשר לקנה המידה של ה-canvas. - אם הגודל השתנה, מקצים מחדש את הטקסטורה, כמו שמוצג למעלה.
- מעבירים את
// 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);
}
};
- בהקשר של WebGPU, משתמשים ב-
drawElementImageToTexture. פועלים לפי הכללים הבאים:- יוצרים את הטקסטורה עם שימוש ב-
COPY_DSTוב-RENDER_ATTACHMENT, ומוסיפים אתTEXTURE_BINDINGכדי לדגום אותה ב-shaders. שימוש שגוי מדווח כשגיאת אימות של WebGPU, ולא כחריגה. - מעבירים את גודל המרקם בתור
size. בליsize, רכיב גדול יותר נחתך לפי המרקם. עםsize, הרכיב מותאם למילוי. - אי אפשר לשנות את הגודל של טקסטורות ב-WebGPU. יוצרים מחדש את המרקם כשגודל הרכיב משתנה, ובונים מחדש את קבוצות הקישור שמפנות אליו.
- יוצרים את הטקסטורה עם שימוש ב-
// 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);
}
};
כשמשתמשים בלולאת requestAnimationFrame כדי לעבד את הסצנה, צריך להפעיל את canvas.requestPaint() בתוך הלולאה כדי לוודא שתוכן ה-HTML מעובד ב-canvas. event.changedElements מציג את הרכיבים שהעיבוד שלהם השתנה. חשוב לוודא שאתם מעלים מחדש את הטקסטורה רק אם חל עדכון ברכיבי ה-HTML הצאצאים:
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
};
- מסנכרנים את הגיאומטריה של הרכיב.
מחשבים DOMMatrix שממפה את תיבת הגבול של הרכיב, בפיקסלים של CSS, ל-canvas, בפיקסלים של CSS. הדפדפן צריך למפות את מרחב הקואורדינטות התלת-ממדי למרחב הקואורדינטות של ה-CSS באמצעות טרנספורמציה של אזור התצוגה. כדי לעשות את זה:
- מנרמלים את רכיב ה-HTML. הגודל של רכיבי HTML נמדד בפיקסלים (לדוגמה, רוחב של 200 פיקסלים). לעומת זאת, ב-WebGL בדרך כלל מתייחסים לאובייקטים כאל "ריבועי יחידה", למשל, בטווח שבין 0.5- ל-0.5. אם לא תבצעו נרמול, כפתור בגודל 200 פיקסלים ייראה גדול פי 200. בשלב הזה גם הופכים את ציר ה-Y, כי ב-CSS, הכיוון למטה הוא חיובי, אבל ב-WebGL, הכיוון למעלה הוא חיובי.
- ממירים את מטריצת ה-MVP של WebGL למטריצת DOM.
- מיפוי לאזור התצוגה של אזור העריכה. השלב הזה הוא שלב "שינוי קנה המידה": הוא מותח את המתמטיקה של מרחב היחידה בחזרה כדי להתאים לממדי הפיקסלים של CSS של רכיב
<canvas>במסך, והופך את ציר ה-Y בחזרה. מחשבים את הטרנספורמציה הסופית. מכפילים את המטריצות לפי הסדר: Viewport * MVP * Normalization. שילוב של כל הטרנספורמציות האלה לטרנספורמציה סופית אחת יוצר "מפה" שאומרת לדפדפן בדיוק איפה רכיב ה-HTML צריך להיות כדי להתאים לשרטוט התלת-ממדי. הדפדפן מבצע את חלוקת הפרספקטיבה.
// 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); }
אחר כך מעבירים את הטרנספורמציה אל canvas.updateElementGeometry() בתור canvasTransform. הפעולה הזו מעבירה את רכיב ה-HTML כך שהוא יושב ישירות מעל הפיקסלים שעברו עיבוד. כך אפשר לוודא שכאשר משתמש לוחץ על לחצן או בוחר טקסט, הוא באמת לוחץ על רכיב ה-HTML האמיתי. הדפדפן משתמש בטרנספורמציה גם לגבולות הנגישות של הרכיב, למשל, לטבעות המיקוד של קורא המסך:
canvas.updateElementGeometry(targetHTMLElement, {
canvasTransform: computeCanvasTransform(canvas, targetHTMLElement, htmlElementMVP),
});
אפשר להשתמש באפשרויות הבאות כדי לשלוט בבדיקה של מיקום המצביע:
- כל קריאה ל-
updateElementGeometry()מעבירה את הרכיב לראש הסדר של בדיקת מיקום המצביע. צריך להעביר את{ preserveHitTestOrder: true }כדי לשמור על המיקום שלו. - מפעילים את
canvas.clearElementGeometry(element)כדי להסיר רכיב מבדיקת מיקום המצביע, למשל כשמסתירים אותו.
Three.js
בודקים אם הדפדפן תומך ב-HTML-in-Canvas:
if ('content' in HTMLCanvasElement.prototype) { // Use HTML in Canvas API } else { // Use fallback strategy }יוצרים גיאומטריה וחומר מותאמים אישית לתוכן ה-HTML.
מעבירים את רכיב ה-DOM אל THREE.HTMLTexture. כלי העיבוד מוסיף את הרכיב לאזור העריכה שלו ומגדיר את המאפיינים הנדרשים:
material.map = new THREE.HTMLTexture(element); mesh = new THREE.Mesh( geometry, material ); scene.add( mesh );רושמים את הרשת באמצעות
InteractionManagerכדי להפוך את רכיב ה-HTML לאינטראקטיבי. הוא שומר על הרכיב מיושר עם הרשת, ומסנכרן את הגיאומטריה של הרכיב עם אזור העריכה, כך שהקליקים ובחירת הטקסט פוגעים ברכיב ה-HTML האמיתי. קוראים ל-interactions.update()בלולאת האנימציה, לפני הרינדור:
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 );
קוד לדוגמה
אזור עריכה של 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>
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);
}
שיטות מומלצות
- חובה: לפני שמשתמשים ב-HTML-in-Canvas API, צריך לבדוק אם הדפדפן תומך בו.
- חובה: כשמשתמשים ב-WebGL או ב-WebGPU, תמיד צריך להוסיף את המאפיין
content="drawable"לרכיב<canvas>. - חובה: כשמשתמשים ב-WebGL או ב-WebGPU, צריך להוסיף את המאפיין
drawableלכל רכיב שמצויר, כולל רכיבים צאצאים ישירים של ה-canvas. - חובה: כשמשתמשים ב-WebGL או ב-WebGPU, צריך להשתמש בגורם מטפל באירועים מסוג
onpaintכדי לעבד את תוכן ה-HTML ל-canvas. - חובה: כשמשתמשים ב-WebGL, צריך להקצות מראש את מאגר הטקסטורות באמצעות
texImage2D()פעם אחת (כשהוא מאותחל או כשגודל הרכיב משתנה). מקצים מחדש את הטקסטורה רק כשהגודל משתנה, ומגדירים אתTEXTURE_MIN_FILTERל-LINEAR. - חובה: כשמשתמשים ב-WebGL, צריך להשתמש ב-method
texElementSubImage2Dכדי לעבד את תוכן ה-HTML למרקם שאתם מקצים מראש באמצעותtexImage2D(), בגודל מ-captureElementImage(). - חובה: כשמשתמשים ב-WebGPU, צריך להשתמש בשיטה
drawElementImageToTextureכדי לעבד את תוכן ה-HTML לאלמנט ה-canvas. צריך לקבוע את גודל הטקסטורה מ-captureElementImage(), וליצור אותה מחדש רק כשהגודל משתנה. יוצרים את הטקסטורה עם שימוש ב-COPY_DSTוב-RENDER_ATTACHMENT, ומעבירים את גודל הטקסטורה בתורsize. - חובה: כשמשתמשים ב-WebGL או ב-WebGPU, צריך לסנכרן את הגיאומטריה של רכיב ה-HTML עם התוכן שעבר רינדור באמצעות קריאה ל-
canvas.updateElementGeometry(element, { canvasTransform }). - חובה: כשמשתמשים ב-Three.js, צריך לרשום את הרשת באמצעות
InteractionManager, ולקרוא ל-interactions.update()בלולאת האנימציה, כדי שרכיב ה-HTML יקבל קלט. - חובה: צריך לבדוק את גודל המסך ולעדכן את גודל אזור העריכה כך שיתאים לפיקסלים של המכשיר, למשל באמצעות
ResizeObserver. - אל תטמיעו תוכן ממקורות שונים ב-Canvas, כי אין תמיכה בכך.
- אל תפעילו את
ResizeObserverבתוך הגורם המטפל באירועיםonpaint, כי זה עלול לגרום לדליפות זיכרון.
אסטרטגיות חלופיות
ה-API של HTML-in-Canvas לא נתמך כרגע בכל הדפדפנים המודרניים, ולכן בדרך כלל נדרשת אסטרטגיית גיבוי.
עם זאת, לאור היתרונות של ה-API הזה בשיפור הביצועים, כדאי להשתמש ב-HTML-in-Canvas אם הדפדפן תומך בו.
אסטרטגיית ברירת המחדל תלויה בתרחיש לדוגמה. לדוגמה, אם אתם רוצים להציג תוכן HTML אינטראקטיבי ב-canvas, אבל אין תמיכה ב-HTML-in-Canvas, אתם יכולים להציב את תוכן ה-HTML מעל ה-canvas באמצעות CSS.
HTML-in-Canvas polyfill
כדי לדמות את ה-API של HTML-in-Canvas בדפדפנים שלא תומכים בו, צריך להשתמש בסקריפט ה-polyfill הבא.
מתקינים או מטמיעים את הספרייה:
# Install npm install three-html-render# Embed <script src="https://cdn.jsdelivr.net/npm/three-html-render/dist/polyfill.js"></script>מריצים את השיטה
installHtmlInCanvasPolyfill()כדי לתרגם HTML-in-Canvas.