It’s been a while already since our previous entry and today we’ll take a look at state replication for GameObject flows, in the context of the new GameObject layer on top of Netcode for Entities (N4E).
Some Context: GhostField and State Replication in Netcode for Entities
N4E currently uses ECS components for state replication. You declare a component and decorate its fields with the [GhostField] attribute to mark them for replication. They are delta compressed, quantized, eventually consistent, will be rolled back automatically for you during prediction, making sure it’s tick accurate, etc, etc.
public struct MyComponent : IComponentData
{
[GhostField] public int value;
}
This is great for ECS flows, but not for GameObjects. Since our serialization code is bursted and jobified, we can’t simply make a MonoBehaviour replicated by adding GhostField attributes to it. So we need to keep the ECS component. Requiring everyone to create components for something as tentacular as state replication is a bit of a hassle and would involve boilerplate. It also doesn’t allow encapsulation with private fields like you would have in Object Oriented code.
public struct MyHealth : IComponentData
{
[GhostField] public int value; // Has to be public or internal.
}
[RequireNetcodeComponent(typeof(MyHealth))] // N4E needs to know at prefab creation time what entity components will compose the ghost
public class MyCharacter : MonoBehaviour
{
public void Update()
{
var newHealth = EntityManager.GetComponentData<MyHealth>(this.GetEntityId());
newHealth.value++;
EntityManager.SetComponentData(this.GetEntityId(), newHealth);
}
}
We came up with two primitives to help with this. GhostField<T> and GhostComponentRef<T>.
The Solution: GhostField<T>
This is the main point of access for state replication and predicted data. You declare it in your GhostBehaviour (which is a MonoBehaviour), make it partial and it’ll take care of the rest.
public partial class MyCharacter : GhostBehaviour
{
private GhostField<int> m_Health; // source generated component.
public GhostField<float> m_Shield; // still source generated
void Awake()
{
this.m_Shield.Value = 321f;
}
public override void PredictedUpdate()
{
this.m_Health.Value = 123; // This is rolled back automatically
}
}
In the background, this source generates a related component and registers it during prefab registration for you. You can see it as a sort of smart pointer to entities component data.
For now we only support the types N4E supports (no managed types for example). Custom serialization improvements are on our list of things to look at, we’ll post on this when we have more to share.
A caveat of this approach is it does prevent the “4 wheels on a car” case. Having 4 instances of a “Wheel” GhostBehaviour on the same GameObject would mean those 4 instances reuse the same ECS component instance in the background, as Entities use types for accessing components. Since we have a single entity per GameObject, the same ECS component would be reused between these GhostBehaviours. If this is a use case important to you, do let us know!
Partial Class
Source generation requires your GhostBehaviour to be declared as partial.
GhostComponentRef
Entities are still usable in the background, for those that want that optimization path. Since the above component for GhostFields is source generated, the type isn’t visible in IDEs and can’t be used to access it in ECS systems. To facilitate the interactions between GhostBehaviour and ECS systems, we added a GhostComponentRef primitive to allow declaring your components yourself, in order to reference them in system code.
public struct MyHealth : IComponentData
{
[GhostField] public int value;
}
public partial class MyCharacter : GhostBehaviour
{
GhostComponentRef<MyHealth> m_HealthComponent;
public void Update()
{
var health = m_HealthComponent.Value;
health.value++;
m_HealthComponent.Value = health;
}
}
And have an additional system that can do a batch operation and reset all the healths of your thousands of ghosts in one pass and burst compiled.
[UpdateInGroup(typeof(PredictedSimulationSystemGroup))]
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
[BurstCompile]
public partial struct MyResetSystem : ISystem
{
public void OnCreate(ref SystemState state) { }
[BurstCompile]
public void OnUpdate(ref SystemState state)
{
foreach (var myHealth in SystemAPI.Query<RefRW<MyHealth>>().WithAll<Simulate>())
{
myHealth.ValueRW = 0;
}
}
}
This hybrid flow is completely optional, but allows a nice path to get that extra performance without refactoring your entire code base.
Note that this GhostComponentRef primitive might get replaced in the future with engine side primitives. We’ll come back to this when we have more to share.
GhostField Details
The same way you can set quantization and other such settings in N4E, you’ll be able to do the same with GhostBehaviour side GhostFields.
public partial class MyCharacter : GhostBehaviour
{
GhostField<float> m_Health = new(fieldConfig: new FieldConfig(){Quantization = 0}); // No quantization, send all precision
[SerializeField] // GhostFields can be displayed in the inspector
GhostField<float> someValue;
GhostField<int> m_Mana = new(10); // Can have default values
GhostField<MyStruct> m_SomeState; // Supports most value types (N4E generates the appropriate serializers for your custom structs)
}
N4E generates serialization logic recursively for your value types. This means a MyStruct struct with deep nested values will still benefit from delta compression, quantization, etc. See the ghost field page for more information.
Performance
This should also be more performant than a typical ECS GetComponentData() if it was called from MonoBehaviour. With GhostField, we’re caching pointers internally to the internal chunk data for its component. We then do a few light checks to detect structural changes and refresh the pointer. This means most of the time accessing this entity data is a pointer dereference plus a few version checks.
We’ll need more performance tests for this of course. We can potentially avoid having to do those pointer dereferences by simply caching the value itself and applying it all in one go to the entities data at specific sync points in the frame, at the cost of some flexibility. If you want to write ECS systems to get more scale out of this, you would need to take this into account. We’ll post more on this later.
Multithreading
GhostField<T> will automatically complete any job that would write to the underlying component. This also means GhostField<T> won’t be usable in jobs. To write to your fields in jobs, you’ll need to use Entities and write jobs the usual DOTS way. You’ll have to use ECS components with GhostField attributes (and using GhostComponentRef to access them from GhostBehaviours), at least for our first version of this.
Next steps
OnValueChanged:
N4E doesn’t have OnValueChanged callbacks and so we’ll need to look into implementing events like these. We were thinking of doing a per GhostBehaviour OnValueChanged event at first. This way you can control and observe exactly the order for each individual fields and you don’t have to worry about which field has its callback before which.
void Start()
{
this.OnValueChanged += MyValuesChanged;
}
void MyValuesChanged() // Respects monobehaviour sort order
{
if (myHealth.PreviousValue != myHealth.Value)
{
Debug.Log($"Value was {myHealth.PreviousValue} and now is {myHealth.Value}");
if (myShield.PreviousValue != myShield.Value)
{
Debug.Log($"Shield changed too, but only if health did!");
}
}
}
Do let us know if per GhostField OnValueChanged is a use case that’s important to you.
Collections:
N4E has some list support, we’ll need to look at how to expose this and what we can do to improve this. We played around with the idea of having a custom NetcodeList collection to really make sure we control state dirtying for example. More to come on this later.
Variants:
N4E uses “variants” (see doc here) to change the replication schema of existing types. You can think of them as schemas declared in C#. For example, our default quantization for transform syncing is 1000 (millimeter precision). To increase/reduce it, you’d declare a new type, mark it as a variant for LocalTransform and tweak its replication settings there.
[GhostComponentVariation(typeof(Transforms.LocalTransform), "My Transform Variant")]
public struct MyTransformVariant
{
[GhostField(SendData = false)] // Don't send positions
public float3 Position;
[GhostField(Quantization=1000)]
public float Scale;
[GhostField(Quantization=10)] // Only keep decimeter precision for rotation
public quaternion Rotation;
}
With entities, variants can be set on a per prefab basis in the inspector using a GhostAuthoringInspectionComponent and we’ll be looking at making this available outside of subscenes for GhostObjects too. These variants can also be set globally as the default variant for a type already (see here).
Next Dev Log
Next entries, we’ll talk about GhostBehaviours with their new PredictedUpdate() method and input management.
We want your thoughts! One of the main reasons we’re doing this series of posts is to chat about all of this with you guys. There were great questions in the previous dev logs so keep them coming, it’s nice to chat about this publicly!