Search code examples
javascripthtmliframethree.js

Three.js "Context Lost" and "Multiple instances being imported"?


I'm trying to create a 3D editor, where the user can edit a 3D scene, and then hit a "play" button and see the result. To render the result, I'm using an iframe. Here is my HTML code:

<iframe id="testing_frame" class="ui"></iframe>

The class ui is just position: absolute; top: 0;. I don't want to have a URL or FILE as the src to this iframe, instead I want to write directly to it. Here is how I do it:

generatedCode += `
    <!DOCTYPE html>
    <html>

    <head>
        <script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r134/three.min.js"><\/script>
    </head>

    <body style="margin: 0;">
        <script async>"use strict"
            function init(){
                var scene = new THREE.Scene();
                var camera = new THREE.PerspectiveCamera(90, window.innerWidth / window.innerHeight, 0.1, 1000);
                var mesh = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1), new THREE.MeshBasicMaterial({
                    color: 0xff0000
                }));
                scene.add(mesh);
                camera.position.z = -5;
                camera.lookAt(0, 0, 0);
                var renderer = new THREE.WebGLRenderer();
                renderer.setSize(window.innerWidth, window.innerHeight);
                document.body.appendChild(renderer.domElement);

                function animate() {
                    requestAnimationFrame(animate);
                    renderer.render(scene, camera);
                }
                animate();
            }

            window.onload = init;
        <\/script>
    </body>

    </html>`;

That code is stored in the variable generatedCode, which is what I will write to the iframe, here:

var iframe = document.getElementById("testing_frame");
var iframeDocument = iframe.contentDocument || iframe.contentWindow.document;
    
iframeDocument.open();
iframeDocument.write(generatedCode);
iframeDocument.close();

This works fine.

My Problem: I have a start/stop button, which runs this code everytime it is clicked. Each time I hit stop, it says WARNING: Multiple instances of Three.js being imported., and if I start and stop the testing iframe around 10 times, it says THREE.WebGLRenderer: Context Lost.

