Phobos
A hyper accessible persistent networked virtual event space
Phobos is a persistent networked virtual event space that uses WebGL and WebSockets to operate in a web browser. Phobos was created for Indie Maker Syndicate’s inaugural Makers Play event - a hybrid virtual indie game and makerspace showcase.
Environment
The main design goal of Phobos was a hyper accessible persistent networked virtual event space for showcasing indie games and makerspace creations for the Indie Maker Syndicate (March 26-28, 2021). Phobos allows visitors to view the showcases’s projects at their own pace and find out more information about the projects with the least amount of friction. WebGL and WebSocket are now widely supported in modern web browsers and allow visitors easy access to the Phobos client.
Phobos offers 72 virtual exhibit spaces for the indie games and makerspace projects. Each project is given a virtual video screen that loops a short 20-30 second trailer, and a button that takes the visitor directly to the project’s information page with links to store, social, videos, etc. Phobos has a main hub with 4 rooms, and 3 secondary areas leading down to the lower Arena level. Viewing and interacting with project content directly influences the environment. Phobos is alive with the sound of the projects and the visitor’s interaction with them.
Here’s an in-editor view of Phobos, situated near the blue cooling tower:
Here’s an overhead view and 4 side views of Phobos:
The environment for Phobos was designed and created using ProBuilder, and the textures are all procedural textures from Filter Forge.
Shaders
Phobos uses forward rendering for WebGL 2.0 with a few point lights in each area. The custom shader renders four point lights in a single pass using the ideas presented in Unity Multiple Lights.
Further, the custom shader uses a specular lighting model with color, texture, and smoothness, in addition to an optional emission via color/texture variant. The scene lighting uses a single directional and 3 color gradient environmental lighting to give the environment that dusty Mars look and feel.
WebSocket
Phobos uses a custom networking library that utilizes the WebSocket protocol as the transport layer. WebSocket was chosen because the client side WebGL application is targeted for the browser, and WebSocket is the only real choice for efficient bidirectional communication between browser and server. This is a Unity project, and both the client side and the server side are built from the same project and share the same data.
The underlying WebSocket implementation is using WebSocket Sharp for the server side and Unity WebSocket WebGL wrapper for the client side. The WebSocket WebGL wrapper relies upon the browser’s WebSocket implementation and Unity simply calls the native JavaScript methods via extern calls using the DllImport attribute.
Traditionally, since Phobos is a Unity project, I would normally use Photon’s PUN2 networking offering for WebGL (master client/clients). However, in this case, it’s not persistent once the last client leaves and I wanted to ensure that a low bandwidth client wasn’t getting crushed with all of the work. Photon has a real-time offering, but it only supports Windows on the server side. Thus, this custom networking library was created to fill that gap. It’s similar in some ideas to PUN2, but much different in that the server is authoritative.
Important Note: Unity Editor cannot connect to the server using TLS. In order to allow for developmental testing from the Unity editor, a -noTLS flag was added to the server side to disable TLS:
if (this.serverUseTLS) {
this.wsServer.SslConfiguration.EnabledSslProtocols = SslProtocols.Tls12 | SslProtocols.Ssl3;
}
Once the minimal client and server were setup and taking to each other, it was time to tackle a few of the harder problems.
Also, misc note on the server. Since it’s running headless, need to cap the frame rate or it’s going to run at 1000 fps and flood out a ton of packets. I have a command line parameter on the server to set the frame rate, and found that 12 fps is a well balanced number for responsiveness and data usage.
Packets and Marshaling
The client and server communicate by passing byte arrays via the WebSocket service. Using the InteropServices, need to marshal the packet structs to a byte array that can be transmitted between the client and server.
using System.Runtime.InteropServices;
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Ansi)]
public struct WsMsgTransform
{
public WsMsgFormat format;
public uint objectID;
public Vector3 position;
public Vector3 rotation;
public Vector3 scale;
}
The WsMsgFormat is an internal enum that indicates which type of packet it is in the first byte.
In order to convert the struct (e.g. WsMsgTransform) to/from bytes, these two helper methods are used:
public static byte[] ToBytes<T>(T obj)
{
int size = Marshal.SizeOf(obj); // determine size of obj
byte[] array = new byte[size]; // array to hold struct bytes
IntPtr ptr = Marshal.AllocHGlobal(size); // allocate unmanaged memory of size
Marshal.StructureToPtr(obj, ptr, true); // copy structure to pointer of unmanaged memory
Marshal.Copy(ptr, array, 0, size); // copy from unmanaged memory to array
Marshal.FreeHGlobal(ptr); // free allocated unmanaged memory
return array;
}
public static void FromBytes<T>(byte[] array, ref T obj)
{
int size = Marshal.SizeOf(obj); // determine size of obj
IntPtr ptr = Marshal.AllocHGlobal(size); // allocate unmanaged memory of size
Marshal.Copy(array, 0, ptr, size); // copy from array to unmanaged memory
obj = Marshal.PtrToStructure<T>(ptr); // copy from unmanaged memory to array
Marshal.FreeHGlobal(ptr); // free allocated unmanaged memory
}
As an example, to send the transform update packet for an object from the service to the clients, first the struct is created for the packet, converted to bytes, and then broadcast to the clients:
WsMsgTransform wsMsg = new WsMsgTransform();
wsMsg.format = WsMsgFormat.TRANSFORM;
wsMsg.objectID = networkObject.objectID;
wsMsg.position = networkObject.transform.position;
wsMsg.scale = networkObject.transform.localScale;
wsMsg.rotation = networkObject.transform.eulerAngles;
byte[] bytes = WsMsg.ToBytes(wsMsg);
this.wsServer.WebSocketServices["/servicename"].Sessions.Broadcast(bytes);
On the client side, the HandleMessage will receive a byte array and the first byte will determine the format. Using the identified format, the proper message struct can be recreated from the bytes:
case WsMsgFormat.TRANSFORM:
WsMsgTransform wsTransform = new WsMsgTransform();
WsMsg.FromBytes(msg, ref wsTransform);
// handle message on main Unity thread..
Note, the client and the service side are both in child threads, and will need to ensure the Unity Main thread is used to process the actual message structures since no Unity calls can be made outside the main thread. Using a similar flow as UnityMainThreadDispatcher is helpful here since messages may be queued up for the main Unity thread to process in order.
Object Synchronization
The same Unity project is used for both the client side and the server side. An advantage of this is that the same data is present in both and will always be in sync if built from the same commit. The two scenarios of data to be synchronized are: objects that exist at build time, and objects that were spawned in at run time.
In order to synchronize objects, each object needs to have a unique persistent ID. The scene objects that are network aware (using the network tracking component) will self register at edit time with the network object tracker, which will assign it a unique ID. In this instance, I have set build time objects to have an ID less than 100000. When a network object is instantiated, the server will register it with the network object tracker and assign the ID when it sends the network instantiate packet to the clients. The run time objects will have IDs starting at 100000 and will increment up. When an object is destroyed, the network object tracker will mark it as deleted.
When a new client connects, the server sends an update of all the build time objects' information (and status if deleted), and an update of all the new run time created objects. After this initial sync, the new client will be up to date with the server and other clients.
Object Ownership
The server will let the client know which object it owns, and the client is able to effect direct change upon those owned objects. The server is also doing a full physics simulation and will correct the clients which get too far away from truth. Usually the server owns all of the objects with the exception of the direct player controlled character.
RPCs
The clients may broadcast events/actions to the server for it and other clients via remote procedure calls (RPC). Each of the available RPC calls are identified before build time, using a custom attribute: [NetworkRPC]
public class NetworkRPC : Attribute
{
// used to find all RPC methods
}
The RPC Manager will find these on script reload via the [DidReloadScripts] attribute:
[DidReloadScripts]
private static void OnDidReloadScripts()
{
// TypeCache will find methods with the NetworkRPC attribute
TypeCache.MethodCollection networkRPCMethods = TypeCache.GetMethodsWithAttribute<NetworkRPC>();
foreach (var rpcMethod in networkRPCMethods)
// do stuff
Each NetworkRPC method may then be identified in the source via a [NetworkRPC] attribute:
[NetworkRPC]
public void DoSomething(Vector3 position)
{
// do stuff
}
The client code would call the NetworkManager.RPC and instruct the server and clients to execute the method:
this.networkManager.RPC(this.GetType().Name, "DoSomething", this.objectID, position);
The compiler will fill in the “this.GetType().Name” with the actual class name and “DoSomething” is the string name of the NetworkRPC tagged method. The server/clients will process the RPC packet and call the method via MethodBase.Invoke.
In response to receiving the Network RPC packet, the ObjectID is used to find the GameObject referenced, and the server/clients may execute the component’s method on it:
private void CallRPCMethod(GameObject targetGO, string componentName, string methodName, object[] parameters)
{
Component baseComponent = targetGO.GetComponent(componentName);
Type baseType = baseComponent.GetType(); // e.g. PlayerController
System.Reflection.MethodInfo rpcMethod = baseType.GetMethod(methodName);
rpcMethod.Invoke(baseComponent, parameters);
}
Since all the RPCs are preregistered, the RPC packet merely has an index in the RPC list that contains the component and method names which are used for the Invoke call. The object[] parameters is an object array filled in with the types that the target RPC method is expecting as parameters. In this example, it’s a single Vector3 position.
That’s a pretty high level summary of the core of the network library built upon the WebSockets transport. The Marshal methods took a bit to figure out how to send various data types over the network, and the rest built upon that foundation.
Closing
I’ll most likely take a few more passes at this article in the coming months. For now, it’s a high level overview of the project and some hints on the harder part of rolling a custom client/server network library built on the WebSocket protocol.
Hope you found this article on Phobos interesting!
Jesse from Smash/Riot