Speeding up 3D model loading with Rust and WebAssembly

Svenn-Arne Dragly
Cognite
Published in
9 min readApr 30, 2020

Over the past few months, we have been working hard at improving loading times and performance in Reveal, our TypeScript library for viewing 3D models in web applications. Our goal has been to make any model uploaded to Cognite Data Fusion (CDF) visible to users within seconds, and to ensure great performance no matter the size of the model. By streaming data and rewriting our 3D data parsers in Rust and WebAssembly, we have been able to reduce loading times for huge models from minutes to seconds. However, not all file formats turned out to parse faster with our implementation in Rust and WebAssembly — at least not on the first attempt.

Reveal is a blazingly fast library for visualizing huge CAD models on the web. It was designed from the ground up around Cognite’s 3D optimizer, which identifies duplicate geometry or parts that can be represented more efficiently with primitives such as cylinders and tori.

The 3D optimizer also organizes all data in an octree of spatial sectors. Octrees are commonly used in 3D engines to accelerate rendering, but they also help segment the model into smaller chunks. These chunks of data can then be downloaded by the Reveal viewer to visualize even the largest CAD models in a web browser. One example: the oil platform in the screenshot above.

Why does faster model loading matter?

Downloading and parsing a huge model will, even with the optimizations described in the previous section, take a few minutes. In many cases, our users simply can’t wait that long. For example, consider a field worker using an app to view the area on an oil platform surrounding a pump that is about to be inspected. In that case, the visualization needs to load in a seconds — not minutes — for the app to be of any use.

For a long time, our solution to this problem was to download only the relevant parts of a model, such as the 8-by-8 meter area surrounding a particular asset. However, we are increasingly seeing use cases where users need to view entire models, for instance to show the shortest walkable path from one side of an oil platform to the other.

Streaming data and parsing with WebAssembly for improved loading times

To achieve improved loading times and high performance when viewing an entire model, we decided to pursue two approaches in parallel:

  1. Rewriting our engine to allow for streaming data instead of loading the entire model up-front. By adding a low-detail version of every part of the model, we can load regions that are far away from the camera quickly, which improves the rendering performance.
  2. Reducing the time spent on parsing our files, which we figured could be done by rewriting our parsers in a faster language and compiling the code to WebAssembly.

In Reveal, we have three file formats: our own optimized I3DF and F3DF formats, and the standard OpenCTM format:

I3DF is used for primitive geometry detected by Cognite’s 3D optimizer, such as cones, cylinders, spheres, and tori. OpenCTM is used for everything else — that is, nonprimitive geometry. The F3DF format is used to compress all geometry down to a number of low-detail quads that can be used for geometry far away from the camera.

How to target WebAssembly

WebAssembly is a recently standardized binary format for running code on the web. It has a number of benefits over regular JavaScript. For example, it allows us to write the code in other programming languages than JavaScript, which can result in higher performance and reduced data transfer. Typically, the code is written in a language of choice and compiled to WebAssembly, here illustrated for Rust:

We first considered using C++ together with Emscripten to write parsers for the three formats targeting WebAssembly, but we decided instead to look at Rust and the corresponding wasm-bindgen library. We made this choice partly out of personal interest, and partly because Rust has gained a good reputation for having some of the best WebAssembly tooling and for being a performant language. While Emscripten gives access to more libraries in the C++ ecosystem, it also needs to pull in some legacy overhead, which traditionally has led to larger binary sizes.

Rust turns out to be best-in-class when it comes to support for running in a browser. It also has a great, welcoming community running a number of projects to simplify targeting WebAssembly. The official Rust compiler supports WebAssembly as a compilation target, and the Rust and WebAssembly working group has created tools such as wasm-pack that simplify building and bundling WebAssembly projects.

Further, the wasm-bindgen crate has been used to create the js-sys and web-sys covers all of the JavaScript and web APIs in wrappers that are easy to understand and use. Exposing a function or struct to JavaScript from Rust is in many cases as easy as adding a #[wasm_bindgen] attribute to it:

#[wasm_bindgen] 
pub fn greet(name: &str) {
alert(&format!("Hello, {}!", name));
}

We strongly recommend checking out the wasm-bindgen guide if you want to get started with Rust for WebAssembly. You should also try cloning the wasm-bingen repository and running the examples. Most of them can be run with a simple npm install and npm run serve in their respective folders.

In addition, Rust has some serious benefits over C++ when it comes to language features and, in particular, safety. And on top of that, we have found that file parsing is a really good starting point to get into Rust. Parsing files is most often a data-oriented task, which does not require as big changes in mindset as for instance GUI programming, where things like ownership are harder to get right in Rust.

The benefits and challenges of the strong safety focus in Rust

Rust has a strong focus on safety and protects you from the get-go even in single threaded code, as opposed to C and C++, where you need to remember to reach for classes that (try to) protect you. In short, Rust helps you to avoid multiple mutable references to the same piece of data in your code. These restrictions are particularly useful to avoid race conditions when dealing with concurrent data processing, but the borrow-checker also rules out a number of other common bugs, such as modifying the iterator that you are looping over.

