As an educational video game developer, our studio typically uses the Unity game engine to develop games for our customers. That being said, when our customers require a robust web browser experience, we sometimes develop games in HTML5. Having worked in the past with HTML5 game tools like Phaser and having been frustrated by the limitations of existing tools, we developed our own pipeline, unity3D-pixi.js, that allows us to use our Unity pipeline for HTML5 experiences.
Today I’m sharing an in-depth case study in optimizing an HTML5 game in order to help fellow developers making HTML5 games. Get ready to go deep!
In 2019, Filament engaged in a project to update an existing customer’s HTML5 game, implemented in pixi.js, to support iPad 2. Our previous memory budget was 512 MB. After some testing we identified that our new budget before triggering OOM was now 275 MB. iPad 2 only supports iOS 9 so we were extremely limited in the profiling techniques available to us. We settled on using an iPad 3 with iOS 10 as our profiling proxy. In order to profile memory usage accurately, we tried a number of techniques including:
- Memory allocation trace with Apple Instruments
- Watching Activity Monitor and recording the private memory usage reported of the WebContent process manually at regular intervals
- Safari remote debugging w/ Memory Timeline
We ultimately settled on using the Safari memory timeline along with a 10 step ceremony which ensures all the layers of the browser’s in-memory caches were being flushed. Safari provides four stats in the memory timeline: Page Data, Layer Data, Image Data, and JavaScript Heap. The profiling report looks like this:
There is very limited documentation available to understand which javascript objects retain page data and layer data. However, inspecting the mainline WebKit source code gave us a pretty good idea of the general object groupings involved. We actually tried building the source code with profiling information but we were only able to run that in the simulator, which gives inaccurate information since it’s actually proxying OSX syscalls and utilizing audio and graphics resources designed for laptop/desktop class hardware.
After performing multiple trials of each activity in the game and recording memory usage data, we began bisecting the source code to identify memory hungry segments. Ultimately we identified the following areas of general optimization:
- Reorganizing our sprite packing and asset preloading
- Only loading voiceover one line at a time and unloading it after it plays
- Stripping pixi.js of unused shaders and systems
- Turning off mimaps
- Shortening music and sound effects
After these and other optimizations, we were able to load all activities on the iPad 2 and get runtimes of engaged play up to 30 minutes on average. However, we were seeing frequent OOM errors after loading and unloading activities 10-15 times. The high end of playtime in a single sitting on the app was estimated at an hour, so we hadn’t reached our definition of success quite yet.
The next strategy we attempted was to hunt for leaked js objects. We had been profiling and tidying up our heap usage throughout development, which is why we didn’t check here first. While memory can’t technically “leak” in a garbage-collected environment like JS, objects can be referenced for much longer than strictly necessary. We were looking for any unneeded objects, both using the Chrome heap snapshot tool (a lot easier to use and theoretically close to the same results as Safari) and the heap snapshots available in the outdated version of Safari used in iOS 10. We confirmed there wasn’t any possibility of a leak due to user code, no matter how small the retained size reported. We especially were watching for AudioBuffer and WebGLTexture leaks since those retain a large amount of unmanaged browser memory. We did observe small leaks of compiled JS code and other browser-specific runtime resources, but we could not identify any retainers on those objects in our code.
After checking for leaks and running out of ideas, we attempted a number of strategies to pace out large memory allocations to limit a spike into OOM including:
- Specifically scheduling when audio decoding occurs in the load sequence
- Implementing per-frame object creation budgets in our scene creation routines
- Adding a pause after scene teardown
These new changes had no measurable effect! This suggested that the iOS 9 browser was leaking small amounts of memory inside of the WebContent process that was aggravated by the memory management strategies (loading and unloading) we were using to limit assets needed in memory at any given time. That’s not really a huge surprise, since the iOS 9 Safari developers were not thinking about users playing a browser game for an hour when they were testing. Our ultimate workaround for this situation was to store key state in sessionStorage and force a page reload between major activity transitions in the app. This proved successful and allowed us to achieve our 1+ hour playtime goal.
Even though we ultimately had to employ a user-experience degrading solution, we felt that all of our earlier optimizations allowed the user to experience the game, with all of the expected juice, for a significant amount of uninterrupted time. Are you looking for an educational video game developer to make your next HTML5 game? If so, we’d love to hear from you – send us a message for a free consultation!