Search Unity

Using light-baked prefabs on mobile devices

and other cheap tricks to get to 60fps on low-end phones

With their new mobile strategy game, Galactic Colonies, MetalPop Games faced the challenge of making a game in which players could build huge cities on their low-end devices, without their framerate dropping or their device overheating.

Michelle Martin, software engineer at MetalPop Games wrote up this great new article that explains how they achieved good-looking visuals and solid performance on a range of mobile devices, so they could reach as many potential players as possible.

City building on low-end devices

As powerful as mobile devices are today, it is still very difficult to run large and good looking game environments at a solid frame rate. Achieving solid 60fps in a large scale 3d environment on an older mobile device can be quite a challenge.

As developers we could just target high-end phones and assume that most players will have good enough hardware to run our game smoothly. But this will result in locking out a huge amount of potential players, as there are still many older devices in use. Those are all potential customers you don’t want to exclude if it can be avoided.

In our game Galactic Colonies, the players colonize alien planets and can build huge colonies made up from a large number of individual buildings. While smaller colonies might only have a dozen buildings, larger ones can easily have hundreds of them.

This is what our goal list looked like when we started building our pipeline:

  • We want huge maps with a high amount of buildings
  • We want to fast run on cheaper and/or older mobile devices
  • We want nice looking lights and shadows
  • We want an easy and maintainable production pipeline
Lighting in mobile games: the usual challenges

Having good lighting in your game is key to make the 3d models look great. In Unity that is of course really easy: Set up your level, place your dynamic lights and you are good to go. And if you need to keep an eye on performance, just bake all your lights and add some SSAO and other bits of eye candy via the post processing stack. There you go, ship it!

Setting up lighting in mobile games often requires a good bag of tricks and workarounds. For example, unless you are targeting high-end devices you are better off not using any post processing effects at all. Likewise, having a large scene full of dynamic lights will also lower your framerate drastically.

Realtime lighting can be very expensive, even on a desktop PC. On mobile devices resources limitations are even tighter and you can’t afford all those nice features you’d like to have.

Light baking to the rescue

On mobile devices, resources are quite limited and you don’t want to drain your users’ batteries more than necessary by having too many fancy lights in your scene.

If you constantly push a device to the hardware’s limits, the phone will run hot–and consequently, automatically throttle down, to protect itself. This is where you would usually start baking every light that doesn’t need to cast realtime shadows.

Unity-light-baked-prefabs-for-mobile-MetalPopGames-Galactic-Colonies

(A typical level in Galactic Colonies with a high number of buildings)

For us, this was not an option, since our game world is built in real-time by the player. The fact that new regions are constantly being discovered, additional buildings are built, or existing ones upgraded prevents any kind of efficient light baking.

Fake it till you make it

The process of light baking is the precalculation of highlights and shadows for a (static) scene and storing the information in a lightmap.

The process is telling the renderer where to make a model lighter or darker, creating the illusion of light.

Rendering things in this way is really fast, because all the expensive and slow light calculations have been done offline, and at runtime the renderer (shader) just needs to look the result up in a texture.

The tradeoff here is that you will have to ship some extra lightmap textures which will increase your build size and require some extra texture memory at runtime.

You will also lose some space because your meshes will need lightmap UVs and get a bit bigger.

But overall you will gain a tremendous speed boost.

Unity-light-baked-prefabs-for-mobile-MetalPopGames

(A building with and without lightmap)

However, simply hitting the Bake buttton will not work when you have a dynamic world which can be constantly changed by the player.

We are faced with a number of problems that arise when baking lights for highly modular scenes.

Light baking prefabs is not so easy

First of all, light-baking data in Unity is stored and directly associated with the scene data. This is not a problem if you have individual levels and pre-built scenes and you only have a handful of dynamic objects. You can prebake the lighting and be done.

