Behind the Tech of Cavalier: Conqueror of Excellence
Your Majesty’s culture is based on three principles: Chivalry, Courtesy and Excellence. These aren’t just words for our about page. They’re values that guide everything we do.
Cavalier is an internal initiative we created to develop interactive experiences that combine these core principles with our passion for research and development.
Cavalier’s first entry is Posture & Balance; a web game that uses new technologies to explore our value of excellence. In this article we will delve into our tech approach and the challenges we encountered along the way.
The game world consists of two large procedurally generated tiles. Whenever the player gets to the beginning of a new tile, the previous tile is recycled, reconfigured, and placed beyond the tile the player is now on. This process continues indefinitely.
Each tile has a grid layout with an inaccessible outer area and a central path where nuggets and rising pillars are placed. In early prototypes the tiles were populated randomly. However, this resulted in unpredictable and often undesirable patterns.
Later in the process we began curating the chaos by introducing a set of configurable rules. This gave us a number of key values we could use to control the difficulty and pacing of the gameplay.
We wanted to prevent pillars from clumping up too much, and to make sure there was always a clear path through the tile. To achieve this, pillars were spread out row by row using randomized steps.
We also wanted to make sure all the nuggets could be collected. Their positioning is governed by a jagged line, never placing more than one nugget on a row. After play testing we settled on a configuration where nuggets are never more than two lanes apart, which worked well with the sense of speed we wanted to achieve.
The grid also removed the need to perform physical collision detection. Instead, pillar and nugget positions are stored in a two dimensional array. As the player moves through the tile, their position is converted into this array. Collision detection is done by checking if the player index overlaps with any stored object. This saves a lot of processing compared to doing bounding box intersection tests, and is still accurate (we promise).
Three.js & Shader Animation
Posture & Balance is built on top of Three.js. This is the go-to framework for many WebGL projects, as it has a robust feature set and a great community. It also provides nice hooks if you want to get closer to the hardware without having to manage all the nuts and bolts of the WebGL API. We used these to create shader powered animations and post processing effects, which played a big part in the overall aesthetic of the game.
The pillars, nugget collisions, and the stretchy speed particles are all primarily animated on the GPU. They are set up as Buffer Geometries with custom Buffer Attributes. These attributes are used alongside uniforms in extended Phong and Basic materials, where the animation state is calculated inside the vertex shader. This approach is similar to morph targets, but instead of being linear, the interpolation is defined by arbitrary logic.
While harder to set up, this approach has huge performance benefits. The standard way of animating is updating the transformation matrix of a mesh, and sending its 16 floats to the GPU. As the number of objects increases, this quickly becomes a bottleneck, and rendering slows down. With our approach, all the data needed to calculate an animation state is already in graphics memory. The animation state is then controlled by a handful of uniform values, which enables the GPU to crunch numbers undisturbed.
After launching Posture & Balance we continued working on this animation system, which is now available as a Three.js extension on GitHub.
Audio plays a big role in Posture & Balance. We used Howler.js to control loading and playing of sounds. We also used two WebAudio features to further enhance the experience.
The first was controlling the playback rate of the gallop sound effect to match the speed of the horse. The second was using a PannerNode to position sounds inside the game world. This causes the volume and stereo origin of some sounds to change as you move left and right.
These features were fairly straightforward to implement, and added a lot of depth to the experience. The best part is that this works out of the box on most modern browsers.
Interface & Performance
One of the main goals of this project was experimentation. To this end we decided to use Pixi.js for the interface layer. We wanted to see how it would compare to traditional DOM rendering. This approach resulted in some benefits, some challenges, and a lot of learnings.
As we continued to add visual effects to the game, we started noticing performance issues. After some investigation, we were surprised to find out this was primarily caused by the interface layer. We then set out to identify and eliminate as many bottlenecks as possible.
The first thing we changed was our HTML display stack. Initially, Three and Pixi each had their own HTML canvas. The Pixi canvas was transparent, and lay on top. During the optimization phase we decided to merge the two. Instead of being in the DOM, Three rendered into an off-screen canvas, which was used as a texture for a Pixi sprite.
The next part of the optimization phase revolved around reducing the draw count of Pixi. This is a property of the WebGLRenderer that tells you how many separate drawing operations are performed each frame. Getting this number as low as possible is key to a smooth frame rate, as it reduces the overhead from CPU to GPU communication.
The first thing we did was merge our textures into sprite sheets, one for regular and one for retina displays. This way WebGL textures do not need to be updated when rendering different objects. This helps Pixi to group objects into larger batches which can be drawn simultaneously.
Next we addressed text rendering. As it turns out, doing this through Pixi is comparatively slow. Each time a string is updated (like the score and time displays), the text is drawn to an off-screen canvas, which is then send to the GPU as a texture. Excess traffic like this has a negative impact on performance. This could be addressed with Bitmap Fonts, which effectively turn text into images that can be added to a sprite sheet. However, this severely limits flexibility as Bitmap Fonts scale very poorly. We opted to remove text from Pixi altogether, rendering it in the DOM instead.
The final major bottleneck was the Pixi graphics api, which we used to create some simple interface elements. The main culprit here was the gallop indicator, which had about 60 parts. With the graphics api, each piece demanded its own draw call. We solved this by adding differently colored squares to our sprite sheet. These were then scaled for the gallop indicator and some other interface elements.
In the end we managed to reduce the number of draw calls during gameplay to just 2: one for the interface layer, and one for the game world.
The graph that Chrome generates provides a detailed breakdown of what your application is doing each frame. It’s easy to visually identify blocks where frame rate issues arise, as these frames take more space. Then it’s a matter of digging in, figuring out where repeating bottlenecks occur, and trying to find ways to optimize.