📜 ⬆️ ⬇️

UNET - new network technology in Unity 3D

Some time ago, at the Unite Asia conference, we reported on the development of new multiplayer tools, technologies and services for Unity developers. The internal name of this project is UNET, which simply means Unity Networking. But our plans go far beyond simple networking. As you all know, the main goal of Unity is to democratize the game development process. The Unity Networking team wants to democratize the development of multiplayer games. We want all game developers to develop multiplayer games of any type with any number of players. Of course, this is not the easiest task, but we all have already solved it in the past and really want to do it again (because it is really cool!). We decided to divide our common goal into several phases, which should be well known to Unity developers. According to this approach, we will release phase 1, get user feedback, take them into account in our work to make the next phase even better and repeat this cycle. For UNET, Phase 1 will be what we call the Multiplayer Foundation — we’ll tell you about it just below. Phase 2 will be built on the basis of phase 1 and will provide technology for creating games with authorization on the server, which we call Simulation Server, about it in the next articles. In phase 3, we will add the ability to coordinate multiple Simulation Servers using the Master Simulation Server system. As always, the exact release date can not be called, especially given the collection of feedback from our users. But we can say that phase 1 will be part of the 5.x release cycle, and phase 2 is now at the research stage.



Before joining Unity, members of our network team worked mostly on MMOs like Ultima Online, Lord of the Rings Online, Dungeons and Dragons Online, Marvel Heroes, Need for Speed ​​Online, and World of Warcraft. We have a lot of enthusiasm and vast experience in creating multiplayer games, technologies and infrastructure. Unity's mission was known to each of us and always seemed very attractive. We could not refuse the opportunity to do something truly great, like the realization of this dream in the field of multiplayer. So we left previous jobs and joined Unity to make this dream a reality. Now we are working hard on these tools, technologies and services so that anyone can realize their dream of a multiplayer game.

So what do we mean by the Multiplayer Foundation in Phase 1? Here are its main parts:
')
- high-performance UDP-based transport protocol supporting all game types

- Low-level API (Low Level API - LLAPI), providing full control through a socket-like interface

- high-level API (High Level API - HLAPI), providing a simple and secure client / server model

- Matchmaker Service, which provides basic functionality for creating rooms and helping players find each other

- Relay Server, solving communication problems between players trying to connect through firewalls

Considering some of the historical limitations and the ambitious goal, it became obvious to us that we would have to start from scratch. Since our goal was to support all types of games and any number of connections, we started with a new, high-performance transport layer based on UDP. We know that TCP is enough for many games, but fast games still require UDP, as TCP delays the last packets if they do not arrive in order.

Based on this new transport layer, we built two APIs. High-level High Level API (HLAPI) provides a simple and secure client-server model. If you are not a network engineer and just want to make a multiplayer game, you will be interested in HLAPI.

We also took into account reviews about the old system: some users wanted low-level access for more control. Now it has a low-level Low Level API (LLAPI), which provides a more socket-like interface for the transport layer. If you are a network engineer and want to build your own network model or just fine tune network performance, then you will be interested in LLAPI.

Matchmaker Player Recruitment is used to set up rooms in your multiplayer games and help players find each other. Relay Server ensures that your players can always connect with each other.

We learned from our own experience that creating multiplayer games brings a lot of pain. The Multiplayer Foundation is a new set of easy-to-use, professional networking technologies, tools and infrastructure for seamlessly creating multiplayer games. As it seems to me, it is quite possible to say that the creation of a multiplayer game requires a good knowledge of networks and protocols. You either overcome the painfully steep learning curve yourself, or you are looking for a network engineer. After going through this, you have to solve the problem of providing players with the means to find each other. Having solved this problem, you have to deal with providing players with the ability to connect with each other, which can be very difficult if they are behind NAT firewalls. To cope with all this you have to create a decent-sized infrastructure, which is not very nice and has nothing to do with the development of games. After that, you will have to think about the dynamic scaling of your infrastructure, the proper implementation of which usually requires a certain amount of experience.

Phase 1 will save you from all these painful problems. HLAPI will eliminate the need for a deep understanding of network technologies. But if you are a network engineer and want to do everything your own way, then LLAPI will always be available to you. Matchmaker will solve your problems by providing players the opportunity to find each other. Relay Server will solve your problems by providing players with the ability to truly connect with each other. We will also solve your problems by building the necessary infrastructure and its dynamic scaling. Matchmaker and Relay Server will live in the Unity Multiplayer Cloud. So not only physical servers, but also processes will be scaled depending on demand.

High-level API UNET and SyncVar


Introduction and requirements


Some background information. A common practice for online games is to have a server that owns the objects and clients that need to be informed that the data in these objects has changed. For example, in a combat game, the player’s life should be visible to all players. This requires the presence of a member-variable in the script class, which is sent to all clients when the server changes. Here is an example of a simple class for combat:

