@babylonjs/core
Version:
Getting started? Play directly with the Babylon.js API using our [playground](https://playground.babylonjs.com/). It also contains a lot of samples to learn how to use it.
455 lines (454 loc) • 23 kB
JavaScript
/** This module has no top-level side effects: the engine extension it depends on is registered lazily from the constructor */
import { BaseTexture } from "../baseTexture.pure.js";
import { Matrix } from "../../../Maths/math.vector.pure.js";
import { Observable } from "../../../Misc/observable.pure.js";
import { Logger } from "../../../Misc/logger.js";
import { IsWindowObjectExist } from "../../../Misc/domManagement.js";
import { RegisterEnginesExtensionsEngineDynamicTexture } from "../../../Engines/Extensions/engine.dynamicTexture.pure.js";
let _HasWarnedAboutMissingApi = false;
function _WarnMissingApi(apiName) {
if (_HasWarnedAboutMissingApi) {
return;
}
_HasWarnedAboutMissingApi = true;
Logger.Warn(`HTML-in-Canvas: ${apiName} is not available. Enable chrome://flags/#canvas-draw-element, the built-in SVG fallback, or the three-html-render polyfill to use HtmlTexture.`);
}
let _HasWarnedAboutSvgFallback = false;
function _WarnSvgFallback() {
if (_HasWarnedAboutSvgFallback) {
return;
}
_HasWarnedAboutSvgFallback = true;
Logger.Warn("HTML-in-Canvas: the SVG fallback could not rasterize the element. This usually means it references cross-origin or external resources, which taint the snapshot.");
}
let _HasWarnedAboutUploadFailure = false;
function _WarnUploadFailure(error) {
if (_HasWarnedAboutUploadFailure) {
return;
}
_HasWarnedAboutUploadFailure = true;
const message = error instanceof Error ? error.message : String(error);
Logger.Warn(`HTML-in-Canvas: the native upload failed (${message}). The element must be a direct child of the engine's rendering canvas, which must carry the \`layoutsubtree\` attribute, and the upload must run during a paint event.`);
}
/**
* Detects whether the engine can upload an HTML element through the native (or polyfilled) WICG
* HTML-in-Canvas API, without emitting any warning. Used to decide whether the SVG fallback is needed.
* @param engine defines the engine to test
* @returns true when the WICG upload API is available for this engine
*/
export function IsHtmlInCanvasUploadSupported(engine) {
if (engine.isWebGPU) {
const queue = engine._device?.queue;
return !!queue && typeof queue.copyElementImageToTexture === "function";
}
const gl = engine._gl;
return !!gl && typeof gl.texElementImage2D === "function";
}
/**
* Uploads a live HTML element (or a captured ElementImage) into an existing 2D texture using the WICG
* HTML-in-Canvas API (https://github.com/WICG/html-in-canvas).
*
* This is a side-effect-free helper - it is only reachable when {@link HtmlTexture} (or a direct caller)
* is imported, so it adds nothing to bundles that do not use it. It relies on
* `WebGLRenderingContext.texElementImage2D` (WebGL) or `GPUQueue.copyElementImageToTexture` (WebGPU),
* available either natively (behind chrome://flags/#canvas-draw-element) or through the
* three-html-render polyfill.
* @param engine defines the engine that owns the texture
* @param texture defines the internal texture to update
* @param element defines the source HTML element (or captured ElementImage) to upload
* @param invertY defines if data must be stored with Y axis inverted (true by default)
* @param config defines an optional source rectangle and sizing configuration
* @returns true if the upload succeeded, false otherwise (e.g. the API is unavailable)
*/
export function UploadHtmlElementToTexture(engine, texture, element, invertY = true, config) {
if (!texture || texture._isDisabled) {
return false;
}
if (engine.isWebGPU) {
return _UploadHtmlElementToWebGPUTexture(engine, texture, element, invertY, config);
}
return _UploadHtmlElementToWebGLTexture(engine, texture, element, invertY, config);
}
// The texElementImage2D signature was changed mid-flight in the WICG proposal (see
// https://github.com/WICG/html-in-canvas#idl-changes): the current spec form is
// texElementImage2D(target, internalformat, element, config?), but older Chrome Canary builds (and the
// three-html-render polyfill) still ship the legacy texImage2D-shaped overload
// texElementImage2D(target, level, internalformat, format, type, element). We prefer the new form and
// fall back to the legacy one when the implementation rejects it for the wrong number of arguments. The
// detected shape is cached so we only pay the failed call once.
let _UseLegacyTexElementImage2D = false;
function _CallTexElementImage2D(gl, internalFormat, element, config) {
if (!_UseLegacyTexElementImage2D) {
try {
gl.texElementImage2D(gl.TEXTURE_2D, internalFormat, element, config);
return;
}
catch (error) {
// Only a wrong-arity TypeError means we are talking to the legacy signature; anything else
// (e.g. the element is not a canvas child) is a genuine upload failure and must propagate.
// Native rejects the new form with "N arguments required, but only M present"; the
// three-html-render polyfill rejects it with "unexpected argument count N".
if (!(error instanceof TypeError) || !/arguments required|argument count/.test(error.message)) {
throw error;
}
_UseLegacyTexElementImage2D = true;
}
}
// Legacy overload: texElementImage2D(target, level, internalformat, format, type, element).
gl.texElementImage2D(gl.TEXTURE_2D, 0, internalFormat, gl.RGBA, gl.UNSIGNED_BYTE, element);
}
function _UploadHtmlElementToWebGLTexture(engine, texture, element, invertY, config) {
// texElementImage2D is provided either natively (behind a flag) or by the three-html-render polyfill.
// Engines without a WebGL context (e.g. NullEngine) have no _gl and are handled by this guard.
const gl = engine._gl;
if (!gl || typeof gl.texElementImage2D !== "function") {
_WarnMissingApi("texElementImage2D");
return false;
}
// texElementImage2D requires a *sized* internal format (RGBA8, SRGB8_ALPHA8, RGBA16F or RGBA32F);
// the unsized gl.RGBA returned by _getInternalFormat is rejected.
const internalFormat = engine._getRGBABufferInternalSizedFormat(texture.type, texture.format, texture._useSRGBBuffer);
const wasPreviouslyBound = engine._bindTextureDirectly(gl.TEXTURE_2D, texture, true);
engine._unpackFlipY(invertY);
try {
_CallTexElementImage2D(gl, internalFormat, element, config);
if (texture.generateMipMaps) {
gl.generateMipmap(gl.TEXTURE_2D);
}
if (!wasPreviouslyBound) {
engine._bindTextureDirectly(gl.TEXTURE_2D, null);
}
texture.invertY = invertY;
texture.isReady = true;
return true;
}
catch (error) {
// Reaching here means the snapshot upload failed - usually a real misconfiguration (the element is
// not a child of the rendering canvas, or `layoutsubtree` is missing), though a stray call before
// the first paint snapshot can also throw. Either way we must NOT disable the texture, so a later
// paint-driven update can still succeed; we surface a one-time diagnostic to help debugging.
_WarnUploadFailure(error);
return false;
}
}
function _UploadHtmlElementToWebGPUTexture(engine, texture, element, invertY, config) {
// copyElementImageToTexture is provided either natively (behind a flag) or by the three-html-render polyfill.
const device = engine._device;
const queue = device && device.queue;
if (!device || !queue || typeof queue.copyElementImageToTexture !== "function") {
_WarnMissingApi("GPUQueue.copyElementImageToTexture");
return false;
}
const gpuTexture = texture._hardwareTexture?.underlyingResource;
if (!gpuTexture) {
return false;
}
try {
const source = { source: element };
if (config) {
source.sx = config.sx;
source.sy = config.sy;
source.swidth = config.swidth;
source.sheight = config.sheight;
}
queue.copyElementImageToTexture(source, {
destination: { texture: gpuTexture },
width: config?.width ?? texture.width,
height: config?.height ?? texture.height,
});
if (texture.generateMipMaps) {
engine._generateMipmaps(texture);
}
// The WICG copyElementImageToTexture API has no flipY option; the destination orientation is
// determined by the source. We record the requested value for downstream sampling conventions.
texture.invertY = invertY;
texture.isReady = true;
return true;
}
catch (error) {
// Reaching here means the snapshot upload failed - usually a real misconfiguration (the element is
// not a child of the rendering canvas, or `layoutsubtree` is missing), though a stray call before
// the first paint snapshot can also throw. Either way we must NOT disable the texture, so a later
// paint-driven update can still succeed; we surface a one-time diagnostic to help debugging.
_WarnUploadFailure(error);
return false;
}
}
/**
* A texture whose content is rendered from a live HTML element using the WICG HTML-in-Canvas API
* (https://github.com/WICG/html-in-canvas).
*
* The element is hosted as a child of the engine's rendering canvas (which is marked `layoutsubtree`) so
* the browser can lay it out and snapshot it; the WICG API requires the source element to be a direct
* child of the canvas whose context performs the upload. Content is uploaded through
* `WebGLRenderingContext.texElementImage2D` (WebGL) or `GPUQueue.copyElementImageToTexture` (WebGPU),
* available either natively (behind chrome://flags/#canvas-draw-element) or via the three-html-render
* polyfill (https://github.com/repalash/three-html-render). When neither is present, a built-in SVG
* `<foreignObject>` fallback is used instead (see `useSvgFallback`).
*
* As with {@link HtmlElementTexture}, updates are not automatic unless `autoUpdate` is enabled; in
* that case the texture refreshes whenever the host canvas reports a paint change.
*/
export class HtmlTexture extends BaseTexture {
/**
* Instantiates an HtmlTexture from an HTML element.
* @param name Defines the name of the texture
* @param element Defines the HTML element to render into the texture
* @param options Defines the texture creation options
*/
constructor(name, element, options) {
super(options.scene || options.engine);
/**
* Observable triggered once the texture has been rendered for the first time.
*/
this.onLoadObservable = new Observable();
this._paintHandler = null;
this._ownsHost = false;
this._originalParent = null;
this._originalNextSibling = null;
this._addedInert = false;
this.element = element;
this._textureMatrix = Matrix.Identity();
if (!element || (!options.engine && !options.scene)) {
this.host = null;
this._width = 0;
this._height = 0;
this._format = HtmlTexture._DefaultOptions.format;
this._generateMipMaps = HtmlTexture._DefaultOptions.generateMipMaps;
this._samplingMode = HtmlTexture._DefaultOptions.samplingMode;
this._useSvgFallback = HtmlTexture._DefaultOptions.useSvgFallback;
return;
}
this._generateMipMaps = options.generateMipMaps ?? HtmlTexture._DefaultOptions.generateMipMaps;
this._samplingMode = options.samplingMode ?? HtmlTexture._DefaultOptions.samplingMode;
this._format = options.format ?? HtmlTexture._DefaultOptions.format;
this._useSvgFallback = options.useSvgFallback ?? HtmlTexture._DefaultOptions.useSvgFallback;
const autoUpdate = options.autoUpdate ?? HtmlTexture._DefaultOptions.autoUpdate;
this._width = options.width || element.offsetWidth || 256;
this._height = options.height || element.offsetHeight || 256;
this.name = name;
this.wrapU = 0;
this.wrapV = 0;
this.anisotropicFilteringLevel = 1;
this.host = this._createHost();
// The native WICG API has no content until the first paint snapshot is recorded, so the initial
// upload must happen from a paint event. The rendering canvas emits `paint`; listen for at least the
// first one and, when auto-update is disabled, detach after that first refresh. A non-canvas helper
// host never emits `paint`, so this listener is simply never invoked there.
if (this.host) {
this._paintHandler = (event) => {
// A single `paint` event covers every changed child of the shared rendering canvas. When the
// browser reports which elements changed, skip the upload unless ours is among them, so that
// multiple HTML textures hosted on the same canvas don't all re-upload on every unrelated change.
if (event.changedElements && !event.changedElements.some((changed) => this.element === changed || this.element.contains(changed))) {
return;
}
this.update();
if (!autoUpdate && this._paintHandler && this.host) {
this.host.removeEventListener("paint", this._paintHandler);
this._paintHandler = null;
}
};
this.host.addEventListener("paint", this._paintHandler);
}
this._createInternalTexture();
}
_createHost() {
if (!IsWindowObjectExist() || typeof document === "undefined") {
return null;
}
// Remember where the caller had the element so dispose() can put it back: we are about to re-parent it
// into the host (the element must be a direct child of the host to be captured and laid out).
this._originalParent = this.element.parentNode;
this._originalNextSibling = this.element.nextSibling;
// Per the WICG spec, the source element must be a *direct child* of the same canvas whose WebGL/WebGPU
// context performs the upload, and that canvas must opt its subtree into layout via `layoutsubtree`.
// The engine already owns our texture through its rendering canvas, so the element has to live inside
// that canvas: it is the only one whose context can snapshot the element via texElementImage2D /
// copyElementImageToTexture. A separate canvas would lay the element out but could never be captured
// into the engine's texture (this is why nothing rendered on the native path before).
const renderingCanvas = this._getEngine()?.getRenderingCanvas() ?? null;
if (renderingCanvas) {
renderingCanvas.layoutSubtree = true;
// `layoutsubtree` opts children into hit testing; mark ours inert so it never steals pointer events
// from the canvas (e.g. camera controls). Synthetic event dispatch still reaches inert elements, so
// HtmlRaycastInteractionManager keeps working; HtmlInteractionManager clears it to enable native input.
this._addedInert = !this.element.hasAttribute("inert");
this.element.setAttribute("inert", "");
renderingCanvas.appendChild(this.element);
this._ownsHost = false;
return renderingCanvas;
}
// No rendering canvas (e.g. NullEngine / offscreen): the native upload paths cannot run, because they
// capture from the engine's canvas and there is none. Only the SVG fallback renders here, and it builds
// from the element's serialized markup rather than from a canvas - so a host *canvas* would buy nothing.
// Host the element in a hidden, off-screen <div> purely so it is laid out in the document (giving
// correct measured sizes); there is no canvas to capture into on this path.
const host = document.createElement("div");
host.style.position = "absolute";
host.style.left = "-99999px";
host.style.top = "0px";
host.style.pointerEvents = "none";
host.setAttribute("aria-hidden", "true");
host.appendChild(this.element);
document.body.appendChild(host);
this._ownsHost = true;
return host;
}
_createInternalTexture() {
const engine = this._getEngine();
if (engine) {
// createDynamicTexture is an engine extension; register it lazily (idempotent) so this module stays free of top-level side effects.
RegisterEnginesExtensionsEngineDynamicTexture();
this._texture = engine.createDynamicTexture(this._width, this._height, this._generateMipMaps, this._samplingMode);
this._texture.format = this._format;
}
// Trigger the initial render. On the native path this requests a paint so the first upload happens
// once a snapshot exists (calling the WICG API before the first snapshot throws); otherwise the
// SVG fallback (or a direct update) renders immediately.
this.requestUpdate();
}
/**
* @returns the texture matrix used in most of the material.
*/
getTextureMatrix() {
return this._textureMatrix;
}
/**
* Requests the host canvas to emit a paint event on the next rendering update. When auto-update is
* enabled, this triggers a texture refresh in sync with the DOM. Falls back to an immediate update
* when the WICG API is not available.
*/
requestUpdate() {
// `requestPaint` only exists on a WICG-capable rendering canvas; the helper <div> host has no such
// method, so fall back to an immediate update there.
const canvasHost = this.host;
if (canvasHost && typeof canvasHost.requestPaint === "function") {
canvasHost.requestPaint();
}
else {
this.update();
}
}
/**
* Updates the content of the texture from the current state of the HTML element.
* @param invertY Defines whether the texture should be inverted on Y (true by default)
*/
update(invertY = true) {
const engine = this._getEngine();
if (this._texture == null || engine == null) {
return;
}
// When the native WICG upload API is unavailable, render through the SVG <foreignObject> fallback
// (unless it was explicitly disabled, in which case the upload helper emits a one-time warning).
if (this._useSvgFallback && !IsHtmlInCanvasUploadSupported(engine)) {
this._updateFromSvgFallback(engine, invertY);
return;
}
const wasReady = this.isReady();
UploadHtmlElementToTexture(engine, this._texture, this.element, invertY);
if (!wasReady && this.isReady()) {
this.onLoadObservable.notifyObservers(this);
}
}
/**
* Renders the element through an SVG `<foreignObject>` snapshot and uploads it to the texture. This
* fallback works in any browser but only captures same-origin, inline-styled content as a static image.
* @param engine defines the engine that owns the texture
* @param invertY defines whether the texture should be inverted on Y
*/
_updateFromSvgFallback(engine, invertY) {
if (typeof document === "undefined" || typeof Image === "undefined" || typeof XMLSerializer === "undefined") {
return;
}
const svgUrl = this._buildSvgDataUrl();
if (!svgUrl) {
return;
}
const image = new Image();
image.onload = () => {
if (this._texture == null) {
return;
}
const wasReady = this.isReady();
try {
engine.updateDynamicTexture(this._texture, image, invertY, false, this._format);
}
catch {
_WarnSvgFallback();
return;
}
if (!wasReady && this.isReady()) {
this.onLoadObservable.notifyObservers(this);
}
};
image.onerror = () => {
_WarnSvgFallback();
};
image.src = svgUrl;
}
/**
* Serializes the element into an SVG `<foreignObject>` data URL sized to the texture.
* @returns a data URL, or null if serialization failed
*/
_buildSvgDataUrl() {
try {
const clone = this.element.cloneNode(true);
clone.setAttribute("xmlns", "http://www.w3.org/1999/xhtml");
const serialized = new XMLSerializer().serializeToString(clone);
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${this._width}" height="${this._height}">` +
`<foreignObject x="0" y="0" width="100%" height="100%">${serialized}</foreignObject>` +
`</svg>`;
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
}
catch {
return null;
}
}
/**
* Gets the current class name of the texture useful for serialization or dynamic coding.
* @returns "HtmlTexture"
*/
getClassName() {
return "HtmlTexture";
}
/**
* Dispose the texture and release its associated resources.
*/
dispose() {
if (this.host) {
if (this._paintHandler) {
this.host.removeEventListener("paint", this._paintHandler);
this._paintHandler = null;
}
// Remove the `inert` attribute we added so the caller gets the element back as they passed it.
if (this._addedInert) {
this.element.removeAttribute("inert");
this._addedInert = false;
}
// Restore the element to where the caller had it before we re-parented it into the host, rather
// than silently deleting their node from the document.
if (this._originalParent) {
const reference = this._originalNextSibling && this._originalNextSibling.parentNode === this._originalParent ? this._originalNextSibling : null;
this._originalParent.insertBefore(this.element, reference);
}
else {
this.element.remove();
}
// Remove the helper host we created (never the engine's borrowed rendering canvas).
if (this._ownsHost) {
this.host.remove();
}
}
this.onLoadObservable.clear();
super.dispose();
}
}
HtmlTexture._DefaultOptions = {
generateMipMaps: false,
samplingMode: 2,
format: 5,
autoUpdate: true,
useSvgFallback: true,
};
//# sourceMappingURL=htmlTexture.js.map