Optimizing a Large-Scale Babylon.js Scene
This article is part of a series of articles about the Digital Experience for the Mayflower Autonomous Ship.
In this piece, we will focus on the optimisation and architectural techniques used in order to optimise the Babylon.js harbour scene. In total, our scene had over 600 meshes and 1,000,000 vertices. It ran consistently at 45+ FPS in Google Chrome on our 2018 Macbook Pro. Firefox we found to be about 40 FPS, and Safari much lower, although still useable, at 25 FPS, mostly because it does not support WebGL 2.0.
Optimisation techniques discussed here do cover Babylon.js, but are also focussed on methods to improve the underlying models, materials and lighting that we use which has a huge impact on performance. All of our models were built using Blender, and as such, examples included here often reference Blender solutions, but any other 3d computer graphics software can be used.
Note: There are many more optimisation techniques available than described here, and some may not be applicable to your own environments (e.g. some of our techniques required our models to be static in the scene). For a larger list of recommended techniques checkout Babylon’s own article on the matter.
Single vs Multiple Model Imports
There is a trade-off required when designing the assets for your scenes. Let’s take our Harbour as an example.
We could model the entire scene in a single .blend
file and transfer it to Babylon via single .gltf
file. Alternatively, we could design each building, boat, tree and rock in standalone .gltf
files and then import, and position, the assets within the Babylon.js scripting.
Fewer models and meshes mean that the Babylon render loop has less to cycle through. Arranging a scene in 3d software is also easier, and can be done by members of the team that are not skilled JavaScript programmers. However, load times can be significant if you have a particularly large model (for context, our final .glb
file was 8MB).
Alternatively, we could build our individual assets (buildings, trees, etc.) in their own .gltf
files and then import these files and use JavaScript to position them. This is less practical for designing layouts and spaces than in your own 3d software but is rewarded in loading times as models can be loaded in parallel. It would also make it easy to utilise Babylon’s own instancing functionality leading to further optimisation in the sake of tree placement for example.
In the MAS Digital Experience we settled for a mixture. Mostly, we relied on the assets being constructed outside of Babylon.js and in Blender instead. We found having that freedom for non-developers to edit the scene far too useful to sacrifice. We were satisfied with the loading times as the experience was always pre-loaded with an introductory animation and so the scene’s loading, taking place in the background, was less apparent. Additionally, we knew we would want to take high-quality renders of our scenes in Blender. Having the full scene constructed there meant that the static renders reflected the browser-based world perfectly.
Monitoring Performance with Babylon.js
In order for us to optimise, we need to be conscious of our performance throughout development. Whilst Babylon.js has its own excellent Inspector for debugging, the simplest resource we found useful in development was creating our own FPS counter that stayed in the corner throughout. The value for this can be exposed by the Babylon engine
.
As we added new resources to our scenes, it became very clear when we were doing something detrimental to performance. Additionally, when consciously optimising our scenes, this gave a very easy-to-read indicator of improvements.
Be sure to interact with your scene when doing this though. Just because your scene runs at 60 FPS when the camera is static, doesn’t mean it’ll stay that way. Move around, click things, do things your user will do!
If you’re struggling to find where the performance impact is taking place, we found these really useful Babylon features in the Inspector that helped:
Wireframe & Point Rendering
In Babylon’s Inspector we have the option to change our rendering mode. You can view the Inspector by including the following line in your scene’s code:
scene.debugLayer.show()
From the menus that now show, click on the “Scene” object within the “Scene Explorer” on the left. On the right-hand side, under “Rendering Mode”, you can then toggle between “Point”, “Wireframe” and “Solid”. These alternative views we found gave a very clear image of polygon density.
In this case we could see that the building in the centre of this image, representing IBM’s Operational Decision Manager, was still poorly optimised given how many edges, and consequently, polygons it contained.
Show/Hide Mesh & isEnabled Toggle
Another of the Inspectors most useful features was the ability to toggle on/off each mesh in your scene. This enables a really easy way to monitor the impact of specific resources on your scene performance, that may not be linked to poly count.
There are two options on how to do this:
- The “eye” icon to the right of any Mesh in the “Scene Explorer”.
- The “IsEnabled” toggle for a Babylon TransformNode. This option shows in the “General” tab of the Inspector on the right, after clicking on a TransformNode in the Scene Explorer.
In Blender, we found that parenting groups of objects to a single Empty created Transform Nodes. These were especially useful here, as, when imported into Babylon, it was possible to toggle the “isEnabled” option on/off each node, and consequently all children in one hit, making the hunt for potential performance improvements much easier. Blender collections were lost on export to gltf.
With our full scene enabled, we were achieving 48 FPS.
When toggling off the ‘isEnabled’ option in the Inspector for our product-related buildings, the impact on FPS was minor — now at 49 FPS.
When toggling our “filler” buildings off though, we noticed a significant improvement, with the frame rate jumping up to 59/60 FPS. Clearly, these meshes were where we needed to focus our attention and optimise further.
Standard 3D Optimisation Techniques
There are standard good practices that we should follow here that are applicable across any real-time rendered scenes, whether that be Babylon.js and browser-based, or not.
Poly Count
The simplest metric to account for is poly count. It’s much quicker to render 4 vertices than it is 4,000,000. Where possible, be smart with your models and only add detail where it matters. There is no point sculpting a high definition face on a character model if your camera is so far zoomed out that you will never see it.
Ian Hubert’s 1 minute “lazy” Blender tutorials are a great example of how much “detail” can be attained, even with super low-poly meshes. It’s great to build high definition models for 4k renders and top-budget movies, but if you’re looking to build a browser-based experience, then be flexible in where, and how, your models have detail.
If you have a high-poly model and are looking to optimise it, then retopology is your friend. It can be a tedious process, but the performance improvements are significant.
If you have buildings with details like windows or doors, then you can also bake normal maps to give the appearance of detail, without the penalty in vertex count. Normal maps are especially effective at adding high detail without sacrificing polycount.
It is worth noting that .gltf
meshes have triangulated faces, so don’t be surprised when you see a large “face count” jump when you move your model into Babylon. Where possible, triangulate faces before the import to ensure that you have control over the polygon and face counts.
Instancing
As mentioned above, it’s quicker to render 4 vertices, than 4,000,000. A way that we can “trick” our renderer into thinking we are using less vertices is through Instancing. This reduces the total number of draw calls that Babylon has to go through in each render.
In our finished Harbour scene, we had 631 active meshes, with 73 draw calls. Without instancing, we would expect 631 draw calls, and for rendering to be 5–10x slower in our case.
Instances are an excellent way to use hardware accelerated rendering to draw a huge number of identical meshes (let’s imagine a forest or an army). This can either be done within the 3d software of your choice (we use Blender, where it can can be achieved using a linked duplicate), or within Babylon.js directly should your scene suit the use case where you are positioning objects from within your JavaScript.
One negative consequence of instancing and re-using the same model multiple times within a scene is that it can often be obvious that you have rinsed and repeated the same mesh over and over.
Here we share some example renders from Blender showing how to utilise instancing whilst keeping a sense of scale and variability in your models.
In our first image, we have a single building instanced 1,000 times. With the second render, we use 3 material variations of the building split equally across the 1,000 instances. Finally, in the third image, we modify the buildings such that they are not symmetrical, and introduce random rotation about the vertical axis. These small changes introduce a sense of variability, whilst maintaining the optimised geometry.
Extra Consideration for WebGL — Material Consolidation
When working with Babylon, files are generally imported using .gltf
format. You may notice though, that when your models are loaded into Babylon, that it has split your model into multiple, separate objects.
This occurs on a material-by-material basis and means that in the render loop, Babylon is looping through each newly created object each time, rather than just the single, original, mesh — expensive!
The ideal fix here is that you have as few materials as possible, and whilst you may want every object to have beautiful 4K textures and PBR materials, if you’re able to, and it fits your desired aesthetic, you can use a Colour Palette.
With your colour palette, you can define a single material for all of your objects. Set the base/albedo colour of that material to the colour palette texture, and ensure the UV faces of your model line up to the colour you desire.
To do this, in your 3d software, UV unwrap the object, scale the object’s UV down to nothing, and then move the UV into the coloured square matching the colour you want that face to be. If you’re using Blender, Imphenzia does this regularly in his great 10 minute Blender challenges, he goes into detail on this technique here.
Each colour palette only needs to be, say 3x3 pixels (for 9 different colours), or 8x8 pixels (for 64 colours). You can also go for a larger palette which gives you the option to add gradients into your palette too. We used this technique for the harbour’s colour palette in the Mayflower Experience as shown above.
Babylon.js Tricks & Techniques
As mentioned previously, Babylon’s documentation is rich with methods to tackle memory usage and performance issues. Two particularly useful resources we found to be:
- Optimizing Your Scene: https://doc.babylonjs.com/divingDeeper/scene/optimize_your_scene
- Reducing Memory Footprint: https://doc.babylonjs.com/divingDeeper/scene/reducingMemoryUsage
Specifically, we used the following methods across our scenes and mesh importing where appropriate:
Scene: Disable object interaction
If, in your scene, you do not need to directly interact with the 3d meshes, then disabling the mouse events we found very effective.
scene.pointerMovePredicate = () => false;
scene.pointerDownPredicate = () => false;
scene.pointerUpPredicate = () => false;
This method was used in our Harbour and Challenge 3 scenes. We could not use this in Challenge 1 given that we needed to detect users clicking the environmental objects (rocks, buoys & ships).
Scene: Removing cached vertex data
All vertex buffers keep a copy of their data on CPU memory to support collisions, picking, geometry edition or physics. If you don’t need to use these features, you can call this function to free associated memory:
scene.clearCachedVertexData();
Mesh Import: Static Assets
If you have meshes that will not change position, rotation or size, then it becomes very effective to “freeze” the mesh by calling:
mesh.freezeWorldMatrix();
Even if they do change, but intermittently, then you can toggle the world matrix calculation back on by calling:
mesh.unfreezeWorldMatrix();
Mesh Import: No Interaction
If you have no requirement for users to click or pick your meshes, then the following lines can be added when importing your mesh for further performance enhancements:
mesh.isPickable = false;
mesh.doNotSyncBoundingInfo = true;
VueJS & Babylon
After our project had finished, we actually discovered a gotcha in using BabylonJS and VueJS. Whilst we were very happy with our FPS/performance here, it turns out we were causing a huge FPS deficit by binding the BabylonJS engine
and scene
as reactive variables in VueJS.
I won’t go into too much detail here, but it is covered in excellent detail in this forum post.
Concluding Thoughts
Thank you for reading through this article. I hope that at least one point you’ve learned here can help you optimise your large-scale Babylon.js scenes.