A LoadingManager passed to several loaders counts every file they request, including files a loader fetches on its own, and calls onStart, onProgress(url, loaded, total), onLoad and onError; drive a progress bar from loaded / total. loadCovers() accepts one, and the listing also loads the fox (Fox model by PixelMannen (CC0), rigging and animation by tomkranis (CC BY 4.0), glTF conversion by @AsoboStudio and @scurest (CC BY 4.0), via the Khronos glTF Sample Assets):
import * as THREE from 'three';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
import { loadCovers } from './bookshelf.js';
const manager = new THREE.LoadingManager();
const short = (url) => (url.startsWith('blob:') ? 'blob: (embedded image)' : url.slice(-20));
manager.onProgress = (url, loaded, total) => console.log(`${loaded}/${total}`, short(url));
manager.onLoad = () => console.log('all loaded');
manager.onError = (url) => console.log('failed:', url);
await Promise.all([loadCovers(manager), new GLTFLoader(manager).loadAsync('fox.glb')]);1/8 booknest-cover-1.png 2/8 booknest-cover-2.png 3/8 booknest-cover-3.png 4/8 booknest-cover-4.png 5/8 booknest-cover-5.png 6/8 booknest-cover-6.png 7/8 fox.glb 8/9 blob: (embedded image) 9/9 fox.glb all loaded
The total grew from 8 to 9: GLTFLoader registers the .glb once to download it and again while it parses, and the texture embedded in the file arrives as a blob: URL only after parsing starts. A progress bar therefore must tolerate a total that rises, and should hide on onLoad, not when loaded first equals total.
<!doctype html>
<style>
body { margin: 0; background: #f5f1e8; font: 13px system-ui, sans-serif; color: #263238; }
#stage { position: relative; width: 600px; max-width: 100%; }
canvas { display: block; max-width: 100%; }
#bar { position: absolute; left: 20px; right: 20px; top: 12px; height: 10px; background: #e0d8c8; border-radius: 5px; transition: opacity 0.5s; }
#fill { height: 100%; width: 0; background: #e09a10; border-radius: 5px; transition: width 0.2s; }
#log { margin: 4px 8px; font: 12px monospace; white-space: pre; }
</style>
<script type="importmap">
{ "imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.186.1/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.186.1/examples/jsm/" } }
</script>
<div id="stage"><div id="bar"><div id="fill"></div></div></div>
<button id="again">Load again</button>
<div id="log"></div>
<script type="module">
import * as THREE from 'three';
const colors = ['#1f5f8b', '#5b3f99', '#e09a10', '#3f7d3a', '#b5452f', '#2a9d8f'];
const titles = ['Harbor', 'Deep Web', 'Saffron', 'Summits', 'Clockmaker', 'Gardens'];
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.setSize(600, 220);
document.getElementById('stage').prepend(renderer.domElement);
const scene = new THREE.Scene();
scene.background = new THREE.Color('#fbf8f2');
const camera = new THREE.PerspectiveCamera(35, 600 / 220, 0.1, 50);
camera.position.set(0, 0, 3.4);
scene.add(new THREE.HemisphereLight('#ffffff', '#8d6e63', 2));
const books = colors.map((color, i) => {
const book = new THREE.Mesh(new THREE.PlaneGeometry(0.42, 0.6), new THREE.MeshStandardMaterial({ color: '#d8d0c0' }));
book.position.x = (i - 2.5) * 0.5;
scene.add(book);
return book;
});
// Generated covers as data URLs (a real site would request booknest-cover-N.png)
const coverURL = (i) => {
const c = Object.assign(document.createElement('canvas'), { width: 128, height: 180 });
const g = c.getContext('2d');
g.fillStyle = colors[i]; g.fillRect(0, 0, 128, 180);
g.fillStyle = '#fff3e0'; g.font = 'bold 20px Georgia'; g.fillText(titles[i], 10, 40, 108);
return c.toDataURL();
};
const fill = document.getElementById('fill'), bar = document.getElementById('bar'), log = document.getElementById('log');
const manager = new THREE.LoadingManager();
manager.onStart = () => { bar.style.opacity = 1; };
manager.onProgress = (url, loaded, total) => {
fill.style.width = `${loaded / total * 100}%`; // total may grow: never assume it is fixed
log.textContent += `${loaded}/${total} ${url.slice(0, 30)}…\n`;
};
manager.onLoad = () => { bar.style.opacity = 0; log.textContent += 'all loaded'; }; // hide here
manager.onError = (url) => { log.textContent += `failed: ${url}\n`; };
// A loader reports to its manager with itemStart(url) when a request goes out and
// itemEnd(url) when it is done. This one waits a while first, to imitate a slow network.
function loadSlowly(url, delay, onLoad) {
manager.itemStart(url);
setTimeout(() => new THREE.TextureLoader().load(url, (texture) => {
onLoad(texture);
manager.itemEnd(url);
}), delay);
}
function loadAll() {
log.textContent = '';
books.forEach((book, i) => {
book.material.map = null; book.material.color.set('#d8d0c0'); book.material.needsUpdate = true;
loadSlowly(coverURL(i), 300 + i * 300, (texture) => {
texture.colorSpace = THREE.SRGBColorSpace;
book.material.map = texture;
book.material.color.set('#ffffff');
book.material.needsUpdate = true;
});
});
}
document.getElementById('again').onclick = loadAll;
loadAll();
renderer.setAnimationLoop(() => renderer.render(scene, camera));
</script>