Sparrow WebGL Devlog 17: Loading Time Optimizations

Asset Loading Priorities, Loading Screen, GLB Support, and Procedural Water Texture Generation

12.02.2024 - 17:48
The biggest weakness of WebGL websites is their loading times. A local OpenGL application can load large assets in milliseconds from an SSD. However, WebGL assets must be downloaded from the internet first, which is significantly slower than local storage.

For the last few months, I have been working on a new portfolio website that relies heavily on WebGL. When somebody visits the website and nothing happens for a few seconds while assets are being downloaded, they might leave before anything gets rendered. To improve the user experience, I spent a lot of time optimizing the loading times and made massive improvements.

Asset Loader

Previously, loading models and other assets looked something like this:

var model = Sparrow.ModelLoader.loadGLTF( engine , “model.gtlf” , () => {
// onload
} );

The loadGLTF function started an XMLHttpRequest that immediately began downloading the model. Because of the asynchronous nature of XMLHttpRequests the code execution continued and once the download was complete, the onload callback would be triggered. This may not sound too bad because the program doesn’t have to wait for the download to finish. However, you have to keep in mind that we typically need more than one asset and the initialization looks more like this:

var model1 = loadGLTF();
var model2 = loadGLTF();
var texture1 = loadTexture();
var animatedModel1 = loadAnimatedModel();
var texture2 = loadTexture();
// potentially a lot more assets

Because the first loadGLTF() function doesn’t block the code execution (and it shouldn’t), all following asset downloads also start immediately and the bandwidth is shared between them, slowing everything down.

However, not every asset is the same, some are way more important than others. To start rendering, we need the terrain mesh and major landmarks, while we can probably live with some smaller details popping in later.

You could write custom code to prioritize the asset loading, but especially for WebGL with its much slower loading times, this should be an engine-level feature.

Therefore I created a new AssetLoader class for my Sparrow WebGL engine. It replaces all other systems that were responsible for downloading anything. The signatures of the loading functions look very similar to before with the addition of a priority parameter. This parameter can either be REQUIRED, IMPORTANT, or DEFAULT.

Unlike before, calling the loadModel() function will not start the download immediately. Instead, all load requests are saved in a queue, and when the main loop of the engine is invoked, only the REQUIRED asset downloads are started. While waiting for the assets, the engine renders a simple loading screen (see below) and once all required assets are downloaded, the actual rendering of the scene can begin.

The downloads for the IMPORTANT assets only start after all required assets have arrived and similarly, the DEFAULT assets are only loaded after the important ones have finished downloading.

Loading Screen

Almost as important as optimizing loading times is user feedback. Some assets will take a few seconds to download and initialize. Rather than showing a blank screen while they are loading, I added a loading screen to my WebGL engine. As mentioned above, loading new assets only happens when the main engine loop is started, at which point the engine can render frames.

The loading screen is minimalistic and only has a progress bar that counts how many required assets have been downloaded. The progress bar counts the number of assets and a 50kb model and a 4mb texture are treated equally. I’d like to change it to indicate how many bytes have been downloaded instead, but I haven’t found a good way to figure out the number of total bytes yet (it’s either manually telling the engine how big the files are or some kind of preflight request that returns a list of filesizes from the server).

The very simple loading screen:

GLB support

So far, we have only prioritized some assets and shown the user a loading screen. It’s about time that we decrease the file size of some of the assets.

glTF, the de-facto standard format for WebGL models, comes in three variants: Embedded, binary, and separate (it’s what Blender calls them). Embedded files are JSON-formatted text files and all data is directly embedded as base64-encoded strings, which is neither the smallest method to store data nor the fastest way to parse it.

Binary glTF files (.glb) fix both of these issues by storing the data in binary form and the separate glTF version splits up the JSON information and binary model data into individual files.

Previously, my WebGL engine only supported embedded glTF files because the human-readable JSON format is the easiest to understand and work with. However, to use the engine for any real projects it has to support binary glTF files too.

Luckily, the binary glTF format turned out to be very easy to understand and I was able to load GLB files very quickly. However, my first approach was to convert the binary data to a format that my existing base64 string parser could work with. After looking at it a bit closer, I realized that most of these conversions were unnecessary. Even though you don’t have to manage the memory yourself in JavaScript as you have to in C++, this doesn’t mean that there aren’t any memory allocations behind the scenes.

Therefore I decided to rewrite my glTF parser once again and build it from the ground up with support for all three types of glTF files in mind. I’m very happy with the new parser. The distinction between the different glTF types happens very early on and everything gets converted to the same format so that the rest of the code (almost) doesn’t care which type it originally was.

At the same time, the common format is very fast and efficient. When loading a binary file in JavaScript you can set the responseType of the XMLHttpRequest to "arraybuffer", which will return an ArrayBuffer. For binary and separate glTF files, the parser works directly with this ArrayBuffer without any unnecessary conversions. This made a huge improvement in the parsing time and my test model went down from ~40ms for the embedded glTF file to ~10ms for the binary GLB version.

Unrelated to WebGL, I downloaded a new Blender version last week and I realized that the embedded glTF version is no longer an export option, so adding support for GLB files to my WebGL engine was perfect timing.

Finally, I want to mention Draco Mesh Compression. It’s a glTF extension that can be used to compress the binary data even further. For large models with a lot of vertices, it can be very helpful and I have used it with Babylon.js in the past. However, I haven’t added it to my WebGL engine yet, but if it isn’t too complicated I probably will at some point.

Water Texture Generation

As the last optimization step (for now), I removed the two largest assets of my scene completely. I was using a 4.5MB texture and a 7.6MB normal map for the water. As I already mentioned in my water devlog, both of these textures were created procedurally in C++.

If it is possible to generate the textures in JavaScript instead and do it faster than the time it takes to download them, the scene would load faster. Even though it was a bit tedious, I converted the procedural algorithm to JavaScript and checked the speed.

It takes between 300-500ms to create the same 2048x2048 normal map, which is faster than downloading the 7.6MB file in most cases. Of course, if you have fast internet and a slow CPU, this will negatively impact the loading time, but I would assume that it is a net benefit for the majority of users.

I also haven’t finalized the water options yet. I may decide to decrease the resolution of the texture which would speed up the generation. I have decided, however, not to use a color texture because a uniform blue color also looked fine when mixed with everything else.

While working on the water, I also added more settings to the real-time water options menu and added a new interface to control the parameters of the procedural texture.

The new water options:

The main drawback of the procedural generation is that it happens on the main thread. Since it’s less than half a second, it’s not the biggest problem at the moment, but I’d like to look into whether it’s possible to do it in a WebWorker instead.

This blog post is a bit longer than I’d like and the topics don’t produce any beautiful screenshots, but I believe it’s quite interesting and important anyway. You can make an amazing-looking scene in WebGL, but if it takes forever to load, the user will become annoyed or may have even left the website before the loading is finished.

I’m very happy with the loading time improvements I made over the last few weeks, but there are always more optimizations you can make.

by Christian - 12.02.2024 - 17:48


Social Media:  © 2020 All rights reservedCookiesImpressum generated in 10 ms