One of the core visual elements in Marble It Up! are the tiles on every surface of every level. Because the user spends so much time staring at tiles, we spent a lot of time getting them right. During initial development, the tiles went through several iterations to get the general design visually interesting and consistent between many different level layouts and color schemes.
Due to the rapid pace of development in the year before shipping, our solution to most tasks was to use something that worked – not reinvent the wheel. The first iteration of this for tiles was to use the Standard Unity Shader with a diffuse, normal, and metallic map. This looked good close up, but resulted in large flat surfaces looking boring and repetitive.
To fix this, we added a secondary diffuse layer as a noise map to change the brightness of each tile in the grid. It worked reasonably well, adding visual interest on large flat surfaces. It was the final shipped system for some of the large flowing maps like Big Easy and all of the moving platforms.
This approach had a few limitations. The first was that the noise map only adjusted the brightness value and we wanted to have some hue variation as well in the tiles. The second, bigger issue was that while the noise map worked great for large tile sections, it showed visible repetition anywhere that we didn’t carefully adjust UV coordinates, as seen below.
The overall effect was OK, but not great. It was time to create a shader! Ben whipped out some math and got to work on a custom solution. The result was still based on the Standard Shader – using a diffuse, normal, and metallic texture – but with two important changes.
First, instead of a grayscale noise texture, we used an RGB noise map that adjusted the base texture color and brightness based on its hue and value. Alex designed a color-offset system using hue, saturation, and value to modify colors often gives more convincing results when modifying colors. This gave a more complex and interesting look.
Second, we changed how the noise texture was applied. Instead of always repeating the same noise pattern based on the UV coordinates assigned by the level creator, we used the position of each tile in the world (adding UV + World Position and ‘pixelating’ to get hard lines between the tiles). This gave an ever changing pattern of noise without requiring special attention from the level creator. It worked great for everything except moving platforms, because they change their position in the world causing different noise values to be selected.
To get the best of both worlds, we used the Standard Shader for elevators and the custom NoiseTile shader for everything else. This is what shipped with the original Marble It Up and it has worked well. However, now that the Challenge Update is complete and we have a bit of breathing room, we wanted to revisit the tiles because there were still two very significant issues.
First, the original solution used up a lot of disk space and memory. We had tried using tinted versions of the same tile early on to save space, with mixed results. We quickly abandoned that approach and Todd produced each color variant texture by hand. There are almost 100 tile variations in the game and with the current shader, while we can reuse the normal map and a few of the metallic maps, each variation must have its own diffuse texture.
Due to how we keep material references loaded for quick-swapping maps, it means that we have over 130MB of textures loaded in memory at all times, just from the tile variations – not cool! This hurts startup time and increases the chance of out of memory crashes. A better solution would be a solid win.
There was also a more serious problem. You can see that, when viewed from a low angle, grid lines just three tiles away turn soft, blurry and almost invisible. This is due to how the tile texture is shrunk by the GPU to determine how it looks from far away. This behavior can be adjusted, but the alternative is having extremely aliased, crawly visuals far away from the camera as the GPU as it renders unfiltered pixels.
We knew we could write a new shader to fix these two issues. We thought that we could keep all the benefits of the old system while fixing these problems. Along the way we also thought we could unify the shader so that we could draw multiple tile colors at a time with better performance. Additionally, we figured we could use the same shader on both stationary and moving parts of the level.
To keep the grid nice and sharp, Ben took on the task of mathematically modelling the tile grid lines in the shader instead. Because textures are a grid, they can’t perfectly represent crisp sharp shapes. As a result, tile textures had to be higher resolution solely to make the grid looks good. An exact equation describing the grid can be used to generate nice smooth crisp lines no matter how you look at the tile. This way we could cut the tile texture size while at the same time getting better results.
To do this, we use a technique called signed distance fields (SDFs). SDFs tell you for any given location how far you are from the edge of a shape. Using this information, you can get a beautiful crisp version of the shape. Valve popularized this technique, and an example from their research paper is shown below.
You can immediately see how this helps rendering a sharp grid. The first image is soft and blurry, like our distant tiles. The second image shows a simple attempt at improving the look. You can see it’s better but jagged. Using a more intelligent approach as seen in the last image, you can get sharp crisp edges which stay crisp no matter how you look at it. A more detailed technical discussion of SDFs can be found at https://www.ronja-tutorials.com/2018/11/10/2d-sdf-basics.html.
This approach made the color of the tiles look great. That said, without adjusting the normal maps, you still got some nasty jagged edges as seen above. Eventually we found a hack that gave good results – we simply erase the hard edges from the normal map once you got far enough away. Below, you can see the shader highlighting the places that need adjustment in red and green. (You can see that this in-development version of the shader is not drawing the white of the grid lines at all, and also lacks noise.)
After that, we made a few other small tweaks that took into account distance, viewing angle, and so on. Game development often requires many little interesting behaviors to get a convincing effect. In fact, getting these little hacks right often means you can do the big things totally wrong!
The result of all these behaviors – small and large – was immediately and obviously better. We had grid lines that stayed crisp at any viewing angle, while remaining unobtrusive and anti-aliased at a distance. Finally!
Jonathan took on the task of rethinking how our tile textures worked. He came up with an approach where we only needed a handful of textures to reproduce our 100 variants. What made this a little tricky was that each tile has multiple colors (generally 3-4), so simply creating a grayscale image and tinting it doesn’t work. You get flat, boring looking tiles.
His original concept was to create a ‘master’ tile texture that stored different information in each of the red green blue and alpha channels. Each channel would contribute one color to the final result. It quickly became apparent it was counterintuitive and difficult to create these master tiles. Instead we decided to limit the base tile to 3 colors and use a grayscale base texture (with metallic texture baked into one of the channels) with another grayscale ‘detail’ texture (which contributed a second range of colors).
The second part was keeping the color variation of the noise map consistent, while making them easier for Todd to tweak. The noise calculation still uses pixelated worldspace + UV coordinates (though now we can multiply the worldspace component by zero for use with moving platforms). But the noise component now feeds into color gradients that can be chosen by the artist for all three colors to get exact control over how the noise affects the tiles.
Using the Light and Dark gradients the noise can pick between 4 colors per tile, and with an overlay texture we get another two colors to pick between for a ton of variation in tile colors with direct artist control. All that while being less mathematically complex than the previous HSV system!
We’re quite happy with the results. The new shader give us more visual fidelity and better artistic control, while letting us drop 95% of our tile textures and over 100mb of runtime memory usage. We look forward to shipping this improved look in our next release!
Jonathan & Ben
Marble It Up!