GhostFields in GameObject Netcode - DevLog Entry 3

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!

8 Likes

Fantastic update post as always - loved reading through it! Thank you!

This got me confused a bit, from my understanding the GhostBehaviour creates a single IComponentData accompanying it. Assuming a wheel is a struct here, wouldn’t this just be equivalent of a single IComponentData with 4 struct fields in it? When is the distinction between multiple components and single component happening?
As for preferences on this, if I follow the notion that ‘you can only have 1x of a type field’ per GhostBehaviour, that does sound a bit too limiting. There are often cases where I’d have multiple instances of a certain struct in a behavior with related methods to handle things.
e.g. the “Pose” struct already in Unity, or a “GameStat” struct that has a minimum / maximum / base value / modifier / multiplier / final, and related adjustment methods. (E.g. GameStat Life, Mana, MoveSpeed, AttackDamage)
If you mean that the problem is only with references of instances of other GhostBehaviours, that is still a bit limiting but less - We’d be able to temporarily solve this by assigning children gameobjects per wheel instance, right?

The method in itself is not important to me, but there is one usecase where it matters - If the value got reconciled and the final presentation needs to look different. For example in a case where drawing an object’s trail, but needing to clear and redraw the path it took. It is admittedly on the more niche side of things.
In reality, just checking for all this in PredictedUpdate or LateUpdate for presentation is all well and good for the most part.

P.S. I wish we didn’t have to define so many BurstCompile tags. In the example included you’ve shown it used on both the ISystem and the OnUpdate method, is this necessary?

Thanks for reading through it!
Let me clarify. The following is possible

public struct Wheel
{
    public int size;
    public float speed;
}
public partial class Car : GhostBehaviour
{
    GhostField<Wheel> Wheel1;
    GhostField<Wheel> Wheel2;
    GhostField<Wheel> Wheel3;
    GhostField<Wheel> Wheel4;
}

You could even have a networked list of Wheel struct for cases where the amount of wheels isn’t known in advance.

What I meant above is the following is not possible

public partial class Wheel : GhostBehaviour
{
    GhostField<int> size;
    GhostField<float> speed;
}
public class Car : MonoBehaviour
{
    void Awake()
    {
        AddComponent<Wheel>();
        // The following Wheel instances reuse the same generated IComponentData type in the background, as this is generated at compile time
        AddComponent<Wheel>();
        AddComponent<Wheel>();
        AddComponent<Wheel>();
    }
}

from my understanding the GhostBehaviour creates a single IComponentData accompanying it.

Not quite. It’ll generate the amount of IComponentData necessary per GhostBehaviour. Our current implementation has one per GhostField actually, but we plan to add some merging logic later, to make this more performant. This is all in the background though. You’ll be able to have the following for example all in the same GhostBehaviour.

 public partial class Example : GhostBehaviour
{
    GhostField<int> someInt; // generated
    GhostField<MyStruct> someStruct; // generated
    
    GhostComponentRef<SomeComponent> someComp; // not generated
}

public struct SomeComponent : IComponentData
{...}

We’ll try to guide you on subtleties like the above wheel car for example (by adding asserts checking if you have the same monobehaviour type on the same GameObject more than once for example).

We’d be able to temporarily solve this by assigning children gameobjects per wheel instance, right?

That’d be an option. Not much to share on this for now.

The method in itself is not important to me, but there is one usecase where it matters - If the value got reconciled and the final presentation needs to look different. For example in a case where drawing an object’s trail, but needing to clear and redraw the path it took. It is admittedly on the more niche side of things.
In reality, just checking for all this in PredictedUpdate or LateUpdate for presentation is all well and good for the most part.

Right you mean like getting some form of OnCorrected callback? I’ll take a note to talk about this in our next dev log.

P.S. I wish we didn’t have to define so many BurstCompile tags. In the example included you’ve shown it used on both the ISystem and the OnUpdate method, is this necessary?

Not at all! The example is really just to show “this is the way to do bursted things”. But you can write ECS systems that don’t have that attribute. Side note, Entities also has managed systems too with SystemBase! public partial class MySystem : SystemBase

3 Likes

If you rename GhostField<> to NetworkVariable<> it will much easier to understand for everyone and a much easier transition for those converting from NGO.

Who called it “ghost” in the first place? .. and why?

Call it “synced” or “networked” or something and not “ghost”.

“SyncedField<>” or “SyncField<>” and
“SyncedRef<>” or “SyncRef<>”
would make a lot more sense.

