Skip to content

Commit 0fb1c97

Browse files
mvaligurskyMartin Valigursky
andauthored
Add HTML-in-Canvas support with texElementImage2D and interactive hit testing (#8519)
* Add HTML-in-Canvas support with texElementImage2D and interactive hit testing Support using HTML elements as live WebGL texture sources via the HTML-in-Canvas proposal. Add GraphicsDevice.supportsHtmlTextures public API, texElementImage2D upload path, and two examples demonstrating the feature. Made-with: Cursor * Fix ESLint no-use-before-define and quotes errors in examples Move app.on('destroy') handlers after the variables they reference. Made-with: Cursor * updates --------- Co-authored-by: Martin Valigursky <mvaligursky@snapchat.com>
1 parent e8bb5ad commit 0fb1c97

12 files changed

+803
-36
lines changed
7.47 MB
Binary file not shown.
336 KB
Loading

examples/src/examples/misc/html-texture-configurator.example.mjs

Lines changed: 494 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
// @config DESCRIPTION <div style="color:black">Renders live HTML content directly as a WebGL texture via the <b>HTML-in-Canvas</b> API (<code>texElementImage2D</code>).<br>Includes animated CSS gradients, text glow, and a pulsing circle — all driven by standard CSS.</div>
2+
//
3+
// This example demonstrates the HTML-in-Canvas API: a styled HTML element with
4+
// CSS animations is appended to a canvas marked with the "layoutsubtree"
5+
// attribute, then captured into a WebGL texture via texElementImage2D.
6+
//
7+
// Fallback: when device.supportsHtmlTextures is false, a static 2D canvas with
8+
// hand-drawn placeholder graphics is used as the texture source instead.
9+
//
10+
import { deviceType } from 'examples/utils';
11+
import * as pc from 'playcanvas';
12+
13+
const canvas = /** @type {HTMLCanvasElement} */ (document.getElementById('application-canvas'));
14+
15+
// Enable layoutsubtree for HTML-in-Canvas support
16+
canvas.setAttribute('layoutsubtree', 'true');
17+
18+
window.focus();
19+
20+
const gfxOptions = {
21+
deviceTypes: [deviceType]
22+
};
23+
24+
const device = await pc.createGraphicsDevice(canvas, gfxOptions);
25+
device.maxPixelRatio = Math.min(window.devicePixelRatio, 2);
26+
27+
const createOptions = new pc.AppOptions();
28+
createOptions.graphicsDevice = device;
29+
30+
createOptions.componentSystems = [pc.RenderComponentSystem, pc.CameraComponentSystem, pc.LightComponentSystem];
31+
createOptions.resourceHandlers = [pc.TextureHandler, pc.ContainerHandler];
32+
33+
const app = new pc.AppBase(canvas);
34+
app.init(createOptions);
35+
app.start();
36+
37+
app.setCanvasFillMode(pc.FILLMODE_FILL_WINDOW);
38+
app.setCanvasResolution(pc.RESOLUTION_AUTO);
39+
40+
const resize = () => app.resizeCanvas();
41+
window.addEventListener('resize', resize);
42+
43+
// Create an HTML element to use as texture source.
44+
// Per the HTML-in-Canvas proposal, the element must be a direct child of the canvas.
45+
// The 'inert' attribute prevents hit testing on the element.
46+
const htmlElement = document.createElement('div');
47+
htmlElement.setAttribute('inert', '');
48+
htmlElement.style.width = '512px';
49+
htmlElement.style.height = '512px';
50+
htmlElement.style.padding = '10px';
51+
htmlElement.style.background = 'linear-gradient(45deg, #ff6b6b, #4ecdc4, #45b7d1, #f9ca24, #ff6b6b, #4ecdc4, #45b7d1, #f9ca24)';
52+
htmlElement.style.backgroundSize = '400% 400%';
53+
htmlElement.style.animation = 'gradient-shift 4s ease infinite';
54+
htmlElement.style.borderRadius = '0';
55+
htmlElement.style.fontFamily = 'Arial, sans-serif';
56+
htmlElement.style.fontSize = '24px';
57+
htmlElement.style.color = 'white';
58+
htmlElement.style.textAlign = 'center';
59+
htmlElement.style.display = 'flex';
60+
htmlElement.style.flexDirection = 'column';
61+
htmlElement.style.justifyContent = 'center';
62+
htmlElement.style.alignItems = 'center';
63+
htmlElement.innerHTML = `
64+
<h1 style="margin: 0 0 20px 0; animation: glow 3s ease-in-out infinite;">HTML in Canvas!</h1>
65+
<p style="margin: 0; text-shadow: 1px 1px 2px rgba(0,0,0,0.5);">This texture is rendered from HTML using texElementImage2D</p>
66+
<div style="margin-top: 20px; width: 50px; height: 50px; border-radius: 50%; animation: pulse 2s infinite;"></div>
67+
`;
68+
69+
const style = document.createElement('style');
70+
style.textContent = `
71+
@keyframes glow {
72+
0%, 100% { color: white; text-shadow: 0 0 10px rgba(0,0,0,0.8), 0 0 20px rgba(0,0,0,0.4); font-size: 42px; }
73+
50% { color: #f9ca24; text-shadow: 0 0 15px rgba(0,0,0,0.8), 0 0 30px #f9ca24, 0 0 60px #f9ca24, 0 0 90px rgba(249,202,36,0.4); font-size: 48px; }
74+
}
75+
@keyframes gradient-shift {
76+
0% { background-position: 0% 50%; }
77+
50% { background-position: 100% 50%; }
78+
100% { background-position: 0% 50%; }
79+
}
80+
@keyframes pulse {
81+
0% { transform: scale(1); background: #ff6b6b; }
82+
25% { transform: scale(1.2); background: #f9ca24; }
83+
50% { transform: scale(1); background: #4ecdc4; }
84+
75% { transform: scale(1.2); background: #45b7d1; }
85+
100% { transform: scale(1); background: #ff6b6b; }
86+
}
87+
`;
88+
document.head.appendChild(style);
89+
90+
canvas.appendChild(htmlElement);
91+
92+
// Create texture
93+
const htmlTexture = new pc.Texture(device, {
94+
width: 512,
95+
height: 512,
96+
format: pc.PIXELFORMAT_RGBA8,
97+
name: 'htmlTexture'
98+
});
99+
100+
// Fallback canvas texture for browsers without texElementImage2D support
101+
const createFallbackTexture = () => {
102+
const fallbackCanvas = document.createElement('canvas');
103+
fallbackCanvas.width = 512;
104+
fallbackCanvas.height = 512;
105+
const ctx = fallbackCanvas.getContext('2d');
106+
if (!ctx) return null;
107+
108+
const gradient = ctx.createLinearGradient(0, 0, 512, 512);
109+
gradient.addColorStop(0, '#ff6b6b');
110+
gradient.addColorStop(0.33, '#4ecdc4');
111+
gradient.addColorStop(0.66, '#45b7d1');
112+
gradient.addColorStop(1, '#f9ca24');
113+
ctx.fillStyle = gradient;
114+
ctx.fillRect(0, 0, 512, 512);
115+
116+
ctx.fillStyle = 'white';
117+
ctx.font = 'bold 36px Arial';
118+
ctx.textAlign = 'center';
119+
ctx.shadowColor = 'rgba(0,0,0,0.5)';
120+
ctx.shadowBlur = 4;
121+
ctx.shadowOffsetX = 2;
122+
ctx.shadowOffsetY = 2;
123+
ctx.fillText('HTML in Canvas!', 256, 180);
124+
125+
ctx.font = '20px Arial';
126+
ctx.fillText('(Canvas Fallback)', 256, 220);
127+
ctx.fillText('texElementImage2D not available', 256, 260);
128+
129+
ctx.beginPath();
130+
ctx.arc(256, 320, 25, 0, 2 * Math.PI);
131+
ctx.fillStyle = 'white';
132+
ctx.fill();
133+
134+
return fallbackCanvas;
135+
};
136+
137+
// Start with fallback texture, then switch to HTML source once the paint record is ready
138+
const fallbackCanvas = createFallbackTexture();
139+
if (fallbackCanvas) {
140+
htmlTexture.setSource(fallbackCanvas);
141+
}
142+
143+
const onPaintUpload = () => {
144+
if (!app.graphicsDevice) return;
145+
htmlTexture.upload();
146+
};
147+
148+
app.on('destroy', () => {
149+
window.removeEventListener('resize', resize);
150+
canvas.removeEventListener('paint', onPaintUpload);
151+
if (htmlElement.parentNode) htmlElement.parentNode.removeChild(htmlElement);
152+
if (style.parentNode) style.parentNode.removeChild(style);
153+
});
154+
155+
if (device.supportsHtmlTextures) {
156+
// The browser must paint the HTML element before texElementImage2D can use it.
157+
// Wait for the 'paint' event, then set the HTML element as the texture source.
158+
canvas.addEventListener('paint', () => {
159+
htmlTexture.setSource(/** @type {any} */ (htmlElement));
160+
}, { once: true });
161+
canvas.requestPaint();
162+
163+
// Re-upload the texture whenever the browser repaints the HTML children
164+
canvas.addEventListener('paint', onPaintUpload);
165+
} else {
166+
console.warn('HTML textures are not supported - using canvas fallback');
167+
}
168+
169+
// Create material with the HTML texture
170+
const material = new pc.StandardMaterial();
171+
material.diffuseMap = htmlTexture;
172+
material.update();
173+
174+
const box = new pc.Entity('cube');
175+
box.addComponent('render', {
176+
type: 'box',
177+
material: material
178+
});
179+
app.root.addChild(box);
180+
181+
const camera = new pc.Entity('camera');
182+
camera.addComponent('camera', {
183+
clearColor: new pc.Color(1, 1, 1)
184+
});
185+
app.root.addChild(camera);
186+
camera.setPosition(0, 0, 3);
187+
188+
app.scene.ambientLight = new pc.Color(0.3, 0.3, 0.3);
189+
190+
const light = new pc.Entity('light');
191+
light.addComponent('light');
192+
app.root.addChild(light);
193+
light.setEulerAngles(45, 0, 0);
194+
195+
app.on('update', (/** @type {number} */ dt) => {
196+
box.rotate(3 * dt, 5 * dt, 6 * dt);
197+
});
198+
199+
export { app };
4.19 KB
Loading
482 Bytes
Loading
1.48 KB
Loading
256 Bytes
Loading

src/platform/graphics/graphics-device.js

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -400,6 +400,16 @@ class GraphicsDevice extends EventHandler {
400400
*/
401401
supportsShaderF16 = false;
402402

403+
/**
404+
* True if HTML elements (e.g. `<div>`) can be used as texture sources via the HTML-in-Canvas
405+
* API. When supported, an HTML element appended to a canvas with the `layoutsubtree` attribute
406+
* can be passed to {@link Texture#setSource} and rendered as a live texture in the 3D scene.
407+
*
408+
* @type {boolean}
409+
* @readonly
410+
*/
411+
supportsHtmlTextures = false;
412+
403413
/**
404414
* True if 32-bit floating-point textures can be used as a frame buffer.
405415
*
@@ -1054,17 +1064,18 @@ class GraphicsDevice extends EventHandler {
10541064
}
10551065

10561066
/**
1057-
* Reports whether a texture source is a canvas, image, video or ImageBitmap.
1067+
* Reports whether a texture source is a canvas, image, video, ImageBitmap, or HTML element.
10581068
*
10591069
* @param {*} texture - Texture source data.
1060-
* @returns {boolean} True if the texture is a canvas, image, video or ImageBitmap and false
1061-
* otherwise.
1070+
* @returns {boolean} True if the texture is a canvas, image, video, ImageBitmap, or HTML
1071+
* element and false otherwise.
10621072
* @ignore
10631073
*/
10641074
_isBrowserInterface(texture) {
10651075
return this._isImageBrowserInterface(texture) ||
10661076
this._isImageCanvasInterface(texture) ||
1067-
this._isImageVideoInterface(texture);
1077+
this._isImageVideoInterface(texture) ||
1078+
this._isHTMLElementInterface(texture);
10681079
}
10691080

10701081
_isImageBrowserInterface(texture) {
@@ -1080,6 +1091,22 @@ class GraphicsDevice extends EventHandler {
10801091
return (typeof HTMLVideoElement !== 'undefined' && texture instanceof HTMLVideoElement);
10811092
}
10821093

1094+
/**
1095+
* Reports whether a texture source is a generic HTML element (not image, canvas, or video).
1096+
* Used for the HTML-in-Canvas proposal (texElementImage2D).
1097+
*
1098+
* @param {*} texture - Texture source data.
1099+
* @returns {boolean} True if the texture is an HTMLElement that is not an image, canvas, or
1100+
* video.
1101+
* @ignore
1102+
*/
1103+
_isHTMLElementInterface(texture) {
1104+
return (typeof HTMLElement !== 'undefined' && texture instanceof HTMLElement &&
1105+
!(typeof HTMLImageElement !== 'undefined' && texture instanceof HTMLImageElement) &&
1106+
!(typeof HTMLCanvasElement !== 'undefined' && texture instanceof HTMLCanvasElement) &&
1107+
!(typeof HTMLVideoElement !== 'undefined' && texture instanceof HTMLVideoElement));
1108+
}
1109+
10831110
/**
10841111
* Sets the width and height of the canvas, then fires the `resizecanvas` event. Note that the
10851112
* specified width and height values will be multiplied by the value of

src/platform/graphics/texture.js

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1052,16 +1052,31 @@ class Texture {
10521052
}
10531053

10541054
/**
1055-
* Set the pixel data of the texture from a canvas, image, video DOM element. If the texture is
1056-
* a cubemap, the supplied source must be an array of 6 canvases, images or videos.
1055+
* Set the pixel data of the texture from a canvas, image, video, or HTML DOM element. If the
1056+
* texture is a cubemap, the supplied source must be an array of 6 canvases, images or videos.
10571057
*
1058-
* @param {HTMLCanvasElement|HTMLImageElement|HTMLVideoElement|HTMLCanvasElement[]|HTMLImageElement[]|HTMLVideoElement[]} source - A
1059-
* canvas, image or video element, or an array of 6 canvas, image or video elements.
1058+
* Note: using an HTML element (e.g. `<div>`) as a source requires
1059+
* {@link GraphicsDevice#supportsHtmlTextures} to be true.
1060+
*
1061+
* @param {HTMLCanvasElement|HTMLImageElement|HTMLVideoElement|HTMLElement|HTMLCanvasElement[]|HTMLImageElement[]|HTMLVideoElement[]|HTMLElement[]} source - A
1062+
* canvas, image, video, or HTML element, or an array of 6 canvas, image, video, or HTML
1063+
* elements.
10601064
* @param {number} [mipLevel] - A non-negative integer specifying the image level of detail.
10611065
* Defaults to 0, which represents the base image source. A level value of N, that is greater
10621066
* than 0, represents the image source for the Nth mipmap reduction level.
10631067
*/
10641068
setSource(source, mipLevel = 0) {
1069+
if (this.device._isHTMLElementInterface(source)) {
1070+
if (!this.device.supportsHtmlTextures) {
1071+
Debug.error('Texture#setSource: HTML element textures are not supported on this device. Check device.supportsHtmlTextures before calling setSource with an HTML element.');
1072+
return;
1073+
}
1074+
if (this._cubemap || this._volume) {
1075+
Debug.error('Texture#setSource: HTML element textures can only be used with 2D textures, not cubemaps or volume textures.');
1076+
return;
1077+
}
1078+
}
1079+
10651080
let invalid = false;
10661081
let width, height;
10671082

@@ -1110,6 +1125,10 @@ class Texture {
11101125
if (source instanceof HTMLVideoElement) {
11111126
width = source.videoWidth;
11121127
height = source.videoHeight;
1128+
} else if (this.device._isHTMLElementInterface(source)) {
1129+
const rect = source.getBoundingClientRect();
1130+
width = Math.floor(rect.width) || 1;
1131+
height = Math.floor(rect.height) || 1;
11131132
} else {
11141133
width = source.width;
11151134
height = source.height;

0 commit comments

Comments
 (0)