API HTML-in-Canvas cho phép kết xuất DOM thực ngay bên trong một phần tử canvas. Khi được áp dụng cho các bối cảnh kết xuất 3D như WebGL, WebGPU hoặc Three.js, việc thêm thuộc tính content="drawable" vào canvas và thuộc tính drawable vào các phần tử HTML con của canvas sẽ cho phép các phần tử đó được chiếu liền mạch vào cảnh 3D. Điều quan trọng là vì các phần tử HTML vẫn là một phần của cây bố cục DOM đang hoạt động, nên chúng vẫn giữ được khả năng tương tác đầy đủ, cho phép người dùng nhấp vào nút, chọn văn bản và kích hoạt trạng thái tiêu điểm theo cách tự nhiên mà không cần truyền tia phức tạp hoặc xử lý sự kiện tuỳ chỉnh.
Cách triển khai
WebGL và WebGPU
Khi sử dụng WebGL hoặc WebGPU, hãy làm theo các bước sau:
Kiểm tra xem trình duyệt có hỗ trợ HTML-in-Canvas hay không:
if ('content' in HTMLCanvasElement.prototype) { // Use HTML in Canvas API } else { // Use fallback strategy }Khởi chạy
<canvas>để hỗ trợ các phần tử HTML con bằng cách thêm thuộc tínhcontent="drawable"vào phần tử HTML<canvas>. Đặt nội dung HTML của bạn vào bên trong phần tử<canvas>và thêm thuộc tínhdrawablevào mọi phần tử mà bạn vẽ. Một phần tử códrawablesẽ thu thập cây con của nó, ngoại trừ các phần tử con lồng nhau cũng làdrawable:<canvas id="canvas" content="drawable"> <div id="html-content" drawable></div> </canvas>Điều chỉnh tỷ lệ lưới canvas cho phù hợp với hệ số tỷ lệ của thiết bị để tránh bị mờ:
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);Hiển thị nội dung HTML thành một hoạ tiết bên trong trình xử lý sự kiện
canvas.onpaint. Bạn chỉ có thể tải một phần tử lên sau khi canvas đã vẽ phần tử đó: hãy gọicanvas.requestPaint()để yêu cầu một sự kiệnpaint.
Định cỡ hoạ tiết từ canvas.captureElementImage(element). width và height của nó nằm trong pixel lưới canvas (kho lưu trữ sao lưu), vì vậy, hãy làm tròn chúng bằng Math.ceil(). captureElementImage() sẽ gửi nếu phần tử chưa có bản ghi vẽ: gọi requestPaint() và thử lại trong sự kiện paint tiếp theo.
// 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;
}
- Trong ngữ cảnh WebGL, trước tiên, hãy phân bổ trước kết cấu sao lưu bằng phương thức
texImage2D. Các đoạn mã trong hướng dẫn này sử dụng ngữ cảnh WebGL 2 (canvas.getContext("webgl2")). Phân bổ bộ nhớ kết cấu bằng cách gọitexImage2D()bằngnullthay vì dữ liệu pixel, giúp dự trữ bộ nhớ GPU có kích thước và định dạng nhất định mà không cần tải bất kỳ pixel nào lên. Hãy tuân thủ các quy tắc sau:- Định cỡ hoạ tiết từ
captureElementImage(), như minh hoạ ở trên. - Chỉ phân bổ cấp 0 trong lần tải lên đầu tiên hoặc khi kích thước thay đổi, vì việc phân bổ lại sẽ xoá kết cấu. Sử dụng bộ nhớ có thể thay đổi: không thể đổi kích thước bộ nhớ
texStorage2D()không thể thay đổi khi phần tử thay đổi kích thước.
- Định cỡ hoạ tiết từ
// 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 };
- Sau đó, trong bối cảnh WebGL, hãy kết xuất nội dung HTML vào kết cấu bằng phương thức
texElementSubImage2D. Hãy tuân theo các quy tắc sau:- Truyền
{ width, height }vào cấu hình. Thao tác này giúp bản sao lấp đầy kết cấu được phân bổ bất kể canvas được điều chỉnh theo tỷ lệ như thế nào. - Phân bổ lại kết cấu nếu kích thước thay đổi, như minh hoạ ở trên.
- Truyền
// 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);
}
};
- Trong ngữ cảnh WebGPU, hãy sử dụng phương thức
drawElementImageToTexture. Hãy tuân theo các quy tắc sau:- Tạo hoạ tiết bằng cách sử dụng
COPY_DSTvàRENDER_ATTACHMENT, đồng thời thêmTEXTURE_BINDINGđể lấy mẫu trong chương trình đổ bóng. Cách sử dụng sai sẽ được báo cáo là lỗi xác thực WebGPU, chứ không phải là ngoại lệ. - Truyền kích thước hoạ tiết dưới dạng
size. Nếu không cósize, thì một phần tử lớn hơn sẽ được cắt bớt cho vừa với hoạ tiết. Vớisize, phần tử được điều chỉnh theo tỷ lệ để lấp đầy phần tử đó. - Không thể đổi kích thước hoạ tiết WebGPU. Tạo lại hoạ tiết khi kích thước phần tử thay đổi và tạo lại các nhóm liên kết tham chiếu đến hoạ tiết đó.
- Tạo hoạ tiết bằng cách sử dụng
// 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);
}
};
Khi sử dụng vòng lặp requestAnimationFrame để kết xuất cảnh, hãy gọi canvas.requestPaint() trong vòng lặp để đảm bảo rằng nội dung HTML được kết xuất vào canvas. event.changedElements liệt kê các phần tử có hoạt động kết xuất đã thay đổi. Hãy đảm bảo rằng bạn chỉ tải lại kết cấu lên nếu có nội dung cập nhật cho các phần tử HTML con:
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
};
- Đồng bộ hoá hình học của phần tử.
Tính toán một DOMMatrix liên kết hộp viền của phần tử (tính bằng pixel CSS) với canvas (tính bằng pixel CSS). Trình duyệt cần liên kết từ không gian toạ độ 3D vào không gian toạ độ CSS bằng cách sử dụng phép biến đổi khung nhìn. Để hỗ trợ việc này, hãy làm như sau:
- Chuẩn hoá phần tử HTML. Các phần tử HTML được định kích thước bằng pixel (ví dụ: rộng 200 pixel). Tuy nhiên, WebGL thường coi các đối tượng là "hình vuông đơn vị", ví dụ: từ -0,5 đến 0, 5. Nếu bạn không chuẩn hoá, nút 200px của bạn sẽ trông lớn hơn 200 lần. Bước này cũng lật trục Y, vì trong CSS, hướng xuống là dương, nhưng trong WebGL, hướng lên là dương.
- Chuyển đổi Ma trận MVP WebGL thành Ma trận DOM.
- Ánh xạ tới khung nhìn canvas. Bước này là giai đoạn "điều chỉnh lại tỷ lệ": bước này kéo giãn toán học không gian đơn vị đó ra để khớp với kích thước pixel CSS của phần tử
<canvas>trên màn hình và lật lại trục Y. Tính toán phép biến đổi cuối cùng. Nhân các ma trận theo thứ tự: Khung nhìn * MVP * Chuẩn hoá. Việc kết hợp các phép biến đổi này thành một phép biến đổi cuối cùng sẽ tạo ra một "bản đồ" cho trình duyệt biết chính xác vị trí của phần tử HTML đó để căn chỉnh với bản vẽ 3D. Trình duyệt thực hiện phép chia phối cảnh.
// 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); }
Sau đó, truyền phép biến đổi đến canvas.updateElementGeometry() dưới dạng canvasTransform. Thao tác này sẽ di chuyển phần tử HTML để đặt trực tiếp lên trên các pixel được kết xuất. Điều này đảm bảo rằng khi người dùng nhấp vào một nút hoặc chọn văn bản, họ thực sự đang nhấp vào phần tử HTML thực. Trình duyệt cũng sử dụng phép biến đổi cho các ranh giới hỗ trợ tiếp cận của phần tử, ví dụ: cho vòng lấy tiêu điểm của trình đọc màn hình:
canvas.updateElementGeometry(targetHTMLElement, {
canvasTransform: computeCanvasTransform(canvas, targetHTMLElement, htmlElementMVP),
});
Sử dụng các tuỳ chọn này để kiểm soát thử nghiệm nhấn:
- Mỗi lệnh gọi
updateElementGeometry()sẽ di chuyển phần tử lên đầu thứ tự thử nghiệm nhấn. Thẻ/vé{ preserveHitTestOrder: true }sẽ giữ nguyên vị trí. - Gọi
canvas.clearElementGeometry(element)để xoá một phần tử khỏi thử nghiệm nhấn, ví dụ: khi bạn ẩn phần tử đó.
Three.js
Kiểm tra xem trình duyệt có hỗ trợ HTML-in-Canvas hay không:
if ('content' in HTMLCanvasElement.prototype) { // Use HTML in Canvas API } else { // Use fallback strategy }Tạo hình học và chất liệu tuỳ chỉnh cho nội dung HTML.
Truyền phần tử DOM vào THREE.HTMLTexture. Trình kết xuất sẽ thêm phần tử vào canvas và đặt các thuộc tính bắt buộc:
material.map = new THREE.HTMLTexture(element); mesh = new THREE.Mesh( geometry, material ); scene.add( mesh );Đăng ký lưới bằng
InteractionManagerđể làm cho phần tử HTML có tính tương tác. Thao tác này giúp phần tử luôn căn chỉnh với lưới và đồng bộ hoá hình học của phần tử với canvas, nhờ đó, các lượt nhấp và lựa chọn văn bản sẽ nhắm đến phần tử HTML thực. Gọiinteractions.update()trong vòng lặp ảnh động, trước khi kết xuất:
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 );
Mã mẫu
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);
}
Các phương pháp hay nhất
- BẮT BUỘC: Kiểm tra khả năng hỗ trợ của trình duyệt đối với API HTML-in-Canvas trước khi sử dụng.
- BẮT BUỘC: Khi sử dụng WebGL hoặc WebGPU, hãy luôn thêm thuộc tính
content="drawable"vào phần tử<canvas>. - BẮT BUỘC: Khi sử dụng WebGL hoặc WebGPU, hãy thêm thuộc tính
drawablevào mọi phần tử mà bạn vẽ, bao gồm cả các phần tử con trực tiếp của canvas. - BẮT BUỘC: Khi sử dụng WebGL hoặc WebGPU, hãy dùng trình xử lý sự kiện
onpaintđể kết xuất nội dung HTML vào canvas. - BẮT BUỘC: Khi sử dụng WebGL, hãy phân bổ trước vùng đệm kết cấu bằng
texImage2D()một lần (khi khởi chạy hoặc khi kích thước phần tử thay đổi). Chỉ phân bổ lại kết cấu khi kích thước thay đổi và đặtTEXTURE_MIN_FILTERthànhLINEAR. - BẮT BUỘC: Khi sử dụng WebGL, hãy dùng phương thức
texElementSubImage2Dđể kết xuất nội dung HTML thành một hoạ tiết mà bạn phân bổ trước bằngtexImage2D(), có kích thước từcaptureElementImage(). - BẮT BUỘC: Khi sử dụng WebGPU, hãy dùng phương thức
drawElementImageToTextuređể kết xuất nội dung HTML vào canvas. Định cỡ kết cấu từcaptureElementImage()và chỉ tạo lại kết cấu khi kích thước thay đổi. Tạo kết cấu bằng cách sử dụngCOPY_DSTvàRENDER_ATTACHMENT, đồng thời truyền kích thước kết cấu dưới dạngsize. - BẮT BUỘC: Khi sử dụng WebGL hoặc WebGPU, hãy đồng bộ hoá hình học của phần tử HTML với nội dung được kết xuất bằng cách gọi
canvas.updateElementGeometry(element, { canvasTransform }). - BẮT BUỘC: Khi sử dụng Three.js, hãy đăng ký lưới bằng
InteractionManagervà gọiinteractions.update()trong vòng lặp ảnh động để phần tử HTML nhận dữ liệu đầu vào. - BẮT BUỘC: Quan sát kích thước màn hình và cập nhật kích thước canvas cho phù hợp với pixel của thiết bị, ví dụ: bằng cách sử dụng
ResizeObserver. - KHÔNG nhúng nội dung trên nhiều nguồn gốc vào canvas vì tính năng này không được hỗ trợ.
- KHÔNG khởi động
ResizeObservertrong trình xử lý sự kiệnonpaintvì việc này có thể dẫn đến rò rỉ bộ nhớ.
Chiến lược dự phòng
API HTML-in-Canvas hiện không được hỗ trợ trong tất cả các trình duyệt hiện đại, do đó, bạn thường cần có chiến lược dự phòng.
Tuy nhiên, do lợi ích về hiệu suất được cải thiện của API này, bạn nên sử dụng HTML-in-Canvas nếu trình duyệt hỗ trợ.
Chiến lược dự phòng phụ thuộc vào trường hợp sử dụng. Ví dụ: đối với nội dung HTML tương tác trong canvas, nếu HTML-in-Canvas không được hỗ trợ, hãy đặt nội dung HTML lên trên canvas bằng CSS.
Polyfill HTML-in-Canvas
Sử dụng tập lệnh polyfill sau đây để mô phỏng HTML-in-Canvas API trong các trình duyệt không hỗ trợ API này.
Cài đặt hoặc nhúng thư viện:
# Install npm install three-html-render# Embed <script src="https://cdn.jsdelivr.net/npm/three-html-render/dist/polyfill.js"></script>Chạy phương thức
installHtmlInCanvasPolyfill()để dịch HTML-in-Canvas.