HTML-in-Canvas API امکان ارائه DOM واقعی را مستقیماً در عنصر بوم فراهم میکند. وقتی در زمینههای پردازشی سهبعدی مانند WebGL، WebGPU، یا Three.js اعمال میشود، افزودن مشخصه content="drawable" به بوم و مشخصه drawable به عناصر HTML فرزند آن، امکان میدهد این عناصر بهطور یکپارچه در صحنه سهبعدی نمایش داده شوند. نکته مهم این است که چون عناصر HTML همچنان بخشی از درخت چیدمان DOM فعال هستند، تعاملپذیری کامل خود را حفظ میکنند و به کاربران اجازه میدهند تا بدون نیاز به پرتوتابی پیچیده یا مدیریت رویداد سفارشی، بهطور بومی روی دکمهها کلیک کنند، نوشتار را انتخاب کنند، و حالتهای تمرکز را فعال کنند.
نحوه پیادهسازی
WebGL و WebGPU
هنگام استفاده از WebGL یا WebGPU، این مراحل را دنبال کنید:
بررسی کنید که آیا HTML-in-Canvas در مرورگر پشتیبانی میشود یا نه:
if ('content' in HTMLCanvasElement.prototype) { // Use HTML in Canvas API } else { // Use fallback strategy }<canvas>را با افزودن مشخصهcontent="drawable"به عنصر HTML<canvas>مقداردهی اولیه کنید تا از عناصر HTML فرزند پشتیبانی کند. محتوای 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به بافت تبدیل کنید. عنصر فقط پساز اینکه بوم آن را رنگآمیزی کرد میتواند بارگذاری شود: برای درخواست رویدادpaint،canvas.requestPaint()را فراخوانی کنید.
اندازه بافت را از canvas.captureElementImage(element) تغییر دهید. width و height آن در پیکسلهای شبکه بوم (ذخیرهسازی پشتیبان) هستند، بنابراین آنها را با 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()اندازه کنید، همانطور که در بالا نشان داده شده است. - سطح ۰ را فقط در اولین بارگذاری یا زمانی که اندازه تغییر میکند اختصاص دهید، زیرا تخصیص مجدد بافت را پاک میکند. استفاده از فضای ذخیرهسازی تغییرپذیر: اندازه فضای ذخیرهسازی تغییرناپذیر
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 }را در پیکربندی رد کنید. این گزینه باعث میشود که کپی، بافت اختصاصدادهشده را بدون توجه به نحوه مقیاسبندی بوم پر کند.- اگر اندازه تغییر کرد، بافت را دوباره اختصاص دهید، همانطور که در بالا نشان داده شده است.
// 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را اضافه کنید تا در سایهزنها نمونهبرداری شود. استفاده نادرست بهعنوان خطای اعتبارسنجی 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 در بوم رندر میشود. 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، به بوم، برحسب پیکسلهای CSS، نگاشت میکند. مرورگر باید بااستفاده از تبدیل نمای دید، از فضای مختصات سهبعدی به فضای مختصات CSS نگاشت کند. برای تسهیل این کار، موارد زیر را انجام دهید:
- عنصر HTML را نرمالسازی کنید. اندازه عناصر HTML برحسب پیکسل است (برای مثال، عرض ۲۰۰ پیکسل). بااینحال، WebGL معمولاً اشیاء را بهعنوان «مربعهای واحد» درنظر میگیرد، برای مثال، از ۰٫۵- تا ۰٫۵. اگر نرمالسازی نکنید، دکمه ۲۰۰ پیکسلی شما ۲۰۰ برابر بزرگتر بهنظر خواهد رسید. این مرحله همچنین محور Y را برعکس میکند، زیرا در CSS، پایین مثبت است، اما در WebGL، بالا مثبت است.
- تبدیل ماتریس WebGL MVP به ماتریس 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); }
سپس تبدیل را بهعنوان canvasTransform به canvas.updateElementGeometry() منتقل کنید. این کار عنصر 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 );برای تعاملی کردن عنصر HTML، مش را با
InteractionManagerثبت کنید. این ویژگی عنصر را با مش همتراز نگه میدارد و هندسه عنصر را با بوم همگامسازی میکند تا کلیکها و انتخاب نوشتار به عنصر 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 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را به هر عنصری که ترسیم میکنید، ازجمله فرزندان مستقیم بوم، اضافه کنید. - اجباری: هنگام استفاده از WebGL یا WebGPU، از یک گرداننده رویداد
onpaintبرای رندر کردن محتوای HTML در بوم استفاده کنید. - اجباری: هنگام استفاده از WebGL، میانگیر بافت را یکبار با
texImage2D()پیشتخصیص دهید (وقتی مقداردهی اولیه میشود یا وقتی اندازه عنصر تغییر میکند). بافت را فقط زمانی که اندازه تغییر میکند بازتخصیص دهید وTEXTURE_MIN_FILTERرا رویLINEARتنظیم کنید. - اجباری: هنگام استفاده از WebGL، از روش
texElementSubImage2Dبرای رندر کردن محتوای HTML به بافتی که ازقبل باtexImage2D()اختصاص دادهاید، با اندازهcaptureElementImage()، استفاده کنید. - اجباری: هنگام استفاده از WebGPU، از روش
drawElementImageToTextureبرای رندر کردن محتوای HTML در بوم استفاده کنید. اندازه بافت را ازcaptureElementImage()تعیین کنید و فقط زمانیکه اندازه تغییر میکند آن را بازسازی کنید. بافت را بااستفاده ازCOPY_DSTوRENDER_ATTACHMENTایجاد کنید و اندازه بافت را بهصورتsizeارسال کنید. - اجباری: هنگام استفاده از WebGL یا WebGPU، هندسه عنصر HTML را با محتوای رندرشده با فراخوانی
canvas.updateElementGeometry(element, { canvasTransform })همگامسازی کنید. - اجباری: هنگام استفاده از Three.js، مش را با
InteractionManagerثبت کنید وinteractions.update()را در حلقه پویانمایی فراخوانی کنید تا عنصر HTML ورودی را دریافت کند. - الزامی: اندازه صفحهنمایش را مشاهده کنید و اندازه بوم را بهروز کنید تا با پیکسلهای دستگاه مطابقت داشته باشد، مثلاً بااستفاده از
ResizeObserver. - محتوای میانمبدئی را در بوم نقاشی جاسازی نکنید، زیرا پشتیبانی نمیشود.
- DO NOT initialize
ResizeObserverwithin theonpaintevent handler, as it may lead to memory leaks.
راهبردهای جایگزین
HTML-in-Canvas API درحالحاضر در همه مرورگرهای مدرن پشتیبانی نمیشود، بنابراین معمولاً به یک استراتژی جایگزین نیاز است.
بااینحال، با توجه به مزایای بهبود عملکرد این API، اگر مرورگر از HTML-in-Canvas پشتیبانی میکند، باید از آن استفاده شود.
راهبرد جایگزین به مورد استفاده بستگی دارد. برای مثال، برای محتوای HTML تعاملی در بوم، اگر HTML-in-Canvas پشتیبانی نمیشود، محتوای HTML را بااستفاده از CSS در بالای بوم قرار دهید.
شبیهساز مرورگر HTML-in-Canvas
از اسکریپت شبیهساز مرورگر زیر برای تقلید HTML-in-Canvas API در مرورگرهایی که از آن پشتیبانی نمیکنند استفاده کنید.
کتابخانه را نصب یا جاسازی کنید:
# Install npm install three-html-render# Embed <script src="https://cdn.jsdelivr.net/npm/three-html-render/dist/polyfill.js"></script>برای ترجمه HTML-in-Canvas، روش
installHtmlInCanvasPolyfill()را اجرا کنید.