class Combat : MonoBehaviour { public int Health; public bool Alive; public void TakeDamage(int amount) { if (amount >= Health) { Alive = false; Health = 0; } else { Health -= amount; } } } 


When a player on the server receives damage, all players need to be informed about the new meaning of life for this player.

It seems simple, but the difficulty is to make the system invisible for developers writing code, efficient in terms of CPU, memory and bandwidth, and flexible enough to support all types that developers want to use. So the specific goals for this system will be:

1. Minimize memory usage without storing shadow copies of variables.

2. Minimize the use of bandwidth by sending only those states that have really changed (incremental updates)

3. Minimize the use of the processor without constantly checking whether the state has changed

4. Minimize protocol and serialization discrepancies without forcing developers to manually write serialization functions

5. Do not require developers to directly mark variables as dirty.

6. Work with all programming languages ​​supported by Unity

7. Do not disturb the current development process.

8. Do not enter manual steps that developers would need to do to use the system.

9. Allow the system to be guided by meta-data (custom attributes (custom attributes))

10. Process both simple and complex types.

11. Do not use reflections at run time.

A very ambitious list of requirements!

Old network system


In the existing Unity network system, there is a “ReliableDeltaCompressed” type of synchronization that produces state synchronization, providing the OnSerializeNetworkView () function. This function is embedded in objects with the NetworkView component and the serialization code written by the developer writes to (or reads from) the provided byte stream. The content of this byte stream is cached by the engine and if the next time the function is called, the result does not match the cached version, the object is considered dirty and its state is sent to the clients. Here is an example of a possible serialization function:

 void OnSerializeNetworkView (Bitstream stream, NetworkMessageInfo info) { float horizontalInput = 0.0f; if (stream.isWriting) { // Sending horizontalInput = Input.GetAxis ("Horizontal"); stream.Serialize (horizontalInput); } else { // Receiving stream.Serialize (horizontalInput); // ... do something meaningful with the received variable } } 


This approach meets some of the requirements in the above list, but not all. At runtime, it works automatically, since OnSerializeNetworkView () is called by the engine with the frequency of sending data over the network and the developer does not need to mark variables as dirty. It does not add any additional steps to the build process and does not interrupt the current development process.

But its performance is not particularly high - especially when there are many network objects. CPU time is spent on comparisons, cached copies of byte streams are memory. It is also prone to mismatch errors in the serialization functions, since they need to be updated manually when new member variables are added that need to be synchronized. It is not guided by metadata, so the editor and other tools cannot find out which variables are synchronized.

Code Generation for SyncVars


In the course of working on a new state synchronization system in UNET, our team developed a solution with code generation based on custom attributes. In custom code, it looks like this:

 using UnityEngine.UNetwork; class Combat : UNetBehaviour { [SyncVar] public int Health; [SyncVar] public bool Alive; } 


This new custom attribute tells the system that the instance variables Health and Alive need to be synchronized. Now the developer does not need to write the serialization function, since the code generator has data from custom attributes, based on which it will be able to generate excellent serialization and deserialization functions with the correct order and types. The generated functions will look something like this:

 public override void UNetSerializeVars(UWriter writer) { writer.WriteInt(Health); writer.WriteBool(Alive); } 


Since this function overrides the virtual function in the base class UNetBehaviour, then when serializing a game object, the script variables will be serialized automatically. After that, they will be unpacked at the other end using the appropriate deserialization function. The inconsistencies are impossible, because when you add a new [SyncVar] variable, the code is updated automatically.

This data is now available to the editor, so the inspector window can show more information:



But with this approach, there are still a number of problems. The function always sends the entire state — it is not incremental, so when changing one member of the object, the state of the entire object is sent. And how do we know when to call the serialization function? It is not very efficient to send a state if nothing has changed.

We overcame this with properties and dirty flags. It seems natural that each [SyncVar] variable can be wrapped in a property that will put dirty marks on it as it changes. This approach has been partially successful. The presence of a bit mask with dirty marks allowed the code generator to perform incremental updates. The generated code began to look like this:

 public override void UNetSerializeVars(UWriter writer) { Writer.Write(m_DirtyFlags) if (m_DirtyFlags & 0x01) { writer.WriteInt(Health); } if (m_DirtyFlags & 0x02) { writer.WriteBool(Alive); } m_DirtyFlags = 0; } 


Now the serialization function can read the mask with dirty labels and serialize only those variables that need to be written to the stream. We get efficient bandwidth usage and the ability to find out if the object is dirty. For the user, this is still fully automatic. But how will these properties work?

Suppose we are trying to wrap [SyncVar] instance variables:

 using UnityEngine.UNetwork; class Combat : UNetBehaviour { [SyncVar] public int Health; // generated property public int HealthSync { get { return Health; } set { m_dirtyFlags |= 0x01; Health = value; } } } 


This property performs the task, but has the wrong name. The TakeDamage () function from the above example uses Health, not HealthSync, so it will ignore the property. The user will not be able to directly use the HealthSync property at all, since it does not exist before the code generation is performed. It could be done in two steps, when at the first stage the automatic code is generated, and at the second the user updates his code - but this is very fragile. This approach is prone to compilation errors that cannot be fixed without rewriting large pieces of code.

Another option would be to require developers to write the above properties for each [SyncVar] variable. This approach adds work to programmers and is potentially error prone. The bitmasks in the user and generated code must match exactly, so adding or removing [SyncVar] variables will be an extremely delicate process.

Introducing Mono Cecil


Thus, we need to generate wrapper properties and force the source code to use them even if it is unaware of their existence. Fortunately, for Mono there is a tool called Cecil that does just that. Cecil is able to load Mono assemblies in ECMA CIL format, modify them and write them back.

At this moment, things get a little crazy. The UNET code generator creates wrapper properties, then it finds all the places in the code where the source variables are used, and then replaces the references to these variables with references to the wrapper properties and voila! Now the user code calls the newly created properties without requiring any work from the user.

Since Cecil works at the CIL level, there is an additional advantage in the form of support for all languages, since they are all compiled into one format.

The generated CIL for the final serialization that is inserted into the build with the script now looks like this:

 IL_0000: ldarg.2 IL_0001: brfalse IL_000d IL_0006: ldarg.0 IL_0007: ldc.i4.m1 IL_0008: stfld uint32 [UnityEngine]UnityEngine.UNetBehaviour::m_DirtyBits IL_000d: nop IL_000e: ldarg.1 IL_000f: ldarg.0 IL_0010: ldfld uint32 [UnityEngine]UnityEngine.UNetBehaviour::m_DirtyBits IL_0015: callvirt instance void [UnityEngine]UnityEngine.UNetwork.UWriter::UWriteUInt32(uint32) IL_001a: ldarg.0 IL_001b: ldfld uint32 [UnityEngine]UnityEngine.UNetBehaviour::m_DirtyBits IL_0020: ldc.i4 1 IL_0025: and IL_0026: brfalse IL_0037 IL_002b: ldarg.1 IL_002c: ldarg.0 IL_002d: ldfld valuetype Buf/BufType Powerup::mbuf IL_0032: callvirt instance void [mscorlib]System.IO.BinaryWriter::Write(int32) IL_0037: nop IL_0038: ldarg.0 IL_0039: ldc.i4.0 IL_003a: stfld uint32 [UnityEngine]UnityEngine.UNetBehaviour::m_DirtyBits IL_003f: ret 


Fortunately, ILSpy can convert CIL to C # and vice versa, so that we can view the generated CIL as C #. ILSpy is a great tool for working with Mono / .Net builds. C # looks like this:

 public override void UNetSerializeVars(UWriter writer, bool forceAll) { if (forceAll) { this.m_DirtyBits = 4294967295u; } writer.UWriteUInt32(this.m_DirtyBits); if ((this.m_DirtyBits & 1u) != 0u) { writer.Write((int)this.mbuf); } this.m_DirtyBits = 0u; } 


Let's see how this meets our requirements:

1. No shadow copies of variables

2. Incremental updates

3. No state change checks.

4. No hand-written serialization functions.

5. No explicit dirty calls

6. Works with all programming languages ​​supported by Unity.

7. Does not affect the usual development process

8. Does not require developer manual work.

9. Based on metadata

10. Works with all types (with new serializers UWriter / UReader)

11. Does not use reflections at run time.

It seems like they are all executed. The system will be efficient and developer friendly. I want to believe that it will simplify the development of multiplayer games for Unity for all.

We also use Cecil to implement RPC calls to avoid searching functions by name using reflection. We will tell about this in further articles.

Low Level LLAPI and UNET Transport Layer


Starting to design a new network library for Unity, we wanted to understand how the ideal library should look like. We found out that we (roughly speaking) have two types of users:

1. Users who want network tools to give them a ready-to-use result with minimal effort (ideally, no effort at all).

2. Users who develop network-oriented games and want very flexible and powerful tools

Focusing on these two types, we have divided our network library into two different parts: the high-level HLAPI (high-level API) and the low-level LLAPI (low-level API).

In this part, the discussion will focus on the low-level API and library structure, which are based on the following principles:

Performance, Productivity, Productivity


LLAPI is a thin layer on top of UDP sockets, most of the work is done in a separate thread (so LLAPI can be configured to use only the main thread). It has no dynamic memory allocation and no heavy synchronization (most of the library uses synchronization based on memory access barriers (memory barrier synchronization) with a small number of atomic increment / decrement operation).

If something can be done on C #, then it should be done on it


We decided to give access only to what our users really need. Like BSD sockets, LLAPI supports only one abstraction - the exchange of raw binary messages. There are no tcp-like streams, serializers, or RPC calls, only low-level messages.

Flexibility and customizability? Yes please...


If you look at the implementation of sockets in TCP, you will see a lot of parameters (waiting times, buffer length, and so on) that you can change. We chose a similar approach and allowed users to change almost all the parameters of our library so that they could adjust them to their needs. Facing the choice between simple and flexibility, we chose flexibility.

Simplicity and pleasantness


We tried to design LLAPI as much as possible similar to the BSD socket API.

Network and transport layers


Logically, a low-level UNET library is a set of network protocols built on top of UDP and including a “network” layer and a “transport” layer. The network layer is used to connect between nodes, deliver packets and control possible leakage and congestion. The transport layer works with "messages" belonging to various communication channels.



Channels have two purposes, they can separate messages logically and provide various guarantees of delivery and quality of service (delivery or quality of service).

Channel setup is part of the setup procedure, which we will cover in future articles. At the moment, let's just assume that the setting looks like "My system will contain up to 10 connections, each connection will have 5 channels, channel 0 will have this type, channel 1 will have a different type, and so on." The last part of the sentence is defined as:



The second parameter is the channel number, the last is the type of channel or quality of service of the channel (delivery grant).

UNET (for now) supports the following QOS:

- Unreliable : an unreliable message that may be lost due to network problems or an internal buffer overflow, similar to a UDP packet. Example: short log entries.

- UnreliableFragmented: The maximum packet length is unchanged, but at times you will most likely want to send “large” messages. This type of channel before sending will disassemble your messages into fragments and collect them back before receiving. Since this quality of service is unreliable, delivery is not guaranteed. Example: a long log.

- UnreliableSequenced: The channel provides the order of delivery, but since this quality of service is unreliable, the message may be lost. Example: voice, video.

- Reliable: The channel guarantees access (or disconnection), but does not guarantee order. Example: transfer damage.

- ReliableFragmented: the same as UnreliableFragmented, but in addition to it guarantees delivery. Example: group damage.

- ReliableSequenced: the same as UnreliableSequenced, but additionally guarantees delivery. This QOS is similar to the TCP stream. Example: file transfer and patches.

- StateUpdate: unreliable channel type, forcibly dropping packets older than received / sent. If the transfer buffer contains more than one message, only the most recent one will be sent. If the recipient's buffer when reading contains more than one message, only the most recent one will be delivered. Example: location transfer.

- AllCostDelivery: very similar to Reliable, but there are differences. A reliable channel will re-send messages based on round trip time value (RTT), which is determined dynamically, while AllCostDelivery will automatically forward messages after a certain period of time (set in the settings). This can be useful for small but important messages: “I hit player A’s head” or “A mini-game is starting.” Example: game events like the departure of bullets.

Let's look at a typical LLAPI function call:

1. Initialization of the library



2. Network configuration: topology, number of channels, their types, different timeouts and buffer sizes (this will be discussed in other articles).

3. Creating a socket



This function will open a socket on port 5000 on all network interfaces and return an integer value as the socket description.

4. Connecting to another node



This function will send a connection request to another host at 127.0.0.1/6000. It will return an integer value as a description of the connection to this node. You will receive a connection event when the connection is established or a disconnection event if the connection cannot be established.

5. We send the message



The last function will send the binary data contained in the buffer via the socket described in hostId for describing the node connectionId using channel 1 (in our case this is a “reliable channel”, so that the delivery of the message will be guaranteed)

6. Receive network events

To receive network events, we select the survey model. The user must poll the UTransport.Receive () function to receive notifications about network events. Note that this model is very similar to the usual select () call with zero timeout. This function receives 4 different events.

UNETEventType.kConnectEvent - someone connects to you or has successfully established a connection requested by UTransport.Connect ()

UNETEventType.kDisconnectEvent - someone disconnects from you or the connection requested using UTransport.Connect () cannot be established for any reason that is reported by an error code.

UNETEventType.kDatatEvent - New data received

UNETEventType.kNothing - nothing interesting happened



7. Send disconnect request

This function call will send a disconnect request to the connectionId to the host with the hostId. The connection will be immediately closed and may be reused in the future.



Notes


1. The article is compiled from three entries in the English Unity blog:

Announcing UNET - New Unity Multiplayer Technology
UNET SyncVar
All about the Unity networking transport layer

2. Examples of source code in the form of pictures were in the original article in English.

Source: https://habr.com/ru/post/228395/


All Articles