Here I have a video demonstrating my problem. (Don't worry about the things in the console before I start doing anything)

Thanks!

Edit: Here is the start/stop code:

<button id='play_btn' onClick='test_iframe();';>Play</button>

This is the button, below is the test_iframe() function:

function test_iframe() {
    let code = generateCodeFromProjectData();
    var iframe = document.getElementById("testing_frame");
    var iframeDocument = iframe.contentDocument || iframe.contentWindow.document;
    
    //Write to iframe
    iframeDocument.open();
    iframeDocument.write(code);
    iframeDocument.close();

    //If they press play, change it to stop and show the iframe.
    if(document.getElementById("play_btn").innerHTML == "Play"){
        document.getElementById("testing_frame").style.display = "block";
        document.getElementById("play_btn").innerHTML = "Stop";
    } else { //If they press stop, hide the iframe and change it to play.
        document.getElementById("testing_frame").style.display = "none";
        document.getElementById("play_btn").innerHTML = "Play";
    }
}

Finally, here is the generateCodeFromProjectData() function, which receives the code:

function generateCodeFromProjectData(){
    generatedCode = "";
    //Opening
    generatedCode += `
    <!DOCTYPE html>
    <html>

    <head>
        <script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r134/three.min.js"><\/script>
    </head>

    <body style="margin: 0;">
        <script async>"use strict"
            function init(){
                var scene = new THREE.Scene();
                var camera = new THREE.PerspectiveCamera(90, window.innerWidth / window.innerHeight, 0.1, 1000);
                var mesh = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1), new THREE.MeshBasicMaterial({
                    color: 0xff0000
                }));
                scene.add(mesh);
                camera.position.z = -5;
                camera.lookAt(0, 0, 0);
                var renderer = new THREE.WebGLRenderer();
                renderer.setSize(window.innerWidth, window.innerHeight);
                document.body.appendChild(renderer.domElement);

                function animate() {
                    requestAnimationFrame(animate);
                    renderer.render(scene, camera);
                }
                animate();
            }

            window.onload = init;
        <\/script>
    </body>

    </html>`;

    return generatedCode;
}

Edit 2: Here's my new code for the `iframe`

generatedCode += `
    <!DOCTYPE html>
    <html>

    <head>
        <script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r134/three.min.js"><\/script>
    </head>

    <body style="margin: 0;">
        <script>
            function init(){
                var scene = new THREE.Scene();
                var camera = new THREE.PerspectiveCamera(90, window.innerWidth / window.innerHeight, 0.1, 1000);
                var mesh = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1), new THREE.MeshBasicMaterial({
                    color: 0xff0000
                }));
                scene.add(mesh);
                camera.position.z = -5;
                camera.lookAt(0, 0, 0);
                var renderer = new THREE.WebGLRenderer();
                renderer.setSize(window.innerWidth, window.innerHeight);
                document.body.appendChild(renderer.domElement);

                function destroy() {
                    scene = null;
                    camera = null;
                    mesh = null;
                    renderer.dispose()
                }

                return {destroy};

                function animate() {
                    requestAnimationFrame(animate);
                    renderer.render(scene, camera);
                }
                animate();
            }
            window.onload = () => {
                var instance = init();
                window.onbeforeunload = () => {
                  instance.destroy();
                }
              }
        <\/script>
    </body>

    </html>`;

Now I get no error for Context Lost, but the red cube in the center does not show up.


Solution

  • It seems like a memory leak to me. Anything happened inside <iframe /> stays in the memory unless it is deallocated manually or the tab(window) is closed. I've experienced exact same problem when using storybook with different frontend libraries; storybook also uses <iframe /> as an isolated demonstration, but it always needed manual deallocation.

    Manual memory deallocation, or dispose() function is necessary in Three.JS as well. Here's an excerpt from the official Three.js document:

    Why can't three.js dispose objects automatically?

    This question was asked many times by the community so it's important to clarify this matter. Fact is that three.js does not know the lifetime or scope of user-created entities like geometries or materials. This is the responsibility of the application. For example even if a material is currently not used for rendering, it might be necessary for the next frame. So if the application decides that a certain object can be deleted, it has to notify the engine via calling the respective dispose() method.

    It says the dispose is necessary for Three.Object3D and says nothing about WebGLRender. Interestingly enough, the dispose() function is listed as WebGLRenderer's spec.

    https://threejs.org/docs/?q=renderer#api/en/renderers/WebGLRenderer

    to sync the dispose action to iframes, use window.onbeforeunload event handler or add dispose function to your page move action.

    // add this code to your iframe code.
    function init () {
      var scene = // ...
      var camera = // ...
      var mesh = // ...
      var renderer = // ...
      /* ... */
      function destroy() {
        scene = null;
        camera = null;
        mesh = null;
        renderer.dispose()
      }
      return {destroy};
    }
    window.onload = () => {
      var instance = init();
      window.onbeforeunload = () => {
        instance.destroy();
      }
    }
    
    

    edit: the answer only solves the context lost issue. It has no problem in terms of destroying the instance. Multiple instances being imported should be regarded as another problem. Anything that's added inside <iframe/> is considered in the same scope as its parent. adding <script src="..." /> inside iframe again and again causes multiple instances being imported. So to solve this issue, code must be provided from its parent.

    • generatedCode should not include the <script />
    • code should be provided in the same scope as the iframe controller code, so that it can sync the lifecycle of iframe and three.js renderer.

    it's little bit more complex than disposing. here's the working code and working demo at codepen

    <script src="your three.js url"></script>
    <body>
    <div id="root">
    </div>
    
    <button id='play_btn' onClick='test_iframe()'>Play</button>
    </body>
    
    // multiple instances being imported & context lost issue solved
    function init(width, height) {
      console.log("init", width, height);
      var scene = new THREE.Scene();
      var camera = new THREE.PerspectiveCamera(90, width / height, 0.1, 1000);
      var mesh = new THREE.Mesh(
        new THREE.BoxGeometry(1, 1, 1),
        new THREE.MeshBasicMaterial({
          color: 0xff0000
        })
      );
      scene.add(mesh);
      camera.position.z = 5;
      camera.lookAt(0, 0, 0);
      var renderer = new THREE.WebGLRenderer();
      renderer.setSize(width, height);
    
      function destroy() {
        console.log("destroy");
        scene = null;
        camera = null;
        mesh = null;
        renderer.dispose();
      }
    
      function animate() {
        if (!renderer) return;
        requestAnimationFrame(animate);
        renderer.render(scene, camera);
      }
      animate();
      return { destroy, domElement: renderer.domElement };
    }
    
    let iframe;
    const getIframe = () => {
      const code = generateCodeFromProjectData();
      const root = document.getElementById("root");
      const iframe = document.createElement("iframe");
      iframe.setAttribute("id", "testing_frame");
      iframe.setAttribute("class", "ui");
      iframe.style.width = "300px";
      iframe.style.height = "300px";
      root.appendChild(iframe);
      const iframeDoc = iframe.contentDocument || iframe.contentWindow.document;
      iframeDoc.open();
      iframeDoc.write(code);
      iframeDoc.close();
      const body = iframeDoc.querySelector("body");
      const instance = init(body.clientWidth, body.clientHeight);
      body.appendChild(instance.domElement);
      function destroy() {
        root.removeChild(iframe);
        instance.destroy();
      }
      return { iframe, iframeDoc, destroy };
    };
    
    function test_iframe() {
      if (!iframe) {
        // init
        console.log("init");
        iframe = getIframe();
        document.getElementById("play_btn").innerHTML = "Stop";
        return;
      }
      console.log("dispose");
      // disposing
      document.getElementById("play_btn").innerHTML = "Play";
      iframe.destroy();
      iframe = null;
    }
    
    function generateCodeFromProjectData() {
      generatedCode = "";
      //Opening
      generatedCode += `
        <!DOCTYPE html>
        <html>
        <body style="margin: 0;width: 300px;height:300px;">
        </body>
        </html>`;
    
      return generatedCode;
    }