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!
<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;
}
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.
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 />
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;
}