Protocol
Documentation Objective
This document describes the protocols used to communicate with Aether Engine.
Overview
Aether's engine simulation data is sent to the client via the muxer. In order for the data to be correctly processed by the muxer and the client, all the components need to agree in the data format.
Protocol
The protocol defines the format of the data that is sent over the wire from the simulation to the muxer and from the muxer to the client
The data format can be defined in different ways, here we will explore two used in the Aether SDK.
C structs
The simplest and quickest way to define a protocol is as set of trivially copyable structs (e.g it is valid to copy them using memcpy
). This is the approach taken in the generic-netcode-protocol.
We can place the entity data we want to be sent inside a struct and send the in-memory representation directly. We use the HADEAN_PACK
macro to eliminate padding to ensure that the data layout is consistent between different compilers.
HADEAN_PACK(struct Entity {vec3f position;quat rotation;unsigned int color;unsigned long id;float size;});
Some of the drawbacks of this approach are:
- does not account for endianness differences between platforms
- does not perform any sort of optimization of the data sent
- it may be cumbersome to read/write if the client is not written in C/C++
Cross platfrom protocol using FlatBuffers
To solve the issues related to endianness, platform and languages definitions a serialization library with implementations on the different platforms can be used.
The SDK provides an example of using FlatBuffers to define the protocol.
struct Entity {position: Vec3;rotation: Quat;color: uint;id: ulong;size: float;}
The protocol definition is compiled using the FlatBuffers compiler to generate code for the different platforms where it will be used. See using the compiler for more info.
Integrating flatbuffers protocol with generic-netcode
The SDK provides an example of using FlatBuffers to define the protocol and using it with the provided generic netcode.
The generic netcode provides an marshalling interface to use for the protocol. Generic netcode also comes with a trivial marshaller class that can be used with a protocol defined as a set of trivially copyable structs as detailed in the generic-netcode-protocol. To use a different protocol with the generic protocol a new marshaller interface has to be defined. The marshaller will encode the information in and out of the protocol format.
In the SDK example the protocol is defined in PhysicsDemo/Simulation/Protocol/protocol.fbs
. For example the information for a simulation entity is:
struct Entity {position: Vec3;rotation: Quat;color: uint;id: ulong;size: float;}
In the SDK example the new marshalling interface for the simulation and the muxer is defined in PhysicsDemo/Simulation/Protocol/cpp/marshalling.h
using the C++ structs generated by the FlatBuffers compiler from the definitions.
#include 'protocol_generated.h' // header generated by FlatBuffers compiler defines Entity and Worker classstruct physx_marshaller_traits {using entity_type = Entity;using static_data_type = aether::monostate;using per_worker_data_type = Worker;using config_type = physx_marshalling_config;};class physx_marshaller : public aether::netcode::marshaller<physx_marshaller_traits> {...void add_entity(const Entity &entity) override;...};class physx_demarshaller : public aether::netcode::demarshaller<physx_marshaller_traits> {...std::vector<Entity> get_entities() const override {...
Integrating a FlatBuffers-based protocol with the generic netcode on the client side
If the client is written in C++, as in the case of the OpenGL client in the SDK's physics demo, the same marshalling code can be reused on the client side. However, if the client is written in another language (as is the case for the Unity client) a new demarshaller is implemented using the C# definitions generated by the FlatBuffers compiler.
using FlatBufferspublic class Demarshaller{...public static void DecodePacket(byte[] data, int size);public static List<global::Entity> getEntities();...}
A high level overview of integrating Aether, Unity and FlatBuffers is described in the Unity integration overview. An example implementation of a C# demarshaller can be found in the UnityClient project in the Physics Demo installed with the SDK.
Integrating protocols with custom netcode
The generic netcode also imposes the restriction of using the same protocol from the simulation to the muxer as from the muxer to the client.
If the desired protocol cannot be written in a way that conforms to the marshalling interface then the generic netcode cannot be used for net relevancy. However, it is still possible to fork the generic netcode (as is done for Aether Hunt 2) or write a net relevancy implementation from scratch using the netcode interface provided by the muxer.
Protocol versioning
To prevent a mismatch in the protocol used to construct the messages between the simulation and the muxer or between the muxer and the client a version check is added in the marshalling.
Each protocol message contains a header with a protocol-specific magic number, a protocol version and a compression flag. This enables identification of the protocol and the protocol version used to encode the message and determine if the rest of the message is compressed or not. In the provided examples, the protocol versioning is done outside FlatBuffers so that the protocol versioning continues to work even when a different protocol specification mechanism is used.
int write_protocol_version(Writer &writer) const {using namespace aether::io::byte_order;const le_uint64_buf magic = PHYSX_MARSHALLER_MAGIC;const le_uint16_buf version = PHYSX_MARSHALLER_VERSION;auto ret = aether::write_all(writer, &magic, sizeof(magic));if (ret != 0) { return ret; }ret = aether::write_all(writer, &version, sizeof(version));return ret;}std::vector<char> encode() const override {std::vector<char> data;aether::io::in_memory_writer<char> writer(data);// Write protocol headerint ret = write_protocol_version(writer);const uint8_t compression_on = config.use_zstd;// Write flag to indicate if compression is enabledret = aether::write_all(writer, &compression_on, sizeof(compression_on));{if (compression_on) {aether::io::zstd_writer<decltype(writer)> compressed_writer(writer);ret = write_content(compressed_writer);} else {ret = write_content(writer);}assert(ret == 0);}return data;}
In the demarshaller the header is read for each packet and the marshall magic number and protocol is checked:
int read_protocol_header(Reader &reader) {using namespace aether::io::byte_order;le_uint64_buf magic;auto ret = aether::read_exact(reader, &magic, sizeof(magic));if (ret != 0) { return ret; }le_uint16_buf version;ret = aether::read_exact(reader, &version, sizeof(version));if (ret != 0) { return ret; }if (magic != PHYSX_MARSHALLER_MAGIC) { return -1; }if (version != PHYSX_MARSHALLER_VERSION) { return -1; }return 0;}