A tale of lessons learned developing for a platform that literally caught fire
by
(aka Kieran Lord)
As usual I may swear and rant.
This was really hard to make into a cohesive talk.
The name of the talk was going to have this really cool point in 5 steps...
However in re-writing this talk I found a better way to do it.
So lets fix up the title
by
(aka Kieran Lord who sucks at naming talks)
I make games!
Plus a bunch of middleware, consulting, professional education and R&D.
A deep dive into how to optimize for a particular kind of hardware or game.
Hard limits or rules on "don't use this feature" or "don't use more than X drawcalls"
A case study of a project that was a right nightmare to optimize all throughout development
The focus is going to be on looking at processes and guidelines that we used to finish it.
You're in a processes and workflow talk.
Everything put into a game should be in a state & quality where it could go into the final game.
Basic performance is a big part of that quality.
If performance is bad, work will need to be redone, crunched or cut.
(Obviously excluding prototyping & first passes)
Fig1. Solution not shippable
You need baseline performance in order to make the game.
That means the game must run at target frame rate during development.
"Early optimization is the root of all evil"
Fig2. How poor performance affects designers & artists.
Performance is the entire team's responsibility.
Everyone in the team needs the tool & knowledge to manage performance for anything they're making independently of others.
You need to make tools and teach other members of the team how to use them.
If performance problems, common errors and or busywork can be automated DO IT.
Fig3. A tool that saved hundreds of hours and took 5 minutes to make.
First person walking game, always moving forward.
You're a zombie and you run into people to eat their brains.
The round ends if you go too long without eating any brains.
You look really dumb while playing it.
Small but visually complex environments (for GearVR)
Very rough S6 guidelines
Weak GPU but much more easily CPU bound (mostly rendering)
The goal was to write code that was quick to make/edit and had a good baseline performance.
Always profile before/after each change.
Refactor or optimize areas that are running slow.
Focus on shortcuts that avoid major issues.
Everyone on the project should be able to use the profiler.
But as a programmer, it should be used to test everything before it's put in the game.
It also shows when/where heap allocations are occuring in the callstack.
Never just rely on your gut feel that "it's faster". Always compare before/after in the profiler.
Other roles need the tools to make content for the project as efficiently as possible.
If someone in a non-technical is spending time every iteration doing technical work... at least consider that for automation.
Don't just use the unity default features, turn it into an editor built around your team & game.
The most powerful feature unity has is how flexible it's editor scripting is.
Everyone on the team needs to know at least rough limits of what they're working on (how many characters / sounds / fx / polys etc).
Finding those out/keeping them up to date is just as important as writing features.
Always ensure there's a small amount of breathing room in these numbers so that people can break them a little if needed.
Heap allocations can be VERY slow.
A programmer specific performance problem.
PCs aren't nearly as badly effected by this as as consoles & mobile.
If unchecked this can make porting a nightmare.
Garbage collection happens when you allocate something and the heap is full/fragemented, too much time has passed or if you call GC.Collect()
There's significant CPU spike as the garbage collector cleans up deleted objects on the heap.
You can manage the severity of the next GC spike by calling GC.Collect when CPU usage is low.
List<Objective> m_tempObjectives
= new List<Objective>();
public override void OnUpdate()
{
base.OnUpdate();
m_tempObjectives.AddRange(m_objectives);
foreach (var objective in m_tempObjectives)
{
if (objective != null)
{
objective.Update();
}
}
m_tempObjectives.Clear();
}
Go read the official Unity tutorial "Optimizing garbage collection in Unity Games" ... it's detailed! VERY detailed!
In Brief:
The cost of physics in CPU is heavily affected by on complexity and number of colliders near one another.
This can be cut down a lot.
Use layermasks extensively, especially for triggers & raycasts.
Also try to keep your physics environment "clean"
If you don't need to update something every frame... don't!
Break expensive logic out from required updates, in the update make it animate correctly and blend any changes.
Use coroutines, jobs, timers or managers to update that independently of the framerate/physics timestep.
Profiling everything before/after each change revealed that our "gut feel" couldn't be trusted at all.
Getting everyone on board with GC alloc rules was a great time investment.
Regularly updated benchmarks & tools made for other team saved both their time and programmer time (less support needed!)
Before explaining the process we need to briefly discuss the rendering pipeline.
To just make the core gameplay we were pushing the GearVR's rendering limits.
The requirements of the render pipeline dictated the art style, asset pipelines and the team's workflows.
A drawcall is basically a SetPass call followed by one or more vertex buffers sent to the GPU.
SetPass is called between batches of objects to set textures & render parameters.
SetPass can be very slow on CPU
If the Render thread gets behind, the GPU can be left idle.
Batching is where unity tries to group objects together to minimize the number of SetPass calls.
Consoles, Standalone VR and mobile are much more affected by drawcalls than PC.
Which render APIs your platform supports has a big impact on how expensive drawcalls are.
Unity is faster at submitting draw calls with the new APIs such as vulkan/metal/DX12 .
Additionally on some platforms they allow drawcall submission to be multithreaded heavily.
If you are only releasing for platforms that support this you'll generally be able to run many more drawcalls without becoming CPU bound.
Hardware support for these isn't universal however... and vulkan is presently unsupported on GearVR
The first kind of batching unity supports is "Static Batching".
Works as "baked" batching for static geometry (though can be done at runtime).
Has significant memory overhead.
Reliably saves CPU time.
It performs significantly better with large "chunks" of geometry rather than hundreds of small objects.
Dynamic batching merges groups of meshes each frame CPU for rendering.
Limited to meshes of ~300 verts or less, depending on your shader.
Only saves CPU on platforms where SetPass is very slow.
Test with/without dynamic batching.
Most games run faster with dynamic batching disabled!
Draws a single mesh multiple times with different render parameters.
Very good performance compared to other batching methods.
This feature would have been perfect for Zombie...
...but it wasn't supported for GearVR at the time
Zombie was unfortunately the worst case scenario.
So everything had to be static / dynamic batched.
All assets needed to be authored, processed compiled to make maximum use of both batching types
We were constantly alternating between being CPU and GPU bound
The profiler was the easiest way to track if we were CPU or GPU bound and what was the cause.
Gfx.WaitForCommands will indicate when the render thread is waiting for the GPU.
Long processing in Camera.Render usually suggested being bound by drawcalls.
Profiler does affect performance significantly on device. So we had a "framerate info" overlay built into dev builds.
If we were CPU bound, we'd start by opening up the frame debugger to see what drawcalls could be cut or batched together.
It made tracking down when batching wasn't working a lot easier.
Additionally it helped us with ordering our rendering to minimize overdraw (the main way we were GPU bound)
Unfortunately the FD had issues with VR devices at the time, so we'd mostly use it in editor as a guide.
Static batching was always a saving for CPU.
We had to prevent certain objects static batching to avoid overdraw.
We were in the unfortunate position where needed dynamic batching to make the game work.
A lot of the problems we faced on this project could have been avoided if the platform supported instancing or newer APIs
The artists should be able to build models, put them into the game & test them.
Any technical busywork should be automated and have a fast iteration time.
The more of the time artists are focused on making content for the game (within constraints) the better the game gets.
Teach artists how to use the profiler to check their work using the profiler!
Just the parts of the profiler relevant to their work.
How to check polygon budgets, drawcall restrictions etc.
Everything had a specific, simple asset type.
Eg. All NPCs have a CharacterType asset as a central point for other assets that define how they look/sound/behave.
Testing characters, objects, effects etc in both empty scenes or levels in the game was supported and easy.
Drawcalls can only contain objects with the same material & params.
So if you need to reduce drawcalls, merging textures and materials is the first thing to do.
My preference is to generate altas textures, meshes & materials automatically in editor.
Unity does provide methods to combine textures for this, but getting a good automatic workflow takes a bit of editor scripting.
There's some tools on the Asset Store that do variations on this if you don't want to roll your own
The difficulty with atlases is keeping them up to date.
Editor scripts checked if any inputs to the atlas had changed. The atlas only auto-rebuilds if this has happened.
This kept iteration time down as reimporting atlases was quite slow.
Auto-generating atlases was an easy win, and less work than expected.
Teaching artists to use the profiler was really valuable.
In future I'm going to make it so artists can export groups of models (eg, all characters) in a single fbx and import scripts break them up & fix transforms.
Everyone on the team had a hand in working on the levels.
Despite the restrictions, the tools and limitations had to be fully understood by everyone.
We also wanted to use probuilder (an in-unity level/mesh editor) in the pipeline as the team was already familiar with it.
Each level is built in grey box using probuilder.
Visual meshes are arranged to cover the probuilder geometry.
Design focused geometry makes fast & clean collision and navigation meshes and is easier to tweak.
Editor scripts whenever the scene is saved
Navmeshes are built from probuilder geometry flagged as "colliders"
Occlusion is built for visual meshes (and probuilder objects flagged as "occluders")
Warnings are thrown for common mistakes.
Static visual meshes are grouped into chunks by hand.
Chunks are parent objects that atlas & merge all visual meshes under them on play/build.
Selecting which meshes were in each chunk was a tradeoff between drawcalls (better static batching) and polycount
From benchmarking we knew rough "safe limits" for drawcalls and polycount. (75k static, 8k dynamic, 25 drawcalls/eye)
Everyone was shown how to use the profiler to track drawcalls & polycount.
If anyone could reorganize chunks/occlusion to fit more polys, they could add more detail.
Quality skyrocketed.
Everyone wanted to minimize polys to fit more stuff but we had "wasted polys" on objects that were never seen.
So we added "Backface Cull Volumes" to the scenes to strip polygons during the chunking process.
Any face inside the volume that matches the direction of the volume gets discarded.
We generally saved 20% of polys per level using these.
The trimmed polys still wasted space in the lightmap, a tradeoff for it being a simple workflow.
Chunks are parented to culling sectors. A parent with a bounding volume
A culling sector has a list of the other sectors that are visible when the camera is inside it.
Some sectors didn't have their own geometry, they were just "triggers" for visibility sets around doorways.
This is separate from unity's occlusion culling which is also used.
We accidentally made a game out of fitting more detail into levels... and we got pretty good at it.
Automatic processes with simple markup volumes are really easy to explain and use.
Custom occlusion culling could have been easier to use but fit the needs of the project.
Having benchmarked limits that people can work against is essential (even if they're rough)
Wanted to make sure that everyone was testing on final hardware as much as possible
Or at the least checking everything in desktop VR.
People tend to avoid this as it's slower, espeically with GearVR build times.
Iteration time is key!
Every scene needs to work when you push play. No exceptions.
If any systems needed by the scene controller or any actor are auto-constructed.
Testing on device is slow enough as is. Being forced to make a full build is too much.
We usually just want to test one level, so we needed a way to one click build & run a single scene.
I'd maintained a fork of UBS (an opensource unity build manager) on previous projects which was expanded for this.
We didn't have enough headsets go around. So we needed to support non-vr builds.
Expanded the build system to have a concept of plaforms (Skus)
Each SkuType was an editor script that configured the project & plugins to build or run in this mode.
Each SkuType also injects a prefab into each built scene based on the platform (eg, to handle different VR camera setups)
public class SkuTypeZombieGameNonVR
: SkuTypeZombieGame
{
#if UNITY_EDITOR
public override void EnablePlatform()
{
base.EnablePlatform();
UnityEditor.PlayerSettings
.virtualRealitySupported = false;
}
#endif
public override bool vrSupported
{
get { return false; }
}
}
Unity generally has good iteration time during prototyping.
Keeping that iteration time up during development is up to you.
In our case level specific builds/non-vr versions helped a lot but each project is different!
foreach(var mr in gameObject.GetComponentsInChildren<MeshRenderer>())
{
// do stuff!
}
using(var mrList = gameObject.GetComponentsInChildrenTempList<MeshRenderer>())
foreach(var mr in mrList)
{
// do stuff!
}
Fig1. Creates a new array on the heap
Fig2. Example using a pooled temporary list instead
public class TempList<T> : IDisposable, IList<T>
{
private static readonly Queue<TempList<T>> s_lists = new Queue<TempList<T>>();
public readonly List<T> list = new List<T>();
private TempList() {}
// acquire a temporary list
public static TempList<T> Get()
{
lock(s_lists) return s_lists.Count > 0
? s_lists.Dequeue()
: new TempList<T>();
}
// return a list back to the pool
public void Dispose()
{
list.Clear();
lock(s_lists) s_lists.Enqueue(this);
}
public static implicit operator List<T>(TempList<T> from)
{
return from != null ? from.list : null;
}
// ILIST IMPELMENTATION (call appropriate method on this.list)
}
Fig3. TempList implementation
public static TempList<T> GetComponentsInChildrenTempList<T>(this Component @this,
bool includeInactive=false)
{
var tempList = TempList<T>.Get();
if(@this!=null)
{
@this.GetComponentsInChildren<T>(includeInactive, tempList.list);
}
return tempList;
}
public static TempList<T> GetComponentsInChildrenTempList<T>(this GameObject @this,
bool includeInactive=false)
{
var tempList = TempList<T>.Get();
if(@this!=null)
{
@this.GetComponentsInChildren<T>(includeInactive, tempList.list);
}
return tempList;
}
Fig4. TempList extension methods
AtlasData stores info on how to map textures to
MeshGroups store all the meshes created for atlased models.
Prefabs are created for each of the atlased models.
AtlasMesh files created for each atlased model. Dummy assets that are lookup keys for the real models.
In order to auto-rebuild atlases if they're needed we need to be able to track if contents have changed.
Building the atlas creates a hash asset which stores Unity's hash for each asset.
If the list of GUIDs/hashes for the AtlasGroup's inputs don't match the hash asset, the atlas is out of date
This worked really well! It's a reliable & general purpose solution for auto-generating assets.
Defines what's going to be built into an atlas and how.
Takes Model/Material list assets as inputs.
Used as a handle for the atlas by the rest of the game.
When built it creates a child folder for all the output assets.
Recursive list assets for models & materials as inputs for atlases.
List of assets and a list of child lists.
Easy to maintain lists of objects/characters by hand, but tracking what levels use wasn't.
Saving a level creates/updates a material list for all static meshes in that level.
Problem: We used lightprobes instead of dynamic lighting + dynamic batching.
Light probes calculate SH values from the light probes for MeshRenderer's anchor transform.
Different SH values = different batches for dynamic batching.
Characters drawcalls:
No anchor
1 character = up to 50 drawcalls
Waist bone as anchor
1 character = 1 drawcall.
(Same as using skinned meshes)
Multiple characters with shared anchor
up to 5 characters = 1 drawcall
We also had the problem that applying the atlas UVs to meshes at runtime was too slow on the GearVR.
So "pre-atlased" versions of models for characters & objects needed to be built with the atlas texture.
Each atlas needed to have a lookup table for a prefab to use instead of the model.
Environments marked up with light sectors, bounding volumes.
Objects/Characters look up which light sector they're in and use it's transform as their light probe anchor.
Light sectors can overlap! Objects use the sector with the highest priority.
Have to be tweaked by hand to make it look right.
Pros:
Solved our batching problems!
Also simplified adding light probes to levels. The sectors automatically created light probes for each bounding volume.
Cons:
Placement of light sectors to look good became a level design/art problem. More on that soon!
Still paying for transforming/culling/occlusion on each mesh seperately.
Boxing occurs when a value type (struct, int, bool etc) is passed into a function that expects an interface or object.
The value type creates a temporary System.Object that contains the value so that the function can treat it like an object.
This is very easy to do without realising it.
If a function that shouldn't be allocating is showing up on the profiler with GC Alloc this is the likely culprit.
void SurpriseAlloc()
{
int cost = 5;
string displayString
= String.Format("Price: {0} gold", cost);
}
public interface IInterface {}
public struct MyStruct : IInterface {}
void ThisIsBadToo()
{
var val = new MyStruct();
InterfaceBoxingExample(val)
}
void InterfaceBoxingExample(IInterface val)
{
Debug.Log("dang");
}
bool AnotherExample(object thing)
{
int val = 5;
return thing!=null && thing.Equals(val);
}
Raycasting is incredibly useful, however it's very expensive on CPU (and operates in the main thread!).
Caspule/Sweep casts are even worse!
It's very easy to cause spikes even on PC if too many raycasts occur in one frame.