Closed Beider closed 4 years ago
Here's the source for my position replication component. I think it could be done more generically rather than being position/rotation specific. I was thinking would properties on the MDReplicated attribute to tune interpolation and send rate (similar to what you suggested in #10 ) https://gist.github.com/DoubleDeez/9e032411a5578264285f6c68905da8fa
I do think this idea would fit into the framework but I think we'll need to let people decide if the Property value should be sent out whenever it changes or if it should be every N frames or M seconds and interpolated on the clients between updates. Also instead of create a MDClockedNetworkDataNode and MDClockedNetworkProperty attribute, this could be made as part of MDReplicator and MDReplicated, with the settings on MDReplicated. That would kinda roll this more into #10 where the replicated properties can be tuned via the MDReplciated attribute.
The main benefit I see from adding a time component (or in this case game ticks) that is synchonized across clients is that it allows for networked objects to execute things at the right time and also be completely in synch.
The best example I can think of here is a player that is interpolated and also has a shoot signal that is sent from server to client. I had this issue in my own game, my interpolation works fine and looks smooth, but it is of course naturally a little bit behind where the actual character is on the other client.
Then when a shoot signal is sent I send the position and the direction the bullet is fired in, this causes it to instantly fire on the other client, however since the player character is behind where it is on the server when this signal arrives it just spawns in the middle of nowhere. I could send just a fire and direction however that causes the bullet to spawn in a different place on the client and the server.
This is what the time component would solve for me, then I can wait with the shoot signal until the player is moved to the right position.
Video of this problem in my own game here at around 1:30 mark
If it would be possible to have the game ticks be an optional feature of the replicator that would probably solve this, but then the replicator will also have to store the values and maybe not set a variable until the time it is supposed to be set.
Another advantage of the custom class I suggested will be that you will be able to override it and have your own custom handling code. For instance if you want a networked game where you can reverse time you would already have that possibility once you have the game ticks.
I will keep implementing my solution as I thought out for now, then once it is done maybe it is easier to see how the Replicator could be adjusted to feature the same functionality. If it could be made easier to use than the way I thought out I am all for it.
I finally got it working, I commited it on my own branch so you can check it out if you want. Still need to clean up the code a bit but it seems to work pretty well now. The only thing I think is really needed is the ability to have the network update interval be automatically adjusted based on ping (with a min/max probably), if the ping is too low and the interval is too high then the synchronization doesn't work too well.
It also auto adjusts how far behind each client is based on the maximum ping to other clients, so if the ping goes up or down the client will stay as close behind the server as possible.
If you got the time have a look at my code and maybe you have some ideas how we could make it fit better with the framework. One cool thing I like with my current setup is that in theory you could make a global synchronization node. Then have it assign networked properties to each object that need them. I didn't do that for the example, in the example each object is sending it's own update at an interval. However it wouldn't be hard to have the position of all objects being sent at the same time.
I haven't had a chance to look into this in detail yet but I will. One thing that came to mind though is how does it feel for clients? For example, if you're replicating movement won't the player feel the input delay?
That would be up to your game though, personally I plan on having each individual client of my game be the master of their character's position. So it will only tell the other clients where they are moving and what they are doing, in other words it will feel as responsive as single player. But my game will be a coop game so I don't really care too much about cheating, at least not as much as I would in a competetive game.
That being said if you are going with an authorative server model I would assume you could have a similar system except you would simply send position corrections back from the server if the client tries to do something illegal.
I think prediction logic and such is very hard to write into the network library itself though, since how you want to do prediction depends a lot on what you are dealing with. For instance if you have an AI character you could simply have the server send some commands and rely on the commands being executed the same way on all clients.
That being said the advantage with having each network property being a class as I have made it now vs the replicator is that you could write any sort of handling code you want within the class. As it is now it is just providing a string to be sent and it gets the same string back on the other side. So you could have a very complex class that tracks multiple things and compresses it down as much as you like.
Perhaps we could make room for both solutions in the framework? So if you just want quick and functional networking without all the hassle you use the replicator. But if you want more control we also have such a system in place.
As it stands there are probably some bugs in my code and it could need some cleanup. I just focused on getting it working for now, making it good enough for the framework would come second.
I will take some time tomorrow or sunday and write a simple top down shooter example, that would probably make it easier to get a good feel for how it works.
I finished the shooting game example, source code is already on my fork.
Here is a video of it in action.
There are two things I think needs to be added, first one is simple which is simply the ability to send data for some future tick. Currently you can use SetValue to set the value at the current tick, however being able to send for the future means you can trigger an action on all clients at a specific tick.
Second is a global interval sending class, right now each MDClockedNetworkedNode sends it's own data to itself when it's interval is up. This is nice since all it's properties that are set to be sent on an interval is sent together. However for further optimization it might be nice to be able to batch multiple networked nodes together, for instance if you are just sending position for each node on an interval it would be nice to be able to batch together 20-30 nodes and just send all positions at once.
I finally had the opportunity to watch the videos and read through the code for this. I like the concept and believe it has value in being part of the framework. I'm trying to figure out how it can be best integrated in the framework.
The goal of the framework is to make it easier for the average user to add network capabilities to their game and figuring out syncing is a fairly complex concept so I'm hoping we can get it to a point where they just need to add an attribute to their properties (or something of similar complexity) and have it synchronize.
There's some parallels between your Clocked Networking implementation and what the framework currently has and I think we can merge them a bit. This is a bit of a brainstorm so the my idea is evolving a bit as I type:
MDClockedNetworkDataNode
is like a normal Godot Node
but adds tracking internally. To make it easier on users, I think removing the internal tracking would be beneficial, similar to how I have MDGameSession
and MDReplicator
tracking external nodes, I think all the tracking should be done in MDGameSynchronizer
and nodes that make use of the Game Synchronizer features will be automatically tracked. So MDClockedNetworkDataNode
would no longer exist, instead the synchronizer would hold a list of Node
s that it treats as clocked network nodes.
Taking into consideration the previous point, MDGameSynchronizer
is an augmented version of MDReplicator
as in it handles when Replication should take place. So perhaps instead of giving the task of tracking clocked nodes to the game synchronizer, we integrate that functionality into MDReplicator
and make MDReplicator aware of the GameClock and GameSynchronizer. ReplicatedNode
in MDReplicator
would be a good place to look at adding the tracking to.
With the tracking being done externally, there doesn't seem to be a reason to distinguish between Clocked vs Networked on the Node level and instead we should distinguish on the Value level. MDClockedNetworkValue
is like an augmented property with the MDReplicated
attribute. Conceptually it's quite similar to ReplicatedMember
and how it's used in MDReplicator
, I think we could merge that clocked functionality there.
The question is now, when is a value clocked and when is it not? My current thinking is that if the Game Synchronizer is enabled, all MDReplicated
values will be clocked values by default and vice-versa. We could add a setting to the attribute to let users opt-in/out per-value if we think there's value there.
This external tracking approach does lose the ability for users to override behaviour by subclassing / implementing their own values but I think we need to design for the basic user first and add the option to customize on top of that. I think the clocked networking is very valuable and should be the default behaviour as I think it will solve a lot of problems for people that just want easy to use networking. I think it will also put us in a good position for improving interpolation/prediction.
Sorry this is so long and a bit chaotic, hopefully what I'm saying makes sense. Let me know what you think.
I generally agree with what you said, so I looked through the replicator code and tried to see how we could combine the two. So here are my suggestions.
Remove MDReplicatedType.Always, every frame is just too often and floods the network too much. We replace it with Interval and have a default interval of 0.05 or 0.1 seconds.
Add replication group as part of the MDReplicated attribute. This was the suggestion from this post, I think the replication group is important as it would allow you to choose which data is sent together when sending at intervals. It could also allow for OneShot or OnChange properties that are only changed together to be sent together. This would be cross node so you could in theory group all your AI position updates from the server together in one group. Only requirement here is that they all need to be the same MDReliability and MDReplicatedType. If there are some that don't match we log an error and split the group up.
In addition if you do not provide a group the replicator will automatically try to group for you (if you do interval updates). We then have some max group size and automatically split it up if it exceeds that size. We can then also write some code so the replicator tries to evenly split the updates of these groups, so while they may use the same interval they are sent on different frames.
We need another new property in additon to MDReplicatedType, maybe MDReplicatedMode which mainly would denote stuff like if you want interpolation or not.
The MDReplicated should maybe also have some setting that let you set if the property should be replicated while the game is paused or not. Default could be that it is replicated on pause, but for most game data there is no point in keeping on replicating while paused. This also means the replicator needs to care about the GameSynchronizer if it is set to auto pause, as it will still need to update new clients before unpausing.
The replicator is currently working almost directly on members, I suggest we convert this into an interface and extend the class in a similar way to MDClockedNetworkValue then we can wrap most of the clocking functionality underneath this. This would make it easier to implement different types of replication with/ without game clock. But mainly it would allow for customization as well as long as we provide the possibility of adding / removing replicated values to the MDReplicator in the same way I provided for my node. Then people can just implement this interface in their own custom class if they want flexibility. The main problem with such an interface is that typing gets harder when you want to store them in a generic list. I am not super familiar with C# but I am sure we can work around it somehow? In my code I just used GD.Str2Var and GD.Var2Str but that was not a very optimized solution.
I agree that we should just make it simple, if the GameClock is active then all values are clocked by default. We could make an opt out, but you could also argue that if they want to opt out they can just send their own Rpc signal instead.
Ideas for stuff that I am not sure if is valuable but I quite like.
Add OnValueChange callback to MDReplicatedType, I feel this makes for cleaner code rather than having to check every frame if something has changed, particularly since the underlying system has to check anyway so you save some processing time as well. If you look at how I wrote my example I use the same code for shooting on both the master and puppets. Since they all get the callback I just use the callback.
I would like to find a way to work in the OneShot replication type that I added, with game ticks this makes sense. It was very nice for shooting to just have a callback that is called once on a specific game tick, however I am currently not sure how to best introduce this. One option would be to have the replicator detect a change and then reset the value back after it has been replicated. Another would be to add in a custom class just for this that you can set to be replicated, when the replicator detects this class it will subscribe itself to some sort of event in this class that is called when a signal is fired.
Open Questions
You seem to have a better handle on C# than me so perhaps you got ideas on how this could be done better. I will not be able to put too much work into this for the next week or two because of real life commitments. Are you able to work on this in the near future and would you like to split up this work?
I would suggest if you got time that you do the base rework of the MDReplicator (extracting the interface, maybe adding the groups) without the game ticks and interpolation, then I could add additional subtypes that implements the game clock and clock based interpolation once I have time again.
I'm in the middle of a long distance move so I won't have time for the next few weeks but afterwards I'll put some work into this. My strength is in C++ so some of that has carried over into C#.
Numbered the points so it's easier to refer to them:
MDReplicatedType.Always
sounds good.I would imagine that we try to auto balance, but also consider that there is no point in splitting it up if there are too few values. Maybe we should specify updates in physics frames instead of milliseconds since that works out more evenly with the engine.
Example: So say you update every 6 physics frames for interval. If you only got 5 values you want to send we could just send them all every 6th frame. However once we hit some preset point, for example 10 values we split them into two groups of 5. These two groups are sent every 6th frame but 3 frames apart, so every 3 frames we send something. Then once both those group hits 10 again we split into 4 groups of 5 and that are sent on separate frames. Then finally once all hit 10 again we split into 6 groups and from there on we just keep adding while trying to keep the groups balanced.
You could get away with two settings for this, how often you want updates which would in turn also decides how many groups you get max as we only send 1 group per frame (unless we are forced to send more because of differing settings). And what the "soft max size" for the group is, which would be the point groups are divided if we still have free groups.
There is a second part to this that is important as well, we need to figure out how we send group definitions between clients. Since clients will need to know what is in the group and in what order the group data is. Sending this data every time the group is sent could create massive overhead which we probably want to avoid.
I would suggest we identify each group with a sequntial int (unique per peer) and each time we modify a group we create a completely new group ID and send the definition as RPC reliable to all clients. If a client recieves an update for a group ID it doesn't know it will just hang onto it until the definition arrives. Of course this has the problem that if group definitions change often and your groups are large you end up with a lot of overhead for sending group information over the nextwork.
Another option would be to give each replicated value an identifier and always sending the identifier along with the value. So when sending a whole group we would just send pairs of identifiers and values. Less overhead when groups change but more overhead with every packet.
At the bottom of this class you can see an example of how i did it, in my case I assume all clients have the properties in the same order for the given datanode. So I just send all the values as arguments to an RPC call and assume the puppet has the same order as the master.
I agree, let's keep it simple and enable it by default and rather allow opting out of it.
.
public interface ReplicatedMember
{
object GetValue();
void SetValue(object Value, uint Tick = 0); // If GameClock is not active tick would just be 0
MDReplicatedType GetReplicationType();
MDReliability GetReliability();
void SubscribeToChangedEvent(OnValueChangedEventHandler handler);
}
You can see I did something same for my own class here.
Then we can make some generic default implementation that would work on any member type. Then add a custom interpolating one (just like i did).
Here is my default implementation that works on any value, only difference from replicator is that I just convert them to strings for sending.
And here is my interpolated Vector2 which is just an extension of the base class that does interpolation.
So the callback that I defined in the interface above is how I did it for my own class. On the network master the callback would be called every time the property is changed. On the client it was called every tick if interpolation was on since we would have a new interpolated value. Otherwise for non interpolated or OnChange values it was called when it was changed. Finally for OneShot it was called just on the frame it was supposed to trigger (which would be instant on the network master)
You can see how I used it in the example here. The OnShoot is actually the same on master and puppet, while for position updates the master does it separate from the client as master is using MoveAndSlide(). The thing that makes it nice is that I don't need to check for value changes in PhysicsProcess() on the puppets. They just get their changes through the callback. I should probably just have disabled processing for the puppets to not waste the processing time for the single bool check every frame.
Of course since with the replicator you would be replicating a member of the class you could choose how to do it depending on the use case. If you got something like position maybe you want to just do it in PhysicsProcess() while if you got something that rarely changes you do it in the callback.
Sounds like a good solution
Maybe just leave it as is. It wouldn't be hard to add separate estimation per client in the game clock in the future if we feel it is necessary.
For 3: The auto balancing algorithm sounds good. For the second part I had not thought of the issues here. Why do the group IDs need to be networked? It's just a concept the replicator will use to schedule updates right? As for setting values, we can do the inverse of what I did in the replicator for checking for changes via reflection. We will still have to send a NodePath or ID that we setup ourselves (likely with a solution similar to what you proposed for Group IDs but instead it would be Node IDs). Then using the C# reflection system we can set the values we receive by RPC. For 6: Okay yeah, this is what I thought. I'll look into solutions on how this can be accomplished best in C# when I have some time. In C++ we would have a default template with template specializations where needed. For 8: Right, but with the change of making clocked network values externally tracked, the equivalent in the new setup for position would be
[MDReplicated]
Vector2 NetworkedPosition;
then you could instead use a custom setter to do your OnPositionChanged call
protected Vector2 _position;
[MDReplicated]
protected Vector2 NetworkedPosition {
get
{
return _position;
}
set
{
_position = value;
OnPositionChanged();
}
}
Adding a callback param to MDReplicated would just be syntactic sugar at that point, which has value but isn't functionally necessary.
Number 3. What I was mainly thinking about was sending the entire group as a single RPC call to reduce overhead by not sending as many packets. That being said, I don't know if the underlying network implementation already does something like this. But overall there is no point in this optimization if it works without doing so, so just assigning a unique id to each replicated property and making sure all clients have that id would probably be good enough.
About 8, yeah I suppose it is mostly just a convenience so that you would not have to do that. I just thought it would have looked cleaner if your looked like
[MDReplicated(MDReliability.Reliable, MDReplicatedType.Interval, OnPositionChanged)]
protected Vector2 Position;
Finally let me know what you figure out for 6, I only have a few hours this week to do work on my godot game so I will probably spend some time actually working on my game as I spent all my time lately on networking.
3: Yeah I want to avoid optimizing in areas that should be optimized in the engine itself as that kind of detracts from the point of the framework of being an addition to the Godot networking.
8: yeah exactly, I'm not saying we don't do it, just that it isn't a requirement.
and yeah I'll update here with anything I find for solutions to 6
Since I had a bit of time today I decided to modify the replicator to use an interface to see how this would look.
Here is the code on my branch.
You can see I moved most of the logic inside the replicated node, this way we could make a different instance of it that uses the game clock and simply instance the correct version depending on if game clock is active or not.
The interface for the replicated member turned out way simpler than I thought,
interface IReplicatedMember
{
bool ShouldReplicate();
void Replicate(int JoinInProgressPeerId);
MDReplicatedType GetReplicatedType();
bool IsReliable();
}
If this is the approach we want to go for I would move the ReplicatedMember to it's own class MDReplicatedMember and introduce a new MDClockedReplicatedMember.
Then I would probably introduce a third replicated class that does interpolation and prediction.
I think the code could need some adjustments as well but in general I think this concept would work quite well, it also allows people to implement their own replication classes. I was thinking of making the picking of which replicated type to use a virtual method and add the standard virtual replicater type to the game instance. Then people could just override the replicator if they want to have custom replication implementations.
I'm thinking we should have MDReplicatedMember as a base then it could be extended for additional functionality (adding game clock awareness, interp, etc). Then we would also need some form of factory so that custom ReplicatedMember types could be added by framework users without changing the framework
Yeah I was thinking of having MDReplicatedMember be the base for MDClockedReplicatedMember. I guess I could remove the interface in that case and we just override it. Also considering the rest of the framework generally use virtual methods I was thinking of just adding something like this below to MDReplicator.
protected virtual IReplicatedMember CreateReplicatedMember(MemberInfo Member, MDReplicated RepAttribute, Node Instance)
{
return new ReplicatedMember(Member, RepAttribute.Reliability == MDReliability.Reliable, RepAttribute.ReplicatedType, WeakRef.WeakRef(Instance));
}
And then adding this to the GameInstance
protected virtual Type GetReplicatorType()
{
return typeof(MDReplicator);
}
I will also need to make the MDReplicator a Node as clocked values can't just use Rset directly, so I need to send the values to the replicator first then pass them to the ReplicatedMember, so I would instance the MDReplicator from the GameInstance like everything else.
I think I will have a little bit more time than I estimated over the next few days so I will see what I can get done.
Ah yeah, the clock stuff will need RPCs on the replicator so that makes sense.
So I finished the initial implementation of this now, it was fairly easy to get stuff to work. However there are probably bugs and I could really need you to look over the reflection part as I think it may not be optimal (nor possibly quite stable), but that can probably wait a bit. I also saw mentioned that reflection is pretty slow in C# and that you can improve it with something like FastMember, might be worth looking into?
Anyway here is my new replicator.
I introduced a new NetworkKeyIdMap class that is managed by the server, it contains uint keys that correspond to NodePath#MemberName strings that I use in the KeyToMemberMap. This allows it to only send a uint over the network for replication instead of the whole path. I also added a buffer in case values arrive before the key pair.
I also adjust so we now use interval, it is currently set to 300 which I used for testing but I guess a more sensible default would be 100 milliseconds.
Inside CreateReplicatedMember it detects if game clock is active and then it looks for Vector2 members and applies the interpolated vector2 implementation if they are found, if not standard clocking is used.
Finally the individual implementations are here. They are pretty similar to what I already made for my old replication.
I will probably work a bit more on this tomorrow trying to clean it up a bit and look for bugs, etc.. But this will certainly need a proper review and we probably need more tests. I think for now I found a bug that already existed in the replicator, OnChange values are not sent to new players when they join. So if it never changes after they joined they will never get the value.
My todo list currently is
Stuff that should be considered later
I think the slow part of reflection is going from strings to Members and FastMember helps that but we don't do that since we store the MemberInfos which should be reasonably fast.
I took a quick glance over the Replicator and the ReplicatedMember types and they look good, just some minor things I would call out in a review once it's up.
The OnChange bug sounds right, last time I was working on this I remember there being some join in progress issues so that's probably it.
For OneShot I was thinking we do something along the lines of extending the MDRpc* functions to check if the gameclock is enabled and if it is, it instead calls an Rpc on the GameSession node, giving it all the info to call the function locally with the tick number and stuff.
What do you mean by "Easy to use change listener instead of having to add this yourself"?
What do you mean by "Easy to use change listener instead of having to add this yourself"?
What I mentioned above about being able to do a listener directly in the declaration so you don't have to make a property and do the whole set thing. It just looks much cleaner this way
[MDReplicated(MDReliability.Reliable, MDReplicatedType.Interval, OnPositionChanged)]
protected Vector2 Position;
And yeah custom RPC implementation that respects game ticks would be an appropriate solution. I will add that to my todo list.
So I finished the second part of the Synchronizer now which is the game clock, it has a tick counter that ticks on all network clients at the same speed and it automatically keeps all clients in synch. I have added some questions at the bottom.
How it works
The game clock uses the maximum ping of any player to decide how far behind network objects should run. There is a video here, just pause at any time and you can see that the top value for the current tick is almost in synch for both clients. You can also see that the remote offset automatically adjusts as ping increases / decrease. Ensuring that remote objects are not further behind than they need to be.
Now my idea is to add two more things that use this,
MDClockedNetworkDataNode Which will be a script you can just attach to a node on your networked objects. By default it has an interval for how often it sends updates. It allows you to add / get MDClockedNetworkProperty.
MDClockedNetworkProperty A property that can be either Interpolated or a one shot thing. You can also set if it is always sent along with the other properties in your MDClockedNetworkDataNode or if it is sent only when you trigger it. Can be overwritten for custom properties
The idea here is that you would add this to your player and maybe add some properties like, Position (Vector) Rotation (Vector) Shoot (bool) (manually triggered, one shot)
Then the MDClockedNetworkDataNode will send updates for position and rotation along with game tick every time it updates. On the clients when you fetch the value it will automatically be interpolated for you based on the game ticks. Then for the Shoot property you can check it every frame and it will only return true on the game tick where it happened (or first game tick that is processed after this tick).
The idea here at least for my own game is to use this to ensure all networked entities synch up properly and that events are triggered at the correct time / location. Since we are interpolating between ticks and events happen at any given tick it should in theory happen exactly the same on all clients (albeit a bit behind for networked clients, based on ping).
I expect to have everything done later this week sometime.
Questions Is this something you think would fit in the framework? Does it conflict with your interpolation changes? If you think it fits, do you think the idea for MDClockedNetworkDataNode and MDClockedNetworkProperty is fine or would you rather implement this part differently?