This obviously doesn’t work when you create you levels dynamically. In a city building game, the world isn’t pre-created. Instead it is largely assembled dynamically and on-the-fly by the player’s decision of what to build and where to build it. This is usually done by instantiating prefabs wherever the player decides to build something.

The only solution to this problem is to store all relevant light-baking data inside the prefab instead of the scene.

Unfortunately there is no easy way to copy the data of which lightmap to use, its coordinates and scale into a prefab.

Building a pipeline for light-baked prefabs

The best approach to achieve a solid pipeline handling light baked prefabs is to create the prefabs in a different, separate scene (multiple scenes, in fact) and then load them into the main game when needed.

Each modular piece is light baked and will then be loaded into the game when needed.

When you take a close look at how light-baking works in Unity you can see that rendering a light baked mesh is really just applying another texture to it, and brightening, darkening (or sometimes colorizing) the mesh a bit. All you need is the lightmap texture and the UV coordinates - both of which are created by Unity during the light baking process.

During the light baking process Unity creates a new set of UV coordinates (which point to the lightmap texture) and an offset and scale for the individual mesh. Re-baking lights changes these coordinates each time.

How to utilize UV channels

To develop a solution for this problem it is helpful to understand how UV channels work and how to best utilize them.

Each mesh can have multiple sets of UV coordinates (called UV channels in Unity). In the majority of cases, one set of UVs is enough, as the different textures (Diffuse, Spec, Bump, etc) all store the information in the same place in the image.

But when objects share a texture, such as a lightmap, and need to look up the information of a very specific place in one large texture, there’s often no way around adding another set of UVs for use with this shared texture.

The drawback of using multiple UV coordinates is that they consume additional memory. If you are using two sets of UVs instead of just one set, EVERY single one of the mesh’s vertices now has twice the amount of UV coordinates. For every vertex, you are storing an extra two floating point numbers, and are also uploading them to the GPU when rendering.

Creating the prefabs

Unity is used to generate the coordinates and the lightmap, using the regular light baking functionality. The engine will write the UV coordinates for the lightmap into the second UV channel of the model. It’s important to note that the primary set of UV coordinates can’t be used for this, because the model needs to be unwrapped.

Imagine a box using the same texture for each of its sides: The individual sides of the box all have the same UV coordinates, because they reuse the same texture. But this won’t work for a lightmapped object, since each side of the box is hit by lights and shadows individually. Each side needs its own space in the lightmap with its individual lighting data. Hence, the need for a new set of UVs.

So, in order to set up a new light baked prefab, all we need to do is to store both the texture and it’s coordinates so that they aren’t lost and copy them into the prefab.

After the light baking is done, we run a script that runs through all the meshes in the scene and writes the UV coordinates into the actual UV2 channel of the mesh, with the values for offset and scaling applied.

The code to modify the meshes is relatively straight forward:

              Mesh meshToModify = GetComponent().sharedMesh;
              Vector4 lightmapOffsetAndScale = GetComponent().lightmapScaleOffset;

              Vector2[] modifiedUV2s = meshToModify.uv2;
              for (int i = 0; i < meshToModify.uv2.Length; i++)
              {
                  modifiedUV2s[i] = new Vector2(meshToModify.uv2[i].x * lightmapOffsetAndScale.x +
                  lightmapOffsetAndScale.z, meshToModify.uv2[i].y * lightmapOffsetAndScale.y +
                  lightmapOffsetAndScale.w);
              }
              meshToModify.uv2 = modifiedUV2s;
            

To be a bit more specific: This is done to a copy of the meshes, and not the original, because we’re doing further optimizations to our meshes during the baking process.

The copies are automatically generated, saved into a prefab, and assigned a new material with a custom shader and the newly created lightmap. This leaves our original meshes untouched, and the light baked prefabs are immediately ready to be used.

A custom lightmap shader

This makes the workflow very simple. To update the style and look of the graphics, just open the appropriate scene, make all modifications until you’re happy, and hit then start the automated bake-and-copy process. When this process has finished, all is done and the game will start using the updated prefabs and meshes with the updated lighting.

