Lately, I've been working on a fast-paced VR multiplayer shooter game in Unity with some classmates. Since we've had negative experiences with UNET in the past and knew we would need to optimize the netcode pretty carefully if the project was to play well, we decided to build a custom networking layer on top of the fantastic Lidgren UDP library. Most of my time has gone into building the networking layer from the ground up (which has been a total blast).
Strings are big and wasteful, but so easy
We initially jammed a prototype out in a weekend (which you can see a mildly self-deprecating demo of here - that is undoubtedly the final title). The netcode was a bit rough, but it worked well enough and, with some minor revisions, served as a good basis to build upon. As the name of this article implies, one of the first revisions we tackled had to do with networked strings.
I knew that we were wasting quite a bit of bandwidth by repeatedly sending the same strings for things like RPCs and object spawns to clients. Initially, I was either going to have the server dynamically build a mapping of numerical identifiers to strings as the game progressed and synchronize that with clients - which sounded like a bit of a nightmare - or manually maintain a static mapping with some editor utility. I was torn - the second option was way less prone to race conditions, but also considerably harder to maintain. As I was profiling, however, it occurred to me that most of the important strings were available at some capacity either directly in the code or in the asset folder. I realized that it would be fairly trivial to write an editor script which generated all of these mappings for us. At runtime, we could load this precompiled set of values and dynamically send either the numerical index into that table or, if we happen to miss, the full string.
How it works
We already had a custom build script for quick iteration during the jam (something which I believe is absolutely essential, especially for multiplayer VR development), so adding a prebuild step was fairly simple. Before we start the build, we search for anything in any of the project's Resources folders and store each relative path in a HashSet<string>. Then, we iterate through all types in our project which might contain RPCs and search for any ObjectRPC or StaticRPC attributes, storing the names in the same HashSet. Here, we also search for any RegisterNetworkString attributes so that developers can manually add strings to the cache. After we've gathered all of this into the HashSet, we assign an integer ID to each string and write these pairs to a TextAsset, encoded as an id:string pair per line, in the root of the Resources folder. At runtime, we load this into two dictionaries - one mapping int->string and another mapping string->int - and anytime a call is made to NetOutgoingMessage.WriteCachedString, we check to see if the desired string exists in this cache. If so, we write a 1 bit and the ID. If not, we write a 0 bit and the actual string. NetIncomingMessage.ReadCachedString then checks this bit and returns either the string at the sent index or the string written to the net message.
Closing and Code
While the actual optimization isn't all that special, there is a valuable lesson to be learned here - pay careful attention to the things that you can automate! The prebuild script took almost no time to write and it makes a huge difference for our build pipeline. Not only is it considerably less error prone than manually setting up the string tables before a build, but it is also way faster for our team for essentially no cost.
While the project is still fairly young, we've taken some pretty clever steps to ensuring an usable, stable networking API which is fast enough for a relatively unpredictable VR shooter. I'll be talking more about that in the coming weeks.
Finally, here's the relevant code to generate the string cache. It doesn't run as fast as it possibly could and it generates more garbage than it needs to, but it happens so infrequently that it doesn't really matter. I'll leave the actual usage of this to you, the reader.
ResourceManifestEditor.cs:
public static void RegenerateManifest()
{
var data = "";
AssetDatabase.Refresh();
foreach (var v in Directory.GetFiles("Assets", "*", SearchOption.AllDirectories))
{
var path = Path.Combine(Path.GetDirectoryName(v), Path.GetFileNameWithoutExtension(v)).Replace('\\', '/');
if (path.Contains("/Resources/") && !v.Contains(".meta") && !string.IsNullOrEmpty(Path.GetFileNameWithoutExtension(v)) && !v.Contains("_resourceManifest.txt"))
{
var index = path.IndexOf("Resources/") + "Resources/".Length;
if (index < path.Length)
{
path = path.Substring(index);
var obj = Resources.Load(path);
if (obj)
{
var encoded = path + "\n";
data += encoded;
}
else
{
Debug.LogError("Non-resource in resources @ " + v);
}
}
}
}
Resources.UnloadUnusedAssets();
File.WriteAllText("Assets/Resources/_resourceManifest.txt", data);
AssetDatabase.Refresh();
}
NetworkStringManager.cs:
public static void GenerateNetworkStrings()
{
// Tells it which assemblies to search
// We could search literally everything, but that takes a long time
var assemblies = new List<Assembly> {
typeof(NetworkStringRegistry).Assembly
};
var set = new HashSet<string>();
EditorUtility.DisplayProgressBar("Building Network Strings", "Preprocessing...", 0);
try
{
EditorUtility.DisplayProgressBar("Building Network Strings", "Generating resource strings...", 0);
ResourceManifestEditor.RegenerateManifest();
foreach(var resource in ResourceManifest.Resources)
{
set.Insert(resource);
}
EditorUtility.DisplayProgressBar("Building Network Strings", "Searching code...", 0);
//var assemblies = System.AppDomain.CurrentDomain.GetAssemblies();
for (int i = 0; i < assemblies.Count; ++i)
{
float baseProgress = ((float)i) / ((float)assemblies.Count);
float nextBaseProgress = ((float)(i + 1)) / ((float)assemblies.Count);
var assembly = assemblies[i];
var types = assembly.GetExportedTypes();
for(int ti = 0; ti < types.Length; ++ti)
{
var type = types[ti];
EditorUtility.DisplayProgressBar("Building Network Strings", "Checking type " + type.Name + " for values...", Mathf.Lerp(baseProgress, nextBaseProgress, ((float)ti) / ((float)types.Length)));
foreach(var v in type.GetCustomAttributes(true))
{
if (v is RegisterNetworkStringAttribute)
{
var attrib = v as RegisterNetworkStringAttribute;
set.Insert(attrib.Value);
}
}
foreach(var method in type.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Static))
{
foreach(var a in method.GetCustomAttributes(true))
{
if (a is ObjectRPCAttribute)
{
var rpc = a as ObjectRPCAttribute;
set.Insert(rpc.Name);
}
if (a is StaticRPCAttribute)
{
var rpc = a as StaticRPCAttribute;
set.Insert(rpc.Name);
}
}
}
}
}
var strbuild = new System.Text.StringBuilder();
int current = 0;
foreach (var v in set)
{
strbuild.AppendLine(current + ":" + v);
++current;
}
System.IO.File.WriteAllText("Assets/Resources/" + NetworkStringRegistry.fileLocation + ".txt", strbuild.ToString());
}
finally
{
EditorUtility.ClearProgressBar();
}
AssetDatabase.Refresh();
}
Note that HashSet.Insert is just an extension method that checks to see whether or not it already contains the element before adding it.