Skip to content

Implementation of Loading-Bar functionality on the Web #338

@ickk

Description

@ickk

This issue intends to address issues raised in #236, with some concrete design discussions.

Context

Loading bevy on the web can take quite a long time depending on the user's network connection, and the size of the files involved.

Currently on the bevyengine website some of our example pages can take a very long time to load the main wasm file & display the canvas, and longer still to load the assets required - leaving the dreaded grey box linger. To a user this can seem like something is broken or frozen; they may refresh or click away before the page finishes loading. This is bad UX and clearly undesirable.

Concerns

There are two distinct items of concern:

  • loading status for the main wasm file,
  • loading status for subsequent downloads of assets.

Tracking the loading status of assets after the wasm module is running should be possible from inside rust/bevy, and might be possible to handle well with some modifications to AssetServer in a platform independent way.

However, tracking the loading status of the main wasm module is obviously not possible from within rust/bevy, as bevy's logic is contained within the wasm module itself! Therefore we need a javascript solution.

Investigation

On the web platform both the wasm module and subsequent asset downloads are handled through the browser's fetch API.

A Response is returned as soon as the fetch has received the headers, but Response does not provide a method to easily get at the current progress of the body (% of data actually loaded).

The response.body is a ReadableStream (part of the Web Streams API). A ReadableStream can only have one reader at a time (returned by the .getReader() method), and the data from a ReadableStream can only be read once.

The implications from this is that a Response is effectively a single use object, so we can not simply read the stream to count the data as it is loaded and then pass the same Response on to the caller.

The Web Streams API also specifies a .pipeThrough() method on ReadableStream. pipeThrough takes a TransformStream, which allows us to cleanly place a bit of code that can access the chunks as they are streamed through and simply pass that data (or a transformed version of that data) on to the receiver.

This would make it extremely ergonomic to extend the behaviour of fetch while providing pretty much the same API to consumers of the response.

It would be possible (but likely messier) to get some similar behaviour by either calling readableStream.tee() or response.clone(). There is also a proposal for a FetchObserver feature, but it has been stale for quite a long time, and it's WIP was removed from FF last year.

Implementation

async function progressive_fetch(resource, callbacks={}) {
  // Allow users to specify only the callbacks they need.
  const cb = Object.assign({
    start: (length) => {},
    progress: (progress, length) => {},
    finish: (length) => {},
  }, callbacks);

  let response = await fetch(resource);

  // get the length and initiallise progress to 0.
  const length = response.headers.get('content-length');
  let prog = 0;

  const transform = new TransformStream({
    start() {
      // When the Stream is first created, call the user-specified "start"
      // callback to do any setup required.
      cb.start(length);
    },
    transform(chunk, controller) {
      // See how much data has been loaded since we last checked, then call
      // the user-specified "progress" callback with the current progress &
      // total length.
      prog += chunk.byteLength;
      cb.progress(prog, length);
      // Simply pass through the data without touching it.
      controller.enqueue(chunk);
    },
    finish() {
      // When the Stream has finished, call the user-specified "finish" callback
      // to do any cleanup necessary.
      cb.finish(length);
    },
  });

  // Give the caller a new version of the Response where we pass its
  // ReadableStream through the user's TransformStream.
  return new Response(response.body.pipeThrough(transform), response)
}

Our implementation of progressive_fetch lets the user specify 3 callbacks:

  • one to perhaps create some DOM element,
  • one to update that element to reflect the current download progress, and
  • one perhaps to cleanup or change the DOM element when everything is finished.

Crucially the result of a progressive_fetch behaves identically to fetch as far as the consuming code is concerned.

Compared to a regular call to fetch:

wasm = fetch(wasm_url);
await load(await wasm, imports);

Using progressive fetch might look like:

// create a Node
let e = document.createElement("li");
document.getElementById("progress-bars").appendChild(e);
// call fetch with a callback that varies the width of the node with the
// progress of the download
wasm = progressive_fetch(wasm_url, {
  progress: (prog, len) => {
    e.style.width = 100*prog/len+"%";
  },
});
await load(await wasm, imports);

This is extremely customisable and ergonomic. Changing the exact behaviour and style of the loading bar is easy.

Integration

For our purposes, we need to replace the calls to fetch in the generated example.js files with a call to progressive_fetch. There may be a 'correct' way to do this, but we could fall back to sed if this is not easy:

sed 's/input = fetch(input)/input = progressive_fetch(input, ...)'

We obviously also need to provide a design for the loading bar itself, and add the relevant html and css to the template.

Problems

While this is in my opinion the cleanest way to provide this functionality, a big problem with using the Web Stream API is that Firefox does not fully implement the spec. Most notably, pipeThrough is missing. All other major browsers seem to support the functionality we need (except of course Internet Explorer, which doesn't support Web Assembly anyway).

There is a polyfill based on the WHATWG reference implementation.

Further Discussion

We need to determine whether Bevy's AssetServer can easily pull the information from the Response object of a regular window.fetch required to provide download-progress of assets in the engine.

An alternative would be to use switch to progressive-fetch in this case as well, and all the AssetServer would need to do is copy the value of the progress to a value it can track, however then users of bevy that deploy to the web themselves would need a copy of progressive_fetch.


I am not a web programmer by trade, so if there are more appropriate ways to implement this I would be interested to hear your feedback.

Metadata

Metadata

Assignees

No one assigned

    Labels

    A-ExamplesC-FeatureA new feature, making something new possible

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions