Promise loading with Three.js

Szenia Zadvornykh
ITNEXT
Published in
4 min readApr 30, 2018

--

This is a quick post about my recent experiences using (native) ES6 promises to simplify loading handling in Three.js projects.

Going by Three.js examples, a common pattern for loading a geometry and corresponding textures looks something like this:

const material = new THREE.MeshStandardMaterial({
map: new THREE.TextureLoader().load('map.jpg'),
normalMap: new THREE.TextureLoader().load('normalMap.jpg')
});
const loader = new THREE.JSONLoader();loader.load('geometry.json', geometry => {
const mesh = new THREE.Mesh(geometry, material);

scene.add(mesh);
});

TextureLoader.load() returns a Texture, which is updated when the image is loaded. JSONLoader.load() is passed a onComplete callback, which is called when the JSON is loaded and processed. When the callback is called, a Mesh is created, whether the textures are loaded or not.

Sometimes this behavior is desired; you’re showing something as soon as possible. But there are also some issues, which I’m sure you’ve all seen before. If the geometry is loaded before the textures, the model appears untextured, naked, before the textures pop in one by one. As they do, there is a noticeable frame drop while they are processed and uploaded to the GPU.

Fortunately, these issues are easy to fix using Promises.

First, let’s make a wrapper function around a JSONLoader and a Promise.

function loadJSON(url) {
return new Promise(resolve => {
new THREE.JSONLoader.load(url, resolve);
});
}

This function returns a Promise that resolves with the loaded geometry when the file is loaded.

Next we can make a similar wrapper around a TextureLoader.

function loadJSON(url) {
return new Promise(resolve => {
new THREE.TextureLoader().load(url, resolve);
});
}

Note that the TextureLoader also has an onComplete callback like the TextureLoader, though it’s used less frequently. In fact, I’m pretty sure all Three.js loaders conform to this convention.

Now let’s write another wrapper around the texture promises, which will resolve with a Material instance.

function loadMaterial() {
const textures = {
map: 'map.jpg',
normalMap: 'normalMap.jpg'
};

const params = {};

const promises = Object.keys(textures).map(key => {
return loadTexture(textures[key]).then(texture => {
params[key] = texture;
});
});

return Promise.all(promises).then(() => {
return new THREE.MeshStandardMaterial(params);
});
}

Promise.all() takes an array of promises and returns a Promise that resolves once all of the sub-promises are resolved.

Next we can combine the geometry and material promise wrappers into one final majestic wrapper function, once again relying on Promise.all().

function loadMesh() {
const promises = [
loadGeometry(),
loadMaterial()
];

return Promise.all(promises).then(result => {
return new THREE.Mesh(result[0], result[1]);
});
}

Promise.all() resolves with an array of values in the same order as the promises (or values) you supplied. In this case the result array looks like [geometry, material].

With the code above, we have essentially created a mini-loading manager that creates a Mesh once all of its assets have been loaded, all with very little (native) JavaScript. Neat.

Let’s flesh it out a little more. The code below is only one of many possible ways of going about this.

const model = {
geometry: {
url: 'geometry.json'
},
material: {
map: 'map.jpg',
normalMap: 'normalMap.jpg',
metalness: 0.0,
roughness: 1.0
}
};
loadMesh(model).then(mesh => {
scene.add(mesh);
});
function loadMesh(model) {
const promises = [
loadGeometry(model.geometry),
loadMaterial(model.material)
];

return Promise.all(promises).then(result => {
return new THREE.Mesh(result[0], result[1]);
});
}

function loadGeometry(model) {
return new Promise(resolve => {
new THREE.JSONLoader().load(model.url, resolve);
});
}

const textureKeys = ['map', 'normalMap']; // etc...

function loadMaterial(model) {
const params = {};
const promises = Object.keys(model).map(key => {
// load textures for supported keys
if (textureKeys.indexOf(key) !== -1) {
return loadTexture(model[key]).then(texture => {
params[key] = texture;
});
// just copy the value otherwise
} else {
params[key] = model[key];
}
});

return Promise.all(promises).then(() => {
return new THREE.MeshStandardMaterial(params);
});
}

The model describes the assets and any additional settings for the Mesh. This is a useful abstraction. We can create many models, and use the code above to create the corresponding meshes. If you need to manage many models at the same time, the createMesh function can be put into another Promise.all() easily. It’s promises all the way down.

const promises = models.map(model => {
return loadMesh(model).then(mesh => {
scene.add(mesh);
});
});
Promise.all(promises).then(() => {
// load complete! begin rendering
});

Another upside of this approach is that promises can be resolved with with exiting or cached values without any changes to our API. This makes it easy to mix loaded and generated assets.

const model = {
geometry: {
geometry: new THREE.SphereGeometry()
},
material: {...}
};

...

function loadGeometry(model) {
if (model.geometry) {
return Promise.resolve(model.geometry);
}

if (model.url) {
return new Promise(resolve => {
new JSONLoader().load(model.url, resolve);
});
}
}

I’ve written a handful of loading managers over the years, and I can tell you that promises simplify things greatly. You will still need to add logic of your own (caching, error handling, different loaders, additional parameters, etc), but not having to deal with concurrent asynchronous requests yourself is a huge time saver.

The snippets from this post can be found here. Snippet 06 contains all the final functions, and should work if you paste it.

I used a similar approach as outlined in this post in https://nike-react.com/, which was by far the most asset management heavy project I’ve ever worked on. Promises, especially Promise.all(), were instrumental in keeping things manageable.

--

--

Creative coder with an interest in art and game design. Tech Lead @dpdk NYC.