多年來,網頁開發人員在網路上建構複雜、高度互動的視覺應用程式時,都必須做出艱難的架構選擇:要依賴 DOM 的豐富語意功能,還是直接在 <canvas> 元素中算繪,以獲得低階圖形效能?
有了全新的實驗性 HTML-in-Canvas API (現已在來源試用階段推出),您不必再煩惱該如何選擇。這個 API 可讓您直接將 DOM 內容繪製到 2D 畫布或 WebGL/WebGPU 材質中,同時保持 UI 可互動、無障礙,並連結至您喜愛的瀏覽器功能。結合 HTML 和低階圖形處理,即可打造前所未有的體驗。
DOM 與 Canvas 的比較
如要瞭解這項新 API 的強大功能,不妨先看看 DOM 和 Canvas 的相對優勢。
DOM 是網頁 UI 的主要元素。這項工具可根據語意理解的內容建立豐富的介面,提供現成的文字版面配置解決方案。使用者可以順暢地在網頁上執行常見作業,例如醒目顯示要複製的文字,或按一下滑鼠右鍵儲存圖片。DOM 也整合了重要的瀏覽器功能:無障礙工具、翻譯、網頁內搜尋、閱讀模式、擴充功能、深色模式、瀏覽器縮放和自動填入。
另一方面,Canvas (和 WebGL/WebGPU) 則可提供低階存取權,驅動像素格線,呈現高度進階的 2D 和 3D 圖像。遊戲和複雜的網頁應用程式 (例如 Google 文件或 Figma) 需要這種高效能的低階存取權。由於畫布基本上是像素格線,因此支援回應式文字等功能時,過去需要複雜的自訂 UI 邏輯,大幅增加套件大小。最重要的是,當 UI 困在靜態畫布像素格線中時,整合到 DOM 中的所有強大瀏覽器功能都會完全中斷。
將 DOM 帶入 Canvas 的優點
HTML-in-Canvas API 是一座橋樑,可讓您兼顧兩者優點。將 HTML 放在 <canvas> 元素內並同步處理其轉換,即可確保內容維持完整互動性,且所有瀏覽器整合功能都會自動運作。
讓 DOM 在 <canvas> 元素內處理 UI 的優點如下:
- 文字版面配置和格式:簡化文字版面配置和格式,包括套用 CSS 樣式的多行或雙向文字。
- 表單控制項:提供豐富的自訂選項,讓您輕鬆使用更具表現力的表單控制項。
- 選取文字、複製/貼上及按一下滑鼠右鍵:使用者可以醒目顯示 3D 場景中的文字,或以原生方式按一下滑鼠右鍵開啟內容選單。
- 選取文字、複製/貼上及按一下滑鼠右鍵:使用者可以醒目顯示 3D 場景中的文字,或以原生方式按一下滑鼠右鍵開啟內容選單。
- 無障礙功能:畫布內算繪的內容會公開至無障礙樹狀結構。無障礙系統可以像剖析一般 HTML 一樣剖析 UI,並向螢幕閱讀器等系統公開。
- Find-in-page:使用者可以透過網頁內搜尋 (Ctrl/Cmd+F) 搜尋文字,瀏覽器會直接在 WebGL 紋理中醒目顯示文字。
- Find-in-page:使用者可以透過網頁內搜尋 (Ctrl/Cmd+F) 搜尋文字,瀏覽器會直接在 WebGL 紋理中醒目顯示文字。
- 可索引性且可與 AI 代理互動:網頁檢索器和 AI 代理可順暢地為 2D 和 3D 場景中顯示的文字建立索引,並讀取這些文字。
- 擴充功能整合:瀏覽器擴充功能可原生運作。舉例來說,文字替換擴充功能會自動更新 3D 網格上顯示的文字。
- 開發人員工具整合:您可以在 Chrome 開發人員工具中,直接檢查畫布內容,包括 WebGL/WebGPU UI 元素。在檢查器中調整 CSS 樣式,即可立即在 3D 紋理上看到更新!
一般應用情況
這項 API 可在多個領域發揮驚人潛力:
- 以大型畫布為基礎的應用程式:現在起,Google 文件、Miro 或 Figma 等重量級網頁應用程式,可將複雜的應用程式 UI 元件原生算繪至以畫布為基礎的工作區,提升無障礙功能並減少套件重量。
- 3D 場景和遊戲:行銷網站、身歷其境的 WebXR 體驗和網頁遊戲現在可以在 3D 場景中放置可完全互動的網頁 UI,例如使用實際 DOM 文字的 3D 書籍,或原生支援複製及貼上的遊戲內終端機。
如何使用 API
使用 API 時,會經歷三個階段:設定畫布、將內容算繪到畫布中,以及更新 CSS 轉換,讓瀏覽器知道元素在螢幕上的實際位置。
必要條件
HTML-in-Canvas API 的來源試用期為 Chrome 148 至 150 版。如要在網站上測試這項功能,請使用 Chrome Canary 149 以上版本,並啟用 chrome://flags/#canvas-draw-element 旗標。如要為其他使用者啟用 API,請註冊來源試用。
步驟 1:基本 Canvas 設定
首先,請在 <canvas> 標記中加入 layoutsubtree 屬性。這會讓瀏覽器瞭解畫布內巢狀結構的內容,準備在畫布內顯示內容,並將內容公開給無障礙樹狀結構。
<canvas id="canvas" style="width: 200px; height: 200px;" layoutsubtree>
<div id="form_element">
<label for="name">Name:</label> <input id="name" type="text">
</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);
步驟 2:算繪
如果是 2D 環境,請使用 drawElementImage 方法。請在 paint 事件中執行這項操作,每當元素重新繪製時 (例如在醒目顯示文字或使用者輸入內容時),就會觸發該事件。請務必使用傳回值更新元素的 CSS 轉換,確保互動功能正常運作。
const ctx = document.getElementById('canvas').getContext('2d');
const form_element = document.getElementById('form_element');
const canvas = document.getElementById('canvas');
canvas.onpaint = () => {
ctx.reset();
// Draw the form element at x:0, y:0
let transform = ctx.drawElementImage(form_element, 0, 0);
// Use the transform returned later on...
};
使用 WebGL 轉譯
如果是 WebGL,則使用 texElementImage2D。運作方式與 texImage2D 類似,但會將 DOM 元素做為來源。
canvas.onpaint = () => {
if (gl.texElementImage2D) {
gl.texElementImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, form_element);
}
};
使用 WebGPU 算繪
WebGPU 會在裝置佇列上使用 copyElementImageToTexture 方法,類似於 copyExternalImageToTexture:
canvas.onpaint = () => {
root.device.queue.copyElementImageToTexture(
valueElement,
{ texture: targetTexture }
);
};
步驟 3:更新 CSS 轉換
您已將元素算繪到畫布中,現在需要更新瀏覽器中的元素位置。這可確保畫布和 DOM 的版面配置之間保持空間同步。這項資訊非常重要,因為瀏覽器必須正確對應事件區域 (例如使用者點選或懸停的位置) 與元素的算繪位置。
如果是 2D 情境,請將算繪呼叫傳回的轉換套用至 .style.transform property:
const ctx = document.getElementById('canvas').getContext('2d');
const form_element = document.getElementById('form_element');
const canvas = document.getElementById('canvas');
canvas.onpaint = () => {
ctx.reset();
// Draw the form element at x:0, y:0
let transform = ctx.drawElementImage(form_element, 0, 0);
// Sync the DOM location with the drawn location
form_element.style.transform = transform.toString();
};
使用 WebGL 或 WebGPU 時,元素的螢幕位置取決於著色器程式碼如何使用輸出紋理,無法從畫布算繪環境推斷。不過,如果著色器程式使用典型的模型檢視投影來繪製材質,則可以使用新的便利函式 element.getElementTransform() 計算轉換,該轉換的使用方式與 drawElementImage() 的傳回值相同。為此,您需要執行下列操作:
- 將 WebGL MVP 矩陣轉換為 DOM 矩陣。
- 將 HTML 元素正規化。HTML 元素的大小是以像素為單位 (例如寬度為 200 像素)。不過,WebGL 通常會將物件視為「單位正方形」,例如範圍從 0 到 1。如果不進行正規化,200 像素的按鈕會放大 200 倍。
- 對應至畫布可視區域。這個步驟是「重新縮放」階段:系統會將該單位空間數學運算結果延展回螢幕上
<canvas>元素的實際像素尺寸。此外,這也會翻轉 Y 軸,因為在 WebGL 中,向上為正值,但在 CSS 中,向下為正值。 - 計算最終轉換。依序將矩陣相乘:
Viewport * MVP * Normalization.將矩陣合併為一個最終轉換,產生「地圖」,告訴瀏覽器 HTML 元素層應位於何處,才能與 3D 繪圖對齊。 - 將轉換套用至 HTML 元素。這會將 HTML 元素層移至其算繪像素的正上方。這可確保使用者點選按鈕或選取文字時,會點選到實際的 HTML 元素。
if (canvas.getElementTransform) {
// 1. Convert WebGL MVP Matrix to DOM Matrix
const mvpDOM = new DOMMatrix(Array.from(htmlElementMVP));
// 2. Normalize the HTML element (pixels -> 1x1 unit square)
const width = targetHTMLElement.offsetWidth;
const height = targetHTMLElement.offsetHeight;
const cssToUnitSpace = new DOMMatrix()
.scale(1 / width, -1 / height, 1) // Shrink to unit size and flip Y
.translate(-width / 2, -height / 2); // Center the element
// 3. Map to the canvas viewport
const clipToCanvasViewport = new DOMMatrix()
.translate(canvas.width / 2, canvas.height / 2) // Move origin to center
.scale(canvas.width / 2, -canvas.height / 2, 1); // Stretch to canvas dimensions
// 4. Multiply: (Clip -> Pixels) * (MVP) * (pixels -> unit square)
const screenSpaceTransform = clipToCanvasViewport
.multiply(mvpDOM)
.multiply(cssToUnitSpace);
// 5. Apply to the transform
const computedTransform = canvas.getElementTransform(targetHTMLElement, screenSpaceTransform);
if (computedTransform) {
targetHTMLElement.style.transform = computedTransform.toString();
}
}
程式庫和架構支援
部分熱門程式庫已支援畫布中的 HTML 功能。
Three.js
手動更新矩陣可能很繁瑣,因此架構已開始採用。Three.js 實驗性支援使用新的 THREE.HTMLTexture:
const material = new THREE.MeshBasicMaterial();
material.map = new THREE.HTMLTexture(uiElement); // Pass the DOM element
const geometry = new THREE.BoxGeometry(1, 1, 1);
const mesh = new THREE.Mesh(geometry, material);
scene.add(mesh);
PlayCanvas
PlayCanvas 也支援使用紋理 API 的 Canvas 內 HTML:
// Wait for the 'paint' event to set the source
canvas.addEventListener('paint', () => {
htmlTexture.setSource(htmlElement);
}, { once: true });
canvas.requestPaint();
// Keep up to date
canvas.addEventListener('paint', onPaintUpload);
const material = new pc.StandardMaterial();
material.diffuseMap = htmlTexture;
material.update();
示範
試用範例前,請先正確設定環境。
我們提供多個範例,可做為使用 API 的參考。我們已看到社群提供的創意解決方案,包括可翻譯的 3D 書籍,以及透過玻璃著色器折射的 UI 元素:
- 3D 書籍:以 WebGL 轉譯的 3D 書籍,使用 HTML 版面配置網頁。使用者可以透過 CSS 替換字型。由於是以 DOM 為基礎,內建翻譯功能可立即運作,AI 代理程式也能以較簡單的方式擷取文字。
- 互動式 3D UI:WebGPU 果凍滑桿會根據底層的 3D 模型折射光線,同時仍會回應標準 HTML
<input type="range">步驟屬性。 - 動畫紋理:動態 3D 廣告看板,使用 DOM 直接將動畫 SVG 鉛筆算繪至 WebGL 紋理,無須自訂動畫迴圈。
- 折射疊加層:互動式排版層會因移動的 3D 游標而扭曲,但仍可使用網頁內搜尋功能完整選取及搜尋。
歡迎查看社群建立的一系列範例。如要將 Canvas 中的 HTML 範例加入這個集合,請建立提取要求。
限制
雖然 API 功能強大,但我們刻意設下一些限制:
- 跨來源內容:基於安全性和隱私權考量,這項 API 不適用於跨來源 iframe 內容。
- 主執行緒捲動:HTML 畫布是使用 JavaScript 繪製,因此捲動和動畫無法像在畫布外一樣,獨立於 JavaScript 更新。開發人員應仔細考量將捲動內容放在畫布內,與讓整個畫布捲動的效能特徵。
意見回饋
如果您正在試用 Canvas 中的 HTML API,歡迎與我們聯絡!您可以註冊來源試用,在實驗階段啟用網站上的這項功能,協助我們設計 API。你也可以提出問題,提供任何意見回饋。