Most of the optimization tips mentioned below were premised on our game- Enemy waters. Needless to say, the effectiveness of these tricks might differ with the kind and scale of your game. So, as a golden rule, always measure the performance gain when you perform some optimizations and exercise some common sense before embarking on sweeping changes to your code.
Gameplay trailer of Enemy Waters
Unified Particle Systems
In Enemy waters, at the fag end of the game, there are levels where the player has to lead his fleet against more than 10 enemy vessels consisting of both submarines and warships. Once the battle starts, you can imagine the amount of ordnance that will be flung - torpedoes, depth charges and cannon shots, and with this comes a significant performance drop on lower end mobile devices- during the most decisive part of the game.
The issue was that every bit of ordnance has it own trail effect behind it. Torpedoes have a wave like wake behind them. Cannon shots leave a trail of smoke, and depth charges emit bubbles as they descend on an unfortunate submarine.
This problem was solved by what we called as - unified particle systems- so instead of having one particle system for every torpedo to leave waves behind it, we have just one particle system which every torpedo will ‘ask’ to leave ‘x’ number of waves and where to emit those waves. It will look something like this
public static void GenerateTorpedoTrailParticles(ParticleSystem particleSystem, float startLifeTime, Vector3 particlePosition, float positionRandFeed, Vector3 prevPos, Vector3 curPos)
//ignore the SqrtHelper.helper.GetSqrt function for now.
float distCovered = SqrtHelper.helper.GetSqrt(Vector3.SqrMagnitude(curPos-prevPos));
//controlling the number of particles based on user’s settings
float qualityMulti = Mathf.Lerp(1.5f, 3f, GraphicsManager.settings.particleEffectsQuality);
//figuring out the number of particles that need to be emitted
int particleCount = Mathf.RoundToInt(distCovered*qualityMulti);
ParticleSystem.EmitParams tempEmitParams = new ParticleSystem.EmitParams();
tempEmitParams.startColor = particleStartColor;
for (int i = 0; i < particleCount; i++)
tempEmitParams.position = particlePosition + positionRandFeed * Random.onUnitSphere+ Random.Range(0f, 1f)*(prevPos-curPos);
tempEmitParams.rotation = Random.Range(0f, 360f);
tempEmitParams.startLifetime = startLifeTime;
tempEmitParams.startSize = Random.Range(0.5f, 1.5f);
So in the end, we had just one particle system which will emit all the torpedo waves in the game. One particle system for all the chimney smoke emitted by ships and so on. This gave us a significant performance boost- especially during those critical moments. Note that this will only work for any ‘continuous’ particle effects. It will be very hard to squeeze one-shot explosion time particle systems using this technique.
Raycasting Against Terrain
In certain scenarios, you can get away by using Terrain.SampleHeight function instead of ray casting against a terrain. In our case, for torpedoes and cannon shots, instead of ray casting every frame against the terrain, we sample the current height of the object against the terrain’s height map and if that number is higher than the object’s ‘y’ value, it means it has entered the terrain and needs to be exploded.
float sampledTerrainY = TerrainManager.manager.gameTerrain.SampleHeight(thisTransform.position);
//explode if the sampled height is higher than the bullet
if (sampledTerrainY+TerrainManager.manager.thisTransform.position.y > thisTransform.position.y)
//explode the shell
Raycasting is not a extremely expensive operation- but if you have 50-100 cannon shots gettin fired around and they are raycasting every frame and checking for collisions, it might eat up some valuable cpu time on lower end devices.
Square Root Tables
This may be of great help if your game is AI heavy, and if that AI is dependent on distance. And enemy waters has a complex AI system. Every warship/submarine not controlled by the player will be controlled by AI. and that AI constantly searches for targets both above water and underwater.
You may have already know that using Vector3.sqrMagnitude should be used instead of Vector3.magnitude wherever you can(if you are hearing this for the first time, you have a lot to catch up on optimizing games). But there will be cases you have to use vector3.magnitude
That is where this line comes into picture(from the unified particle systems section)
float distCovered = SqrtHelper.helper.GetSqrt(Vector3.SqrMagnitude(curPos-prevPos));
SqrtHelper has a table of sqrt values ranging from 1 to 10,000 that were computed in unity editor(not on the mobile device). This square root table will only increase your build size by few hundred kb. And also, the sqrt data is a interpolated number of nearest sqr roots and it will not be as accurate as Vector3.magnitude, but unless you are a making a mobile version of kerbal space programme, it should be fine.
To retain the blog’s readability, the scripts related to the SqrtHelper were uploaded on github here
Note: I read somewhere that some mobile CPUs take less time to compute the square root than accessing an array, but we did not notice this in our testing with the mobile devices we have.
Ok, not everything, but a lot more things. Hyperbole aside, chances are, you are not using pooling to its fullest potential. You may have already heard about the advantages of pooling against reckless usage of instantiate and destroy calls. To go one step further you can use pooling to make your game objects as light as possible.
For example, In enemy waters, the torpedoes had three one-shot explosion particle systems attached to them(apart from the continuous particles we covered in the first section)- one to play when it hits a warship/submarine, other when it hits the terrain, and another when the torpedo auto destroys after some time. There is no need for the torpedo to carry these three particle systems under it when it will only use one of them depending on the scenario, so we pooled the particle effects. Depending on the use case, the torpedo will request the PoolManager for one for its three particles and use it. Simply put, almost anything that a gameobject may or may not use during its lifetime but is part of the game object’s hierarchy can be pooled.
Don’t put Rigidbodies on everything
Before i lead you astray, let me clarify that you must put rigidbodies on objects with colliders for performance reasons(this is a official unity recommendation- never use colliders without rigidbodies). What we are talking about here is to minimize the usage of physics components as much as possible, and replace rigidbodies and colliders with simple maths. For every object with a Rigidbody or a collider, the physx engine has to maintain a copy to compute the object’s movement with regards to forces acting on it.
This logic is extremely relevant for bullets. There is absolutely no need to put rigidbodies and colliders on bullets, even if you are pooling those bullets. You can simple assume that the bullets have no width and linecast(Physics.LineCast) every frame from its previous position to current position and check for collision.
During the LineCast you must use layer mask and do the check only on layers where the collision is relevant. So, you can skip the terrain layer here if you are using the “Raycasting Against Terrain” technique mentioned before.
You can also skip collision with a water layer by simple logic. Most games have water at a constant height, so the water can be represented as an equation, say y=5f(if your water’s y value is 5), so simply check if the bullets y value is less than 5f.
And most importantly, you don’t have to do any raycast at all if you are sure that the bullet in question is not close to any of the relevant colliders. How do we check this- in our case, we have a list of current active(alive) submarines and ships. We just cycle through the list checking if any of them are close to the bullet, if they are not - skip the raycast. Below is the pseudo code
for(every ship or submarine)
p1 = ships/submarine position;
p2 = bullet position;
disp = p2-p1;
if(disp.x<radius && disp.x > -radius && disp.y<radius && disp.y > -radius && disp.z<radius && disp.z > -radius )
break;//must break the loop because you don’t have to raycast more than once per frame
In the next blog post, I will discuss how we did the water waves and foam particle effects when the submarine dives and surfaces - in a performance friendly manner.
Before starting the project, we had a goal of making the game run on devices with just 1gb ram and mali 400 gpu(a 6 year old gpu that is being used by 30% of android devices out there). I am happy to say we’ve met that objective. We also wanted to make the game as gorgeous as possible on high end devices. So we came up with an exhaustive graphics system to scale up each graphics parameter based on the device- but that can be a blog post for another day
Update: The game is live right now on Android and iOS. You can download the game here