The actual lightmap texture is added by a custom shader, which applies the lightmap as a second light texture to the model during rendering.

The shader is very simple and short, and aside from applying the color and lightmap, calculates a cheap, fake specular/gloss effect.

              Shader "Custom/LightmappedPrefabWithSpec"
              {
                Properties
                {
                  _MainTex("Base (RGB)", 2D) = "white" {}
                  _Lightmap("Lightmap", 2D) = "white" {}
                  _Specmap("Specmap", 2D) = "white" {}
                  _SpecularAtt("Glossiness", Range(0.1, 2)) = 0.5
                  _SpecularAmt("Specular", Range(0, 1)) = 0.5
                }

                SubShader
                {
                  Tags{ "Queue" = "Geometry+1" }
                  Pass
                  {
                    CGPROGRAM

                    // Defining the name of the vertex shader
                    #pragma vertex vert

                    // Defining the name of the fragment shader
                    #pragma fragment frag


                    // Include some common helper functions,
                    // specifically UnityObjectToClipPos and DecodeLightmap.
                    #include "UnityCG.cginc"

                    // Color Diffuse Map
                    sampler2D _MainTex;
                    // Tiling/Offset for _MainTex, used by TRANSFORM_TEX in vertex shader
                    float4 _MainTex_ST;


                    // Lightmap (created via Unity Lightbaking)
                    sampler2D _Lightmap;
                    // Tiling/Offset for _Lightmap, used by TRANSFORM_TEX in vertex shader
                    float4 _Lightmap_ST;

                    // Grayscale Map indicating which parts of the models have specular
                    // Note: _Specmap_ST is not needed, as this map is using the same
                    // UVs as for the _MainTex.
                    sampler2D _Specmap;

                    // This is the vertex shader input: position, UV0, UV1, normal
                    // UV1 (= second UV channel) needed for the lightmap texture coordinates
                    struct appdata
                    {
                      float4 vertex   : POSITION;
                      float2 texcoord : TEXCOORD0;
                      float2 texcoord1: TEXCOORD1;
                      float3 normal: NORMAL;
                    };

                    // This is the data passed from the vertex to fragment shader
                    struct v2f
                    {
                      float4 pos  : SV_POSITION; // position of the pixel
                      float2 txuv : TEXCOORD0; // for accessing the diffuse color map
                      float2 lmuv : TEXCOORD1; // for accessing the light map
                      float3 normalDir : TEXCOORD2; // for fake specular
                    };

                    // This is the vertext shader, doing nothing special at all.
                    // Most notably it is calculating the surface normal, because that
                    // is needed for the fake specular lighting in the fragment shader.
                    v2f vert(appdata v)
                    {
                      v2f o;
                      o.pos = UnityObjectToClipPos(v.vertex);
                      o.txuv = TRANSFORM_TEX(v.texcoord.xy, _MainTex); // using _MainTex_ST
                      o.lmuv = TRANSFORM_TEX(v.texcoord1.xy, _Lightmap); // using _Lightmap_ST

                      // Calculating the normal of the vertex for the fragment shader
                      float4x4 modelMatrixInverse = unity_WorldToObject;
                      o.normalDir = normalize(mul(float4(v.normal, 0.0), modelMatrixInverse).xyz);

                      return o;
                    }

                    uniform float _SpecularAtt;
                    uniform float _SpecularAmt;

                    // Fragment Shader
                    half4 frag(v2f i) : COLOR
                    {
                      // Reading color directly from the diffuse texture, using first UV channel
                      half4 col = tex2D(_MainTex, i.txuv.xy);
                      // Reading specular (on/off) value from spec map texture
                      half4 specVal = tex2D(_Specmap, i.txuv.xy);
                      // Reading lightmap value from the lightmap texture
                      half4 lm = tex2D(_Lightmap, i.lmuv.xy);

                      // Fake specular light angle calculation with a hard-coded light direction
                      half3 th = normalize(half3(0, 1, -0.25));
                      float spec = max(0, dot(i.normalDir, th));

                      // Adjusting by overall specular amount and glossyness (material parameters)
                      spec = _SpecularAmt * pow(spec, 40.0 * _SpecularAtt);
                      // We're just using red value of the specular texture, like a grayscale map,
                      // although technically spec could be colored.
                      // Example: float3 specCol = specVal * spec;
                      spec = spec * specVal.r;

                      // Calculating the final color of the pixel by bringing it all together
                      col.rgb = min(half4(1,1,1,1), col.rgb * DecodeLightmap(lm) + col.rgb * spec);
                      return col;
                    }
                    ENDCG
                  }
                }
                Fallback "Diffuse"
              }
            

