Hi there!!
I’m trying to load an HTML file (local or from a URL) inside the PlayCanvas 3D scene in order to generate 3D HUDs… I don’t want to create the code inside the .js file, but rather load it externally. I’m not sure if what I’m trying to do is crazy or if it’s even possible… Here’s the code I’ve managed to put together so far…
Many thanks!!
// html-as-texture.js (con contentScale + debugDraw)
/* global pc, html2canvas */
var HtmlAsTexture = pc.createScript('htmlAsTexture');
// ---------- Atributos configurables ----------
HtmlAsTexture.attributes.add('url', {
type: 'string',
default: 'https://example.com',
title: 'URL a embeber'
});
HtmlAsTexture.attributes.add('proxyUrl', {
type: 'string',
default: '',
title: 'Proxy (ej: https://tu-proxy/?url= )'
});
HtmlAsTexture.attributes.add('targetSelector', {
type: 'string',
default: '',
title: 'Selector dentro de la página (opcional)'
});
HtmlAsTexture.attributes.add('dpiScale', {
type: 'number',
default: 2,
title: 'Escala de raster (1–3 recomendado)'
});
// Nuevo: escala visual del contenido antes de capturar (zoom)
HtmlAsTexture.attributes.add('contentScale', {
type: 'number',
default: 2,
title: 'Zoom del contenido (1 = sin zoom)'
});
HtmlAsTexture.attributes.add('refreshMs', {
type: 'number',
default: 0,
title: 'Refresco (ms). 0 = una sola captura'
});
HtmlAsTexture.attributes.add('baseIframeSizePx', {
type: 'vec2',
default: [1280, 720],
title: 'Tamaño del iframe oculto (px)'
});
HtmlAsTexture.attributes.add('planeHeightWorld', {
type: 'number',
default: 1,
title: 'Alto del plano en unidades de mundo'
});
// Debug: dibuja borde y texto en el canvas para verificar que llega al material
HtmlAsTexture.attributes.add('debugDraw', {
type: 'boolean',
default: false,
title: 'Debug: dibujar borde/texto en la textura'
});
// ---------- Internos ----------
HtmlAsTexture.prototype.initialize = function () {
// Asegura un RenderComponent tipo 'plane'
if (!this.entity.render) {
this.entity.addComponent('render', { type: 'plane' });
}
// Material "unlit" usando emissive
this._material = new pc.StandardMaterial();
this._material.useLighting = false;
this._material.diffuse.set(0, 0, 0);
this._material.emissive.set(1, 1, 1);
this._material.cull = pc.CULLFACE_NONE; // ver por ambas caras
this._material.update();
this.entity.render.material = this._material;
this._texture = null;
this._intervalId = null;
// Crea iframe oculto
this._iframe = document.createElement('iframe');
this._iframe.style.position = 'absolute';
this._iframe.style.left = '-99999px';
this._iframe.style.top = '-99999px';
this._iframe.style.width = (this.baseIframeSizePx.x || 1280) + 'px';
this._iframe.style.height = (this.baseIframeSizePx.y || 720) + 'px';
this._iframe.style.border = '0';
// Sandbox (si usas proxy, intenta mantener same-origin)
this._iframe.setAttribute('sandbox', 'allow-same-origin allow-scripts allow-forms allow-pointer-lock');
var finalUrl = this.proxyUrl ? (this.proxyUrl + encodeURIComponent(this.url)) : this.url;
this._iframe.src = finalUrl;
document.body.appendChild(this._iframe);
this._onLoadBound = this._onIframeLoad.bind(this);
this._iframe.addEventListener('load', this._onLoadBound);
if (this.refreshMs > 0) {
this._intervalId = setInterval(this._captureAndUpload.bind(this), this.refreshMs);
}
};
HtmlAsTexture.prototype._onIframeLoad = function () {
console.log('[HtmlAsTexture] Iframe cargado. Esperando 2 segundos para capturar...');
// Espera 2 segundos (2000 ms) antes de la primera captura.
// Si la web es muy pesada, puedes aumentar este número a 3000 o 4000.
setTimeout(() => {
console.log('[HtmlAsTexture] ¡Ahora sí, capturando!');
this._captureAndUpload();
}, 2000);
};
// Limpieza del DOM clonado por html2canvas + zoom del contenido
HtmlAsTexture.prototype._cleanClone = function (doc) {
const scale = Math.max(1, this.contentScale || 1);
// 1) Estilos globales (apaga anim/transition, fija fondo y aplica zoom)
const style = doc.createElement('style');
style.textContent = `
* { animation: none !important; transition: none !important; }
html, body { overflow: hidden !important; background:#fff !important; margin:0 !important; padding:0 !important; }
body { transform: scale(${scale}); transform-origin: 0 0; }
`;
doc.head && doc.head.appendChild(style);
// 2) Elimina scripts en el clone
doc.querySelectorAll('script').forEach(s => s.remove());
// 3) Quita/inhabilita stylesheets de otro origen
doc.querySelectorAll('link[rel="stylesheet"]').forEach(link => {
try {
const sameOrigin = !link.href || link.href.startsWith(location.origin);
if (!sameOrigin) link.remove();
} catch (e) {
link.remove();
}
});
// 4) Elimina <style> o sheets que no se puedan leer (CORS/sintaxis)
Array.from(doc.styleSheets).forEach(ss => {
try {
void ss.cssRules && ss.cssRules.length;
} catch (e) {
if (ss.ownerNode && ss.ownerNode.parentNode) {
ss.ownerNode.parentNode.removeChild(ss.ownerNode);
}
}
});
// 5) Ajusta el tamaño "visual" del HTML según el zoom (para que html2canvas lo renderice más grande)
const w = (this.baseIframeSizePx.x || 1280) * scale;
const h = (this.baseIframeSizePx.y || 720) * scale;
const html = doc.documentElement;
const body = doc.body;
[html, body].forEach(el => {
if (!el) return;
el.style.width = w + 'px';
el.style.height = h + 'px';
});
};
HtmlAsTexture.prototype._getTargetNode = function () {
var doc = this._iframe.contentDocument;
if (!doc) return null;
if (this.targetSelector) {
var el = doc.querySelector(this.targetSelector);
if (el) return el;
}
return doc.documentElement || doc.body;
};
HtmlAsTexture.prototype._captureAndUpload = function () {
try {
var target = this._getTargetNode();
if (!target) return;
html2canvas(target, {
backgroundColor: '#ffffff', // fondo sólido
scale: Math.max(1, this.dpiScale || 1),
useCORS: true,
logging: false,
onclone: this._cleanClone.bind(this)
}).then((canvas) => {
// Debug opcional: dibuja borde y texto
if (this.debugDraw) {
const ctx = canvas.getContext('2d');
ctx.save();
ctx.lineWidth = 8;
ctx.strokeStyle = 'rgba(0,0,0,0.5)';
ctx.strokeRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = 'rgba(0,0,0,0.6)';
ctx.font = 'bold 64px sans-serif';
ctx.fillText('HTML-as-Texture OK', 40, 100);
ctx.restore();
}
console.log('[HtmlAsTexture] captura OK', canvas.width, canvas.height);
const gd = this.app.graphicsDevice;
if (!this._texture) {
this._texture = new pc.Texture(gd, {
width: canvas.width,
height: canvas.height,
format: pc.PIXELFORMAT_R8_G8_B8_A8,
mipmaps: false
});
this._texture.minFilter = pc.FILTER_LINEAR;
this._texture.magFilter = pc.FILTER_LINEAR;
this._texture.addressU = pc.ADDRESS_CLAMP_TO_EDGE;
this._texture.addressV = pc.ADDRESS_CLAMP_TO_EDGE;
// Asignamos la textura al material aquí, una sola vez
this._material.diffuseMap = this._texture;
// También ponemos el color difuso en blanco para no teñir la textura
this._material.diffuse.set(1, 1, 1);
// Ajusta escala del plano al aspect ratio
const aspect = canvas.width / canvas.height;
const h = this.planeHeightWorld > 0 ? this.planeHeightWorld : 1;
const w = h * aspect;
this.entity.setLocalScale(w, 1, h);
}
// Subir los datos del canvas a la textura existente
this._texture.setSource(canvas);
this._texture.upload(); // Es buena práctica llamarlo explícitamente
// Actualizamos el material para que refleje los cambios de la textura
this._material.update();
// Aseguramos que la entidad sigue usando este material
// (normalmente no es necesario, pero es una buena salvaguarda)
if (this.entity.render) {
this.entity.render.material = this._material;
}
console.log('[HtmlAsTexture] Textura aplicada al diffuseMap');
}).catch((err) => {
console.warn('[HtmlAsTexture] html2canvas falló:', err);
});
} catch (e) {
console.warn('[HtmlAsTexture] Captura fallida:', e);
}
};
HtmlAsTexture.prototype.onDestroy = function () {
if (this._intervalId) {
clearInterval(this._intervalId);
this._intervalId = null;
}
if (this._iframe) {
this._iframe.removeEventListener('load', this._onLoadBound);
this._iframe.remove();
this._iframe = null;
}
};