On the other hand, Rust’s strong safety guarantees make it a bit challenging to get started with — especially getting used to the borrow-checker. I would argue that the learning curve is particularly steep if you want to write code that traditionally works well with an object-oriented programming paradigm. In my opinion Rust works particularly well with a more functional and data-orientated architecture. File parsing is a natural fit for such an architecture.

Performance improvements with Rust and WebAssembly

In the end, the work to rewrite our parsing in Rust and compiling to WebAssembly paid off. In particular, our own I3DF format, which we previously parsed using our own TypeScript library, saw significant performance improvements.

Loading absolutely all primitives contained in the 3D model of one of our customers’ big oil rigs took about 11.4 seconds using our original TypeScript library in Chrome. We were, however, able to bring that down to 6.7 seconds by using Rust and WebAssembly:

# ChromeRust: ||||||| 6.7 s 
JS: ||||||||||| 11.4 s

In Firefox, the difference is even greater, although Firefox in general is slower than Chrome to parse these files:

# FirefoxRust: ||||||||| 9.4 s 
JS: ||||||||||||||||||||||| 22.6 s

Seeing these numbers seriously boosted our confidence in moving to Rust and WebAssembly to improve performance.

Implementing an OpenCTM parser in Rust

Much of the time needed to load a model is spent parsing OpenCTM files, which contain other geometry that cannot be represented as simple mathematical primitives. Moving this code to Rust turned out to be challenging.

First of all, there was no OpenCTM library available in Rust. There is one in C++, but it has a number of dependencies on other libraries, for example for LZMA decoding. The idea of porting its entire dependency tree to WebAssembly was not immediately tempting.

Thankfully, the OpenCTM specification is relatively simple and easy to understand, which meant that writing our own OpenCTM parser was a feasible option. However, OpenCTM relies on LZMA compression, which is not quite as simple to implement. To avoid going too deep down the rabbit hole, we decided that we first had to find a decent LZMA decoding library. After some research, we landed on lzma-rs and implemented openctm-rs on top of it. The openctm-rs library, which is open-sourced and can now be found on GitHub, does not support all the compression schemes of the OpenCTM standard, but for us, it gets the job done.

Performance issues with OpenCTM parsing

Unfortunately, our OpenCTM parser did not immediately outperform our old JavaScript-based parser, js-openctm and LZMA-JS, the corresponding LZMA library that it relies on. In our initial benchmarks, parsing almost all the meshes in the previously mentioned oil rig model took almost twice as long in Rust as it did in JavaScript when using Chrome:

# ChromeRust: |||||||||||||||||||||||||||||||||||| 35.7 s 
JS: ||||||||||||||| 14.6 s

However, profiling our code in Chrome Dev Tools showed us that a lot of time was spent on allocating zero-initialized memory. For some reason, the call to memset is particularly slow in WebAssembly:

A closer look at the OpenCTM files and the lzma-rs sources showed that the files have a huge dictionary size, which is used to indicate to the LZMA decoder how much space should be allocated up-front. This is likely because OpenCTM uses the default size set by the LZMA library, which is 16 MB. That means that for each file that we parse, we will pre-allocate 16 MB — even though the expected size might be a few hundred KB.

Thankfully, the lzma-rs maintainer was willing to accept a change to the library that would allow us to allocate space as needed instead. By introducing this change, we were able to parse the meshes at speeds comparable to js-openctm in Chrome:

# Chrome  Rust: |||||||||||||| 14.4 s 
JS: ||||||||||||||| 14.6 s

The fact that the numbers are so similar is a testament to how fast JavaScript engines have become — especially when libraries are written with performance in mind, which clearly is the case for js-openctm and the LZMA-JS.

In Firefox our Rust implementation has the upper hand, because the JavaScript engine is a bit slower than Chrome’s V8 at parsing with the js-openctm library:

# FirefoxRust: |||||||||||||| 14.4 s 
JS: ||||||||||||||||||||||| 23.1 s

Conclusion

Rust is still a young language with a smaller ecosystem than what you’ll find for TypeScript. There are not as many existing and mature libraries, such as for parsing files or handling file compression. Having to write our own parser for OpenCTM was not too much work, but unfortunately it did not bring the immediate gains we had hoped for.

Since the Rust version of the OpenCTM parser is faster in Firefox, we are sticking with it for now, but we might consider going back to JavaScript libraries if the performance turns out to be better on other platforms.

As for parsing our internal I3DF format, it is clear that our Rust and WebAssembly combination brings a big enough improvement to make it our choice moving forward. While working on this code, we also came across a few bugs in our old TypeScript-based I3DF parser. This motivated us to put more effort into the Rust version, as those bugs turned out to be caused by shared mutable state — an issue that would have been caught by the Rust compiler.

Finally, it is worth noting that while Rust provides a large number of benefits in terms of safety and performance, it is also a programming language with a steep learning curve. In the beginning, there is a constant stream of slaps on the wrist for doing things wrong. It takes a bit of time to get used to being forced to work in a particular way. After a while, however, it feels good to know that once the code compiles, a huge number of possible errors have already been ruled out.

--

--