Here’s an image of a material setup using the shader from above:

Unity-light-baked-prefabs-for-mobile-Material-setup-MetalPopGames

(Material setup using a custom shader)

Setup and Static Batching

In our case we have four different scenes with all the prefabs set up. Our game features different biomes such as tropic, ice, desert, etc. and we split up our scenes accordingly. For your game the number of scenes might vary, depending on your requirements.

All the prefabs which will be used in a given scene share a single lightmap. This means one single extra texture, in addition to the prefabs sharing only one material.

As a result we were able to render all models as static and batch render almost our entire world in only one draw call.

Unity-light-baked-prefabs-for-mobile-bake-level-MetalPopGames

(The bake level in the Unity editor)

The light baking scenes, wherein all of our tiles/buildings are set up, have extra light sources to create localized highlights. You can place as many lights in the setup scenes as you need since they will all be baked down anyway.

The bake process is handled in a custom UI dialog which takes care of all the necessary steps. It makes sure that:

  • The correct material is assigned to all meshes
  • Hides everything that doesn’t need to be baked during the process
  • Combines/bakes the meshes
  • Copies the UVs and creates prefabs
  • Names everything correctly and checks out the necessary files from the version control system

Unity-light-baked-prefabs-for-mobile-custom-inspector-MetalPopGames

(Custom inspector for an easy workflow)

Properly named prefabs are created out of the meshes so that the game code can load and use them directly. The meta files are also changed during this process, so that references to the meshes of prefabs don’t get lost.

This workflow allows us to tweak our buildings as much as we want to, light them the way we like them, and then let the script take care of everything.

When we switch back to our main scene and run the game, it just works - no manual involvement or other updates necessary.

Fake light and dynamic bits

One of the obvious drawbacks of a scene in which 100% of the lighting is pre-baked, is that it’s difficult to have any dynamic objects or motion. Anything that throws a shadow would require realtime light and shadow calculation, which, of course, we would like to avoid altogether. But without any moving objects, the 3D environment would just appear static and dead.

We were willing to live with some restrictions of course, as our top priority was achieving good visuals and fast rendering. To create the impression of a living, moving space colony or city, not a whole lot of objects needed to actually move around. And most of these didn’t necessarily require shadows, or at least the shadow’s absence wouldn’t be noted.

In our case, we started by splitting all the city building blocks into two separate prefabs. A static portion, which contained the majority of vertices, all the complex bits of our meshes - and a dynamic one, containing as few vertices as possible.

The dynamic portions of a prefab are animated bits placed on top of the static ones. They are not light baked at all, and we used a very fast, cheap fake lighting shader to create the illusion that the object was dynamically lit.

              Shader "Custom/FakeLighting"
              {
                Properties
                {
                  _Color ("Color", Color) = (1,1,1,1)
                  _Brightness ("Brightness", Range(0,1)) = 0.4
                  _MainTex ("Albedo (RGB)", 2D) = "white" {}
                }

                SubShader
                {
                  Tags { "RenderType" = "Opaque" }
                  LOD 200
                  Pass
                  {

                    CGPROGRAM

                    // Define name of vertex shader
                    #pragma vertex vert
                    // Define name of fragment shader
                    #pragma fragment frag

                    // Include some common helper functions, such as UnityObjectToClipPos
                    #include "UnityCG.cginc"

                    float4 _Color;
                    float _Brightness;

                    // Color Diffuse Map
                    sampler2D _MainTex;
                    // Tiling/Offset for _MainTex, used by TRANSFORM_TEX in vertex shader
                    float4 _MainTex_ST;

                    // This is the vertex shader input: position, UV0, UV1, normal
                    struct appdata
                    {
                      float4 vertex   : POSITION;
                      float2 texcoord : TEXCOORD0;
                      float3 normal: NORMAL;
                    };

                    // This is the data passed from the vertex to fragment shader
                    struct v2f
                    {
                      float4 pos  : SV_POSITION;
                      float2 txuv : TEXCOORD0;
                      float3 normalDir : TEXCOORD2;
                    };

                    // This is the vertex shader
                    v2f vert(appdata v)
                    {
                      v2f o;
                      o.pos = UnityObjectToClipPos(v.vertex);
                      o.txuv = TRANSFORM_TEX(v.texcoord.xy,_MainTex);

                      // Calculating normal so it can be used for fake lighting
                      // in the fragment shader
                      float4x4 modelMatrixInverse = unity_WorldToObject;
                      o.normalDir = normalize(mul(float4(v.normal, 0.0), modelMatrixInverse).xyz);

                      return o;
                    }

                    // This is the fragment shader
                    half4 frag(v2f i) : COLOR
                    {
                      // Reading color from diffuse texture
                      half4 col = tex2D(_MainTex, i.txuv.xy);

                      // Using hard-coded light direction for fake lighting
                      half3 th = normalize(half3(0.25, 1, -0.25));
                      // Using hard-coded light direction for fake specular
                      // This matches the value inside the LightmappedPrefabWithSpec shader
                      half3 sth = normalize(half3(0, 1, -0.25));

                      // Fake lighting
                      float lightVal = max(0, dot (i.normalDir, th));
                      float lightScale = 0.75;
                      lightVal = lightVal * lightScale;

                      // Fake spec
                      float spec = max(0, dot(i.normalDir, sth));
                      float specScale = 0.65;
                      float specAtt = 0.65;
                      spec = specScale * pow (spec, 40.0 * specAtt);

                      // Add in a general brightness (similar to ambient/gamma) and then
                      // calculate the final color of the pixel
                      col.rgb = min(half4(1,1,1,1), col.rgb * _Brightness +
                                    col.rgb * lightVal * _Color + col.rgb * spec);
                      return col;
                    }

                    ENDCG
                  }
                }
                FallBack "Diffuse"
              }
            

The objects also either have no shadow, or we created a fake shadow as part of the dynamic bit. Most of our surfaces are flat, so in our case that wasn’t a big obstacle.

Unity-light-baked-prefabs-for-mobile-Galactic-Colonies-MetalPopGames

(Buildings using additional dynamic objects)

There are no shadows on the dynamic bits but it is barely noticeable, unless you know to look for it. The lighting of the dynamic prefabs is also completely fake–there is no realtime lighting at all.

The first cheap shortcut we took was to hardcode the position of our light source (sun) into the fake lighting shader. It’s one less variable the shader needs to look up and fill dynamically from the world.

It’s always faster to work with a constant than a dynamic value. This got us basic lighting, light and dark sides of the meshes.

Specular/Gloss

To make things a bit more shiny we added a fake specular/gloss calculation to the shaders for both the dynamic and the static objects. Specular reflections help in creating a metallic look, but also help convey the curvature of a surface.

Since specular highlights are a form of reflection, the angle of the camera and the light source relative to each other is required to correctly calculate it. When the camera moves or rotates, the specular changes. Any shader calculating would require access to the camera position and every light source in the scene.

However, in our game we only have one light source that we’re interested in using for specular: the sun. In our case the sun never moves, and can be considered a directional light. We can simplify the shader a lot by using only one single light, and simply assuming a fixed position and incoming angle for it.

Even better, our camera in Galactic Colonies is showing the scene from a top-down view, like most city building games. The camera can be tilted a little bit, and zoomed in and out, but it couldn’t rotate around the up axis.

Overall, it is always looking at the environment from above. To fake a cheap specular look, we pretended that the camera was completely fixed, and the angle between the camera and the light was always the same.

This way we could again hardcode a constant value into the shader and achieve a cheap spec/gloss effect that way.

Unity-light-baked-prefabs-for-mobile-Galactic-Colonies-MetalPopGames

(Fake specular/gloss effect)

Using a fixed angle for the specular is of course technically incorrect but it is practically impossible to really tell the difference as long as the camera angle doesn’t change much. To the player the scene will still look correct, which is the whole point of realtime lighting.

Lighting an environment in a realtime video game is and has always been more about visually appearing correct, rather than actually being physically correctly simulated.

Because almost all of our meshes share one material, with lots of the details coming from the lightmap and the vertices, we added in a specular texture map, to tell the shader when and where to apply the spec value, and how strong. The texture is accessed using the primary UV channel, so it doesn’t require an additional set of coordinates. And because there isn’t much detail in it, it is very low resolution, using up barely any space.

For some of our smaller dynamic bits with a low vertec count, we could even make use of Unity’s automatic dynamic batching, further speeding up rendering.

Light baked prefabs on top of light baked prefabs

All the baked shadows can sometimes create new problems, especially when working with relatively modular buildings. In one case we had a warehouse the player could build that would display the type of goods stored in it on the actual building.

This causes problems since we have a light baked object on top of a light baked object. Lightbake-ception!

We approached the problem by using yet another cheap trick:

  • The surface where the additional object was to be added had to be flat and use a specific gray color matching the base building
  • In return for that trade-off, we could bake the objects on a smaller flat surface and place it on top of the area with just a slight offset
  • Lights, highlights, colored glow and shadows were all baked into the tile

Unity-light-baked-prefabs-for-mobile-Galactic-Colonies-MetalPopGames

(The baked objects use a blue glow and cast a shadow)

Cheap and efficient light baking–if you can live with the restrictions

Building and baking our prefabs this way allows us to have huge maps with hundreds of buildings while keeping a super low draw calls count. Our whole game world is more or less rendered with only one material and we are at a point where the UI uses up more draw calls than our game world. The fewer different materials Unity has to render, the better it is for your games performance.

This leaves us ample room to add more things to our world, such as particles, weather effects and other elements of eye candy.

This way even players with older devices are able to build large cities with hundreds of buildings while keeping a stable 60fps.

Unity-light-baked-prefabs-for-mobile-Galactic-Colonies-MetalPopGames

(A player-built colony using light baked prefabs)

Go with what works for your game

Our solution to rendering large planets with busy cities is custom tailored to our game. We went with what works for us. We used our limited camera angles, and the single light source in the scene to cut down the shader complexity by a lot. It also allowed us to cut corners in terms of lighting dynamic objects and calculating specular with a fixed angle. We reduced the amount of dynamic objects and statically batched the rest.

And of course, our low-poly, color-only visual style also makes a lot of things easier, as it allows us to share materials between almost all objects, which makes both static and dynamic batching possible.

Most likely you will not be able to transfer all the solutions described here to your game exactly as they are, and that is ok, because they aren’t meant to be. They’re starting points which you can build upon to find the best ways to optimize your own game. Find what restrictions and limitations your game can live with, and use them to make your game run faster.

Got it

We use cookies to ensure that we give you the best experience on our website. Click here for more information.