Edit: same with the Behaviour. Why GhostBehaviour and not just NetworkBevhaviour or SyncBehaviour, NetBehaviour, LinkedBehaviour, ConnectedBehaviour? Literally anything but “Ghost”.

Yes, especially when upgrading from NGO to this.

Is this something we have to interact with?

1 Like

If you rename GhostField<> to NetworkVariable<> it will much easier to understand for everyone and a much easier transition for those converting from NGO.

That was its name in its first implementation actually :smiley: We changed it on purpose for a few reasons.

  1. It’s a completely different underlying implementation. Keeping the same name would imply same behaviour, which is not the case. NetworkVariables use a reliable pipeline, GhostFields are eventually consistent and will lose state transitions on packet drop for example.
  2. Netcode for Entities already has the concept of GhostSomething. Having the new layer use a different name wouldn’t be self consistent. Especially if doing hybrid ECS + GO code. You’d write NetworkVariables in GO code, but then switch to GhostField in ECS code? Part of our doc would say GhostField and other would say NetworkVariable?
  3. Refactoring that name would be a non trivial undertaking. It’s everywhere in our code base, docs, tools, etc.

Now that said, I’m personally not a fan of the word “ghost” either. I understand where this comes from, the word “ghost” for replicated objects has been floating around for a few decades in the industry (some google search shows Starsiege: Tribes had that concept in the 90s). But it’s not applied correctly imo. Normally a ghost is the client side representation of a synced object (the authority is server side and it has a ghost representation of it client side, which is not the “real” thing, hence a “ghost”), but in N4E we use it to mean both client and server side “replicated object”.
For me, if we are to change that name, we should change it everywhere and be self consistent (instead of renaming it only for GameObject fields).

You might very well see a dev log from us later happily saying we’re removing “ghost” everywhere. But we have other fires to fight right now.

Do let us know if per GhostField OnValueChanged is a use case that’s important to you.

Yes, especially when upgrading from NGO to this.

If we offered a single per GhostBehaviour callback, would that be too much of a hassle to use vs what you’re used to in NGO? Currently looking for arguments on why have per field OnValueChanged, so anything that pops to mind is appreciated :smiley:

Is this something we have to interact with?

We’ll provide default values, so no you don’t have to interact with it. You can declare a GhostField with no configuration and it’ll work. You can actually also not initialize it at all and it’ll still work, since it’s a value type.

public GhostField<int> myInt; // Just works as is, no need for initialization with new GhostField<int>()
1 Like

I think no one will notice that. It’s just the same thing behaving slightly different to before, so instead of receiving every single value change, it will just update to the latest state. I don’t think most people will notice that difference at all .. looking at my NGO Code it would have no impact, I could switch to GON and if things have the same name, it would work exactly the same. I have no place where I explictly expect state changes to arrive, only variables where the latest state needs to be synced. Otherwise variables would be used as events and that’s what RPCs are there for.

Yea, it would need to change for N4E and GON. It’s confusing in both systems.

Sure, but that’s also true for merging render pipelines, upgrading to CoreCLR and other undertakings that make the engine better and easier to work with.

The thing is just that people will transition from NGO to GON (is this the new name? GameObject Netcode?) and the less they are confused, the less frustration you will face from those users.

If there is a per-value callback, transitioning can happen with automatic code changes (replacing NetworkVariable<> with NetworkField<> .. I mean GhostField<> ..).
And if it is a per Behaviour callback, all code revolving around NetworkVariables needs to be rewritten. Which is as much as the project size.

So seamless upgrade (except if classes are used somehwere in NetworkVariables) VS tedious manual upgrade.
If you keep the Naming exactly the same, it might be swappable even ..

I think a lot of people think of this as an Upgrade to NGO .. adding ECS Features like Prediction and Host Migration to it.
And it could live up to those expectations .. all the lego pieces are there.

1 Like

Yes please. I think that is very important as otherwise you would need to do a check each frame. And it disables event based workflows which generally speaking are very nice to have

1 Like

Otherwise variables would be used as events and that’s what RPCs are there for.

RPCs and Netvars are sent reliably in NGO, using the same pipeline. Which means you can send netvar updates and RPCs and they’ll most of time arrive on the same frame on the other side. (It’s not logic we encourage, but it’s not something we can control and tell you not do it). They’ll be sent in different packets with N4E with zero expectation on timeline.

(is this the new name? GameObject Netcode?)

Name pending :smiley:

Which is as much as the project size.

Do you mind sharing a few examples?
How many touch points do you have, what percentage of your netvar usage would you say uses those callbacks?

the less they are confused, the less frustration you will face from those users.

I see confusion happening when there’s weird silent bugs starting to happen because of silent behaviour changes. RPCs vs state updates using different pipelines for example. Or RPC and netvar updates happening in a different places in your frame.

The only code in common between N4E and NGO is our transport UTP. They are essentially two different networking stacks, developed in parallel.

  • There’s no client authority (it’s on our mind, nothing more to share for now)
  • Most internal implementation uses ECS systems, so state can sometimes be updated deferred (e.g. you don’t get your ghost ID on instantiate, you get it when the system in charge of IDs runs later in the frame).
  • You can’t inherit from a NetworkVariableBase as GhostField is a simple smart pointer with not much to override struct.
  • Various NetworkVariabe.WriteDelta, ReadDelta, ReadField, WriteField, CheckDirtyState aren’t compatible.
  • many many more (internally we have a chunky list of differences → would be an interesting dev log of its own)

It’s not just about primitives’ names, it’s about the behaviour underneath.

That said, you’re not the only one asking about NGO’s place in all of this and I agree with most of the points you said above. My list here is mostly to correct the “this is seamless” idea. It’s not to say it’s impossible. Nothing more to share on this right now unfortunately, but we can explore this more deeply in an upcoming dev log. This is of course something we’re actively discussing internally.

I think that is very important as otherwise you would need to do a check each frame.

I could argue you could do less checks overall with a per GhostBehaviour callback. In the per field callback case, the check you’re not doing in your code is a check Netcode then needs to do. It needs to happen somewhere.
In the per GhostBehaviour case, if you have 50 GhostField but only care about one, you only do one check instead of us doing 50 checks. There’s nothing more performant than “nothing” :smiley:
We could potentially try to find a way for you to signal us at compile time you’re interested in changes for a particular field, removing some of those 50 checks. To be continued, thanks for that concern.

And it disables event based workflows which generally speaking are very nice to have

yeah I agree.

I recommend the “Unified Network” in short: UNet. (sorry, please, don’t ban me! :smiley: - also sorry for the offtopic)

5 Likes

I proposed “NEBAAGOL” (for Netcode for Entities But Actually Also a GameObjects Layer). For some reason I was told in my annual review I’m “noisy and disruptive and stop it”.
“GO” for GhostObject was a no go (lol) as well.
Names are hard.

But more seriously, UNet is on our mind, we want to do better.

2 Likes

I imagine the name will settle on Unity Netcode. But it’s gonna be hell on SEO.

How would GhostField Collections work? I find myself using NetworkVariable Dictionaries quite often.

1 Like

Curious if PredictedUpdate will be a “magic method” or an override. I’d prefer the latter, not sure about other devs. Also, maybe there’s an internal reason not to use virtual methods?

I feel that virtual methods better aid intuition and api discovery. But it’s also a bit of nitpicking :upside_down_face:

Btw, I can’t wait for this update! Will it be available in the next 6.5? Or is this something that is planned for the next LTS?

Since we don’t have character customization in the game yet, the number of NetworkVariables is still a bit low.
12 for now. But it will probably raise to 20 or so with character customization.

And out of those 12 uses of NetworkVariables in the codebase 8 are using the OnValueChanged callback. The others are read in Update or during Events.

The Character Customization Variables will also use this callback.

And overall I think it’s a very convenient thing that matches the style of other things in Unity, like UI Sliders. A “some value has changed, look for yourself which one”-callback just isn’t so nice to work with and leads to a lot of boilerplate code where you have to manually check what has changed and which effect you want to apply based on this change. Or split everything to individual MonoBehaviours, each one containing one GhostFiled .. but this is just artificial clutter.

2 Likes

How would GhostField Collections work? I find myself using NetworkVariable Dictionaries quite often.

Still TBD. N4E supports FixedList for example, but nothing for maps. Like I said above, it’s in our next steps though!

Curious if PredictedUpdate will be a “magic method” or an override. I’d prefer the latter, not sure about other devs. Also, maybe there’s an internal reason not to use virtual methods?

That’s my bad, it IS an override. That’ll teach me writing code directly in markdown. Will update.

Btw, I can’t wait for this update! Will it be available in the next 6.5? Or is this something that is planned for the next LTS?

:heart: Nothing to announce there yet. You can see the code for it already in the package in 6.5 and 6.6, it’s hidden behind a define. But it’s too early for us to feel comfortable telling you to start using it. We’re still adding new APIs and modifying existing ones.

Also thanks John for the details, it helps put context on your ask.

7 Likes

Where’s the next devlog entry?

1 Like

Thanks for keeping us on our toes. Here’s the next devlog entry.