Embed HTML web inside 3d space

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;
    }
};