bevyengine / bevy-website

The source files for the official Bevy website
https://bevyengine.org
MIT License
199 stars 347 forks source link

Implementation of Loading-Bar functionality on the Web #338

Closed ickk closed 2 years ago

ickk commented 2 years ago

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:

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:

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.

doup commented 2 years ago

Hi! I've tried your approach, few notes:

// Inside example.html
import init from './{{ page.title }}.js';

const originalFetch = window.fetch;

window.fetch = async function progressive_fetch(resource) {
    // your code which uses `originalFetch`
};

init();

My impression is that all this information must come from the engine (Rust => JS). Also, how do we know how many assets will load? Etc.

Another issue is the loading UX, for know I've done this, which is… meh. 👇

https://user-images.githubusercontent.com/188612/163856488-29a1131c-625e-4c89-a9a5-0acc725341c3.mp4

ickk commented 2 years ago

I really appreciate you taking a look at this!


why not just monkey patch fetch on the example template

I explored the monkey patch route originally, and it's why I wrote progressive_fetch to allow an empty callbacks argument; so it could just be dropped in without breaking existing use-cases for fetch (obviously using changes similar to yours to get at the 'real' fetch inside the fn).

However, I'm not sure whether we want asset-loading to necessarily use progressive_fetch. Even if we do, @mockersf wanted the AssetServer to actually handle asset-loading progress in the engine itself (in Rust), since it would want the same kind of thing for loading-progress of files from disk.

Plus, given that we need to inject the callbacks arg value into the example anyway I don't really know what overwriting window.fetch buys us; I suppose we could hardcode those callbacks? 🙁

As far as injecting the code into the examples.. I don't know if there is a good way for that either, but our deployment script already makes use of sed quite a lot, so it seems as legitimate as the rest of our deployment. 😅

Given the above context, idk. Overwriting window.fetch feels more hacky to me than just providing our own different "fetch" function.

Once everything loads, there is an extra wait until the model renders, it looks like some internal pre-computation.

I suspected this would be the case, but it seems like something the engine needs to deal with. Possibly the AssetServer is responsible if there is computation that needs to be done to get a resource ready before use.