Skip to content

Download Progress Indicator

Description

When downloading large assets, the end user of your application may need to wait sometime.

The length of time will depend on many factors, such as size of file and quality of internet connection.

To indicate to the end user, how long they can expect to wait, it is useful to provide some kind of visual feedback showing the progress of a download.

Example Progress Indicator

We can use the browsers XMLHttpRequest web API to calculate the progress. The XMLHttpRequest registers a ProgressEvent which contains values that we can use.

All Threejs loaders that extend from the THREE.LoadingManager, can read the ProgressEvent via the onProgress callback.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
new GLTFLoader().load(
    'model.glb',
    function (gltf) {
        // inside the onLoad callback
        scene.add(gltf)
    },
    function (xhr) {
        // inside the onProgress callback (Optional)
        console.log((xhr.loaded / xhr.total) * 100 + '% loaded')
    },
    function (err) {
        // inside the onError callback (Optional)
        console.error('An error happened')
    }
)

One important thing to note about calculating the progress, is that we need to know the size of the file that is being downloaded. In the sample code above, note the property xhr.total in line 9.

Not all browser versions can read, or even web servers will provide, the Content-Length header in the HTML response that is used to get that total value.

The web server serving this website is a version of Nginx server that provides the Content-Length header value in responses by default. If your browser can read the total value, then you will notice that some of my other working Threejs examples throughout this site, do show a download progress indicator.

If you are developing your application using the Webpack Dev Server, as is taught in the course, then depending on the version you installed, it also may not send the Content-Length header in the HTML responses. So the xhr.total will always equate to 0. Line 9 above, (xhr.loaded / xhr.total), will equate to infinity since the xhr.loaded will be divided by 0.

One trick to enable the Webpack Dev Server to send the Content-Length header in responses, is to edit the ./src/client/webpack.dev.js of this course boilerplate, and add this highlighted line below.

const { merge } = require('webpack-merge')
const common = require('./webpack.common.js')
const path = require('path')

module.exports = merge(common, {
    mode: 'development',
    devtool: 'eval-source-map',
    devServer: {
        static: {
            directory: path.join(__dirname, '../../dist/client'),
        },
        hot: true,
        headers: { 'Content-Encoding': 'none' },
    },
})

After changing the above configuration, you will then need to stop and restart. CTRL+C, then npm run dev.

Example 1

This example below will show a download progress bar for the model as it is downloading.

<>

Note

If your browser has cached the model, then the download progress will be almost instant. Open the Developer tools on your browser, go to the Network tab and tick Disable cache. Then press the Refresh button in the working example again.

Disable Cache

./dist/client/index.html

In your index.html, add the #progressBar style, and a <progress> HMTL element before where the bundle.js is loaded.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charset="utf-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
        <title>Three.js TypeScript Tutorials by Sean Bradley</title>
        <style>
            body {
                overflow: hidden;
                margin: 0px;
            }

            #progressBar {
                width: 500px;
                height: 24px;
                position: absolute;
                left: 50%;
                top: 10px;
                margin-left: -250px;
            }
        </style>
    </head>

    <body>
        <progress value="0" max="100" id="progressBar"></progress>
        <script type="module" src="bundle.js"></script>
    </body>
</html>

./src/client/client.ts

In client.ts,

  • Create a reference to the progressBar HTML element (lines 30-32).
  • We can use it (line 43).
  • We can hide it in the loaders onSuccess callback before adding the downloaded model to the scene (line 38).
  • Download the Xbot model and save into the ./dist/client/models folder.

Resources

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
import * as THREE from 'three'
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader'
import Stats from 'three/examples/jsm/libs/stats.module'

const scene = new THREE.Scene()

const light = new THREE.DirectionalLight(0xffffff, 5)
light.position.set(1, 1, 1)
scene.add(light)

scene.add(new THREE.AmbientLight(0xffffff, 0.25))

const camera = new THREE.PerspectiveCamera(
    75,
    window.innerWidth / window.innerHeight,
    0.1,
    1000
)
camera.position.set(1.15, 1.15, 1.15)

const renderer = new THREE.WebGLRenderer()
renderer.setSize(window.innerWidth, window.innerHeight)
document.body.appendChild(renderer.domElement)

const controls = new OrbitControls(camera, renderer.domElement)
controls.enableDamping = true
controls.target.y = 1

const progressBar = document.getElementById(
    'progressBar'
) as HTMLProgressElement

const loader = new GLTFLoader()
loader.load(
    'models/xbot.glb',
    function (gltf) {
        progressBar.style.display = 'none'
        scene.add(gltf.scene)
    },
    (xhr) => {
        const percentComplete = (xhr.loaded / xhr.total) * 100
        progressBar.value = percentComplete === Infinity ? 100 : percentComplete
    }
)

function onWindowResize() {
    camera.aspect = window.innerWidth / window.innerHeight
    camera.updateProjectionMatrix()
    renderer.setSize(window.innerWidth, window.innerHeight)
    render()
}
window.addEventListener('resize', onWindowResize, false)

const stats = new Stats()
document.body.appendChild(stats.dom)

function animate() {
    requestAnimationFrame(animate)

    controls.update()

    render()

    stats.update()
}

function render() {
    renderer.render(scene, camera)
}

animate()

Example 2

Maybe you have several large assets to download. We can use the onProgress event in multiple loaders.

This example uses both the RGBELoader and GLTFLoader at the same time.

Download the kloppenheim_06_puresky_1k.hdr and save into the ./dist/client/img folder.

<>

./src/client/client.ts

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
import * as THREE from 'three'
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader'
import { RGBELoader } from 'three/examples/jsm/loaders/RGBELoader'
import Stats from 'three/examples/jsm/libs/stats.module'

const scene = new THREE.Scene()

new RGBELoader().load(
    './img/kloppenheim_06_puresky_1k.hdr',
    function (texture) {
        texture.mapping = THREE.EquirectangularReflectionMapping
        scene.background = texture
        scene.environment = texture
    },
    (xhr) => {
        const percentComplete = (xhr.loaded / xhr.total) * 100
        progressBar.value = percentComplete === Infinity ? 100 : percentComplete
    }
)

const camera = new THREE.PerspectiveCamera(
    75,
    window.innerWidth / window.innerHeight,
    0.1,
    1000
)
camera.position.set(1.15, 1.15, 1.15)

const renderer = new THREE.WebGLRenderer()
renderer.setSize(window.innerWidth, window.innerHeight)
document.body.appendChild(renderer.domElement)

const controls = new OrbitControls(camera, renderer.domElement)
controls.enableDamping = true
controls.target.y = 1

const progressBar = document.getElementById(
    'progressBar'
) as HTMLProgressElement

const loader = new GLTFLoader()
loader.load(
    'models/xbot.glb',
    function (gltf) {
        progressBar.style.display = 'none'
        scene.add(gltf.scene)
    },
    (xhr) => {
        const percentComplete = (xhr.loaded / xhr.total) * 100
        progressBar.value = percentComplete === Infinity ? 100 : percentComplete
    }
)

function onWindowResize() {
    camera.aspect = window.innerWidth / window.innerHeight
    camera.updateProjectionMatrix()
    renderer.setSize(window.innerWidth, window.innerHeight)
    render()
}
window.addEventListener('resize', onWindowResize, false)

const stats = new Stats()
document.body.appendChild(stats.dom)

function animate() {
    requestAnimationFrame(animate)

    controls.update()

    render()

    stats.update()
}

function render() {
    renderer.render(scene, camera)
}

animate()

XMLHttpRequest

ProgressEvent