HTML Textures
What is an HTML Texture?
HtmlTexture renders a live HTML element — a <div>, a form, an SVG, an entire styled DOM subtree — into a texture you can apply to any material. It is built on the experimental WICG HTML-in-Canvas API, which lets the browser lay out and rasterize a real DOM element directly into a WebGL or WebGPU texture.
Unlike a Dynamic Texture (where you draw with the 2D canvas API) or a Video Texture (which copies a <video> element), HtmlTexture snapshots arbitrary, fully styled HTML — including text, CSS layout, and nested elements.
It also ships with optional interaction managers that route Babylon pointer events back to the DOM, so the rendered HTML can stay interactive (buttons, links, hover states) on a 3D surface.
Examples
HTML Texture Control Panel Multiple HTML TexturesBoth demos load the polyfill from a CDN so they run in any browser (see Enabling the polyfill).
Browser support
The underlying API is experimental and not yet broadly shipped. HtmlTexture renders through the first available of these paths:
- Natively, in a browser that exposes the API (for example, Chrome Canary with
chrome://flags/#canvas-draw-elementenabled). This is the fastest path: the browser copies the laid-out element straight into the GPU texture. - Via a polyfill, using
three-html-render, installed through the helper in@babylonjs/core(see Enabling the polyfill below). - Via the built-in SVG fallback, which works in any modern browser with no extra setup (see The built-in SVG fallback below). This is the default when neither of the above is present.
Because the SVG fallback is enabled by default, HtmlTexture renders in regular browsers out of the box. It never throws, so it is safe to include in code that also runs on browsers without the native API.
Both WebGL2 and WebGPU engines are supported; the correct upload path is selected automatically.
Creating an HTML Texture
import { StandardMaterial } from "@babylonjs/core/Materials/standardMaterial";import { HtmlTexture } from "@babylonjs/core/Materials/Textures/HTML/htmlTexture";import { MeshBuilder } from "@babylonjs/core/Meshes/meshBuilder";// Any live DOM element can be the source.const el = document.createElement("div");el.style.cssText = "width:512px;height:512px;background:#1e293b;color:#fff;font:24px sans-serif;padding:24px;box-sizing:border-box";el.innerHTML = "<h1>Hello DOM</h1><p>Rendered into a texture.</p>";const htmlTexture = new HtmlTexture("html", el, { scene });const material = new StandardMaterial("mat", scene);material.emissiveTexture = htmlTexture;material.disableLighting = true;const plane = MeshBuilder.CreatePlane("plane", { size: 2 }, scene);plane.material = material;The element is hosted inside a hidden <canvas layoutsubtree> that Babylon adds to the document for you, so the browser can lay it out and paint it. You do not need to attach the element to the DOM yourself.
Creation options
The third constructor argument is an IHtmlTextureOptions object:
| Option | Default | Description |
|---|---|---|
scene | — | The scene the texture belongs to. Provide either scene or engine. |
engine | — | The engine to use, when no scene is available. |
width | element offsetWidth, then 256 | Texture width in pixels. |
height | element offsetHeight, then 256 | Texture height in pixels. |
generateMipMaps | false | Whether mip maps are generated. |
samplingMode | TEXTURE_BILINEAR_SAMPLINGMODE | The texture sampling mode. |
format | TEXTUREFORMAT_RGBA | The texture format. |
autoUpdate | true | When true, the texture refreshes whenever the host canvas reports a paint change. |
useSvgFallback | true | When true, render through the built-in SVG fallback if the native (or polyfilled) API is unavailable. Set to false to require the native API. |
Updating the texture
When autoUpdate is enabled (the default), the texture refreshes automatically whenever the hosted element repaints. You can also drive updates manually:
// Force an immediate re-render from the current DOM state.htmlTexture.update();
// Ask the host canvas to repaint on the next frame (falls back to update() when unsupported).htmlTexture.requestUpdate();The onLoadObservable fires once, the first time the texture has rendered successfully:
htmlTexture.onLoadObservable.addOnce(() => { console.log("HTML texture is ready");});Call htmlTexture.dispose() when you are done; it removes the hidden host canvas and releases the GPU texture.
The built-in SVG fallback
When the native WICG API (or its polyfill) is unavailable, HtmlTexture falls back to rasterizing the element through an SVG <foreignObject>. The element is serialized, wrapped in an SVG image, and uploaded to the texture as a Dynamic Texture. This works in any modern browser with no flags or extra dependencies, so it is the default behavior.
The fallback is convenient but less capable than the native path. Keep these caveats in mind:
- Inline styles only. External stylesheets and
<link>-ed CSS are not applied; style the element (or its children) inline, or inject a<style>element into the serialized subtree. - Same-origin resources. Cross-origin images, fonts, and other external resources taint the snapshot and cause the fallback to fail; the texture logs a one-time warning when this happens.
- Static snapshot. The fallback captures the DOM as it is at update time. Interactive states that the browser computes during real rendering —
:hover, focus rings, native form-control state, running scripts — are not reflected. - Heavier than the native copy. Each update serializes the DOM and decodes an image, so prefer driving updates explicitly (rather than per-frame) for complex content.
To require the native API and skip the fallback entirely, pass useSvgFallback: false. The texture then logs a one-time warning and renders nothing when the native API is missing.
import { HtmlTexture } from "@babylonjs/core/Materials/Textures/HTML/htmlTexture";// Only render when the native WICG API (or polyfill) is available.const htmlTexture = new HtmlTexture("html", el, { scene, useSvgFallback: false });Interaction
HtmlTexture is one-way by default: it produces a live image of the DOM, but pointer picks on the mesh do not reach the element. Two optional managers (both in @babylonjs/core) bridge Babylon pointer events to the DOM so the rendered HTML stays interactive. They are tree-shaken out unless you import them.
Raycast interaction (any mesh)
HtmlRaycastInteractionManager works for arbitrary meshes — planes, boxes, and curved surfaces — because it maps the picked UV coordinate back to a pixel inside the element and dispatches a synthetic DOM pointer event there.
import { HtmlRaycastInteractionManager } from "@babylonjs/core/Materials/Textures/HTML/htmlRaycastInteractionManager";// Register your DOM listeners as usual.el.querySelector("button").addEventListener("click", () => console.log("clicked in 3D"));new HtmlRaycastInteractionManager(scene, htmlTexture, plane);| Option | Default | Description |
|---|---|---|
targetElement | texture's element | The element that receives forwarded events. |
backFaceCulling | true | Ignore hits on back-facing geometry. |
invertY | true | Whether the texture content is stored Y-inverted, used when mapping UV to pixels. |
Because it relies on scene picking,
HtmlRaycastInteractionManagerrequires theRayside-effect import when you consume@babylonjs/coreas ES modules. Add it once, anywhere in your app:import "@babylonjs/core/Culling/ray";The UMD bundle and the Playground include this automatically.
HtmlInteractionManager(below) does not need it.
Overlay interaction (planar meshes)
HtmlInteractionManager positions the real DOM element as a screen-aligned overlay over the projected face of a planar mesh, so the browser hit-tests it natively. This preserves full native fidelity — focus, text selection, and form input — at the cost of being limited to flat, camera-facing surfaces (it applies position, size, and in-plane rotation, not a full perspective skew).
import { HtmlInteractionManager } from "@babylonjs/core/Materials/Textures/HTML/htmlInteractionManager";new HtmlInteractionManager(scene, htmlTexture, plane);| Option | Default | Description |
|---|---|---|
targetElement | texture's element | The element to overlay. |
enablePointerEvents | true | Whether the overlay captures pointer events for native hit-testing. |
Both managers expose a dispose() method that detaches their observers.
Choose
HtmlRaycastInteractionManagerwhen the surface is curved or non-planar, or when you want Babylon to remain in control of the pointer pipeline. ChooseHtmlInteractionManagerwhen you need true native DOM input (keyboard, IME, text selection) on a flat panel.
Enabling the polyfill
When the native API is not present, install the polyfill helper from @babylonjs/core. It lazily imports three-html-render only when needed, so it never adds to your bundle unless it is actually used.
import { InstallHtmlInCanvasPolyfill } from "@babylonjs/core/Materials/Textures/HTML/htmlInCanvasPolyfill";
// No-op when the API is supported natively; installs the polyfill otherwise.await InstallHtmlInCanvasPolyfill();Helpers available in @babylonjs/core:
IsHtmlInCanvasSupportedNatively()— returnstruewhen the browser exposes the API natively.InstallHtmlInCanvasPolyfill(options?)— installs the polyfill and resolves totruewhen it was installed. By default it is a no-op when native support is present; pass{ force: true }to install regardless. Appending?polyfillHICto the page URL also forces installation.UninstallHtmlInCanvasPolyfill()— removes a previously installed polyfill.
In the Playground
The Playground's UMD bundle can't dynamically import the polyfill package, so load the self-installing CDN build with a <script> tag before creating any HtmlTexture. The script installs itself on load, is idempotent, and no-ops when the native API is present:
const ensureHtmlInCanvasAsync = function () { if ("captureElementImage" in HTMLCanvasElement.prototype) { return Promise.resolve(); } if (document.querySelector("script[data-hic-polyfill]")) { return Promise.resolve(); } return new Promise(function (resolve, reject) { const script = document.createElement("script"); script.src = "https://cdn.jsdelivr.net/npm/three-html-render@0.1.2/dist/polyfill.js"; script.dataset.hicPolyfill = ""; script.onload = function () { resolve(); }; script.onerror = function () { reject(new Error("Failed to load the HTML-in-Canvas polyfill")); }; document.head.appendChild(script); });};
// at the top of your async createScene, before building any HtmlTexture:await ensureHtmlInCanvasAsync();Putting it together
import { ArcRotateCamera } from "@babylonjs/core/Cameras/arcRotateCamera";import { HemisphericLight } from "@babylonjs/core/Lights/hemisphericLight";import { StandardMaterial } from "@babylonjs/core/Materials/standardMaterial";import { HtmlRaycastInteractionManager } from "@babylonjs/core/Materials/Textures/HTML/htmlRaycastInteractionManager";import { HtmlTexture } from "@babylonjs/core/Materials/Textures/HTML/htmlTexture";import { Vector3 } from "@babylonjs/core/Maths/math.vector";import { MeshBuilder } from "@babylonjs/core/Meshes/meshBuilder";import { Scene } from "@babylonjs/core/scene";import { InstallHtmlInCanvasPolyfill } from "@babylonjs/core/Materials/Textures/HTML/htmlInCanvasPolyfill";const createScene = async function () { const scene = new Scene(engine); const camera = new ArcRotateCamera("camera", -Math.PI / 2, Math.PI / 2.5, 4, Vector3.Zero(), scene); camera.attachControl(true); new HemisphericLight("light", new Vector3(0, 1, 0), scene); // Use the native API when present, fall back to the polyfill otherwise. await InstallHtmlInCanvasPolyfill(); const el = document.createElement("div"); el.style.cssText = "width:512px;height:512px;background:#1e293b;color:#fff;font:24px sans-serif;padding:24px;box-sizing:border-box"; el.innerHTML = "<h1>Hello DOM</h1><button>Click me</button>"; el.querySelector("button").addEventListener("click", (e) => (e.target.textContent = "Clicked!")); const htmlTexture = new HtmlTexture("html", el, { scene, autoUpdate: true }); const material = new StandardMaterial("mat", scene); material.emissiveTexture = htmlTexture; material.disableLighting = true; const plane = MeshBuilder.CreatePlane("plane", { size: 2 }, scene); plane.material = material; // Forward pointer picks to the DOM so the button is clickable in 3D. new HtmlRaycastInteractionManager(scene, htmlTexture, plane); return scene;};Notes and limitations
- The API is experimental; behavior and availability may change as the WICG specification evolves.
- Cross-origin content (for example, third-party
<iframe>s) cannot be rasterized, mirroring the browser's tainted-canvas rules. - The built-in SVG fallback only captures same-origin, inline-styled content as a static snapshot; see The built-in SVG fallback for details, or set
useSvgFallback: falseto require the native API. - The overlay
HtmlInteractionManageris screen-aligned and does not perspective-correct steeply oblique faces; use the raycast manager for those. - Synthetic pointer forwarding (
HtmlRaycastInteractionManager) delivers pointer events, but not native keyboard or IME input into rasterized form fields; use the overlay manager when real text input is required.