Search Unity

  1. Megacity Metro Demo now available. Download now.
    Dismiss Notice
  2. Unity support for visionOS is now available. Learn more in our blog post.
    Dismiss Notice

Unity.Networking.Transport Questions

Discussion in 'Entity Component System' started by TheGabelle, Sep 29, 2020.

  1. TheGabelle

    TheGabelle

    Joined:
    Aug 23, 2013
    Posts:
    242
    I've spent some time reading through the manual and from what I can tell it doesn't seem too difficult on the surface, though I haven't dabbled in multiplayer much. I want to play around with this package before DOTS net code to get comfortable with the underlying fundamentals.

    For the hobby project I have in mind (large open world, fps-like), there's a few types of message I need to send.
    • High frequency, Unreliable, Bulky (many moving transforms)
    • Low frequency, Reliable, Small (RPCs like 'Door Opened')
    • Low frequency, Reliable, Bulky ( Files, long NativeArrays, etc)
    Questions
    1. Do I create a separate pipeline for each of the points above? I know reliable pipelines have max 16 packets out at a time, so maybe that's something to worry about? Or would one Reliable pipeline be enough? What happens if there's more than 16 requests for files and tiny RPCs?
    2. Should each pipeline use a compression stage?
    3. Is there anything I need to worry about when sending a single command that's larger than a packet size?

    Bonus Implementation Question
    4. I intend on unreliably sending all relevant position, rotation updates in separate native arrays of {netId, delta} with a message type header so I only send the message type once (payload = msgType, NativeArray). Or do I send a bunch of tiny packets containing {msgID, netID, delta}? maybe just send the position and rotation values as is?​
     
    bb8_1 likes this.
  2. TheGabelle

    TheGabelle

    Joined:
    Aug 23, 2013
    Posts:
    242
    5. Can messages be prioritized? If so, how does that work?
     
  3. FakeByte

    FakeByte

    Joined:
    Dec 8, 2015
    Posts:
    147
    1. Yes, for unreliable, bulky messages you would need a fragmentation pipeline, if its many transforms you are sending, then slice it yourself so that each packet is less than the max MTU as you dont care if one packet would get lost.
    For reliable bulky messages you need a ReliableSequencedPipelineStage and one FragmentationPipelineStage, for reliable non bulky messages the fragmentation part can be left out.
    The actual buffer size of a ReliableSequencedPipelineStage is 32 messages, not 16. If your messages are too bulky and get fragmented then you might run into problems. I haven't tested this use case, but I believe you can just create a second ReliableSequencedPipelineStage and then you need to check the buffer to choose which pipeline you want to use, but don't forget the maximum amount of PipelineStages you can use is 16. So 2x fragmentation pipelines + 2x reliable pipelines already brings you up to 4.

    2. There is no compression pipeline stage yet, you would need to create your own. Or even better, compress the data before handing it over to the network layer as then you can compress the data according to the data types, while the network transport only sees a large byte array, therefor it cant compress it as good as you could.

    3. If the first pipeline stage can't handle packets larger than 1400 bytes you will get an error.

    4. It's always better to combine your packets.

    5. No, you will have to handle this yourself. If you send reliable packets and you know you can only send 32 at a time you could prioritize position packets close to the player and then always send only up to maybe 25 position packets and leave 7 packets for other messages.
     
    bb8_1 and TheGabelle like this.
  4. TheGabelle

    TheGabelle

    Joined:
    Aug 23, 2013
    Posts:
    242
    Excellent information. Thank you.

    1400 bytes being the struct size which populates a NativeArray, or the NativeArray itself?

    Good point. I have an idea for exactly this, though it might not be a good idea. I'll sleep on it and get back to you. In the mean time, would it be wise to use a compression algorithm like 7-zip? Theoretically I could fill a packet with more structs at the cost of decompression on the receiving end? Probably not wise for rapid-fire transform data, but for large transfers it may be a good idea.
     
  5. FakeByte

    FakeByte

    Joined:
    Dec 8, 2015
    Posts:
    147
    The data you can send is 1400 bytes - 4 bytes UdpCHeader - PipelineHeaders.
    As for the native array I would use the GetUnsafePtr() method to get the pointer to the data you want to send, this avoid copying your data. Sending a NativeArray itself has no advantage.

    7-zip is also a general compression algorithm. Look into algorithms for the data you have, A position vector can be quantized for example
     
    bb8_1 likes this.
  6. TheGabelle

    TheGabelle

    Joined:
    Aug 23, 2013
    Posts:
    242
    It's common to convert floats to integers for networking to quantize and avoid precision problems. As an example, 1000.1234567f with a network fidelity of 4 will be sent as 10001234 and converted back to a float as 1000.1234f

    intValue = floorToInt (floatValue * 10^4);
    floatValue = intValue / 10^4;


    Byte sizes for networking:
    {int netID, int3 translation} // 4 + 4*3 = 16 bytes
    {int netID, int3 rotation} // 4 + 4*3 = 16 bytes

    or
    {int netID, int3 translation, int3 rotation} // 4 + 4*3 + 4*3 = 28 bytes

    My game world will be divided into square chunks. All objects in the world will be within the bounds of a single chunk. I can send an object's translation relative to it's respective chunk, using the chunk transform, chunk dimensions, and object's respective x,y offset percentage --> 0f..1f range. I don't need 1 / uInt.MaxValue precision, but 1 / ushort.MaxValue precision is reasonable. I can have a global max height value for the 3rd dimension offset percentage.

    floatValue = ushortValue / ushort.MaxValue;
    ushortValue = floatValue * ushort.MaxValue;

    If my chunks are 512 x 512 in world units, then my precision for x and y offsets is:
    512 / 65535 = 0.0078126f

    A similar rule applies for world height:
    3000 / 65535 = 0.0457771f

    If the world height is too large, I may have to use an integer for the up axis, adding an additional 2 bytes per network message involving translation values.

    Additionally, 1 / ushort.MaxValue precision is reasonable for rotation as well. All rotation can be expressed as a positive values: 2PI * 0f..1f

    Byte sizes for networking:
    {int netID, int chunkIndex } // 4 + 4 = 8 bytes
    {int netID, ushort3 translation} // 4 + 2*3 = 10 bytes
    {int netID, short3 rotation} // 4 + 2*3 = 10 bytes

    or
    {int netID, int chunkIndex, ushort3 translation, ushort3 rotation} // 4 + 4 + 2*3 + 2*3 = 20 bytes

    Object transform values relative to a chunk has an additional benefit of making the implementation of floating origin easier. I would only need to worry about adapting the chunk's transform to the floating origin.

    I could also reduce bytes per object by grouping them by chunkIndex, sending the chunkIndex once per packet. Each object would be 4 bytes less.

    Do you think this is a good idea?
     
    Last edited: Sep 29, 2020
  7. FakeByte

    FakeByte

    Joined:
    Dec 8, 2015
    Posts:
    147
    I would use ID's for Chunks and then inside a chunk have 1 dirty bit to show if an object is inside the update or not. Having 1 bit per chunk object is more effecient than having 4 byte ID's.

    6 bytes for translation sounds about right, I use 50 bits usually which gives me 2mm precision.

    For rotation you can represent the rotation as quaternion in its smallest three representation which uses 3-4 bytes.
     
    TheGabelle likes this.
  8. TheGabelle

    TheGabelle

    Joined:
    Aug 23, 2013
    Posts:
    242
    Several interesting ideas to chew on, thank you.

    Could you elaborate on the 3-4 byte quaternion representation? My knowledge of quaternions isn't as strong as it should be so I'll study up now.
     
  9. snacktime

    snacktime

    Joined:
    Apr 15, 2013
    Posts:
    3,356
    Think more about what is needed for the actual use case. Like rotations for a character controller, it's generally only a single axis that is controlled by the player. So you convert that to a euler angle take the single axis the player controls send that as a varint encoded integer which will be 1-2 bytes depending on the value.

    algorithmic approaches are fine and necessary but your biggest gains will come from higher level approaches that leverage context to avoid sending data at all.

    And converting to integers is mostly done because then you can apply variable length integer encoding. Make sure you understand how that works. It's a common solution but with some caveats and it's also not always the right solution depending on the problem.

    For example varint encoding can be worse then no encoding for negative numbers. Encoding a voxel system would use an entirely different encoding approach then world positions for character movement.

    Details matter here. Saying you are partitioning ok that's good, but what specifically you are partitioning is a requirement to know what the best encoding would be.
     
  10. TheGabelle

    TheGabelle

    Joined:
    Aug 23, 2013
    Posts:
    242
    I'm attempting to create a massive open world with twitch-shooter gameplay. I know floating origin and proximity-based packet data will be necessary. To achieve this I have partitioned the world into chunks and keep transform information relative to an associated chunk. Floating origin will only need to deal with chunk translation as object translation is already relative to a chunk. I'd like to support as many concurrent players as possible by sending snapshots of nearby chunks only. Aiming for a single server which could simultaneously be a client or a dedicated headless machine. I'm also thinking about a possible multi-server topology if I want MMO-level scalability for dedicated machines, but I'm pretty far away from that and will refactor later if needed.
     
  11. TheGabelle

    TheGabelle

    Joined:
    Aug 23, 2013
    Posts:
    242
    My current plan:

    Game servers send world data to clients in 'snapshots' at a fixed interval. From what I've red MMOs tend to send updates at 10hz (every 100ms) where twitch shooters send updates at 30hz (every 33.3ms) -- 120hz (every 8.3ms). I will be targeting 60hz (every 16.6ms) but hopefully my solution can be capable of supporting higher frequencies for better machines and LAN connections.

    Some things update frequently, other things update occasionally. Dropping frequent update packets is okay because another one is coming soon. This is not the case for occasional updates. Therefore one 'snapshot' needs two parts: reliable and unreliable.

    Snapshot Unreliable Pipeline
    Snapshot Reliable Pipeline


    World 'snapshot' message data
    [snapshot id][snapshot payload]

    Snapshot payload consists of snapshot messages. Each snapshot message will container a type and a variable payload length, so they will have a type and length header like so:

    [snapshot msg type] [snapshot msg payload length] [snapshot msg payload]


    As previously mentioned I am sending most updates relative to a chunk, so within a snapshot message payload will be a chunk update. Chunk updates can be typed for adding and removing objects, object transforms, or whatever stateful object data is needed.

    Chunk update for some chunk objects:
    [chunk id] [chunk payload type] [dirty bit flag length] [dirty bits] [chunk update data],

    Chunk update for all chunk objects:
    [chunk id] [chunk payload type] [chunk update data]


    I intend to model the snapshot data as a pair of byte streams for the reliable and unreliable parts. I intend on creating a 'snapshot assembler system' and 'snapshot message assembler system' per snapshot message type. the message assemblers are in the main game world and send their snapshot messages to the netcode world. The netcode world runs the snapshot assembler and then utilizes the Transport package.


    The snapshots should be pretty useful in this form. Clients will hold on to the two most recent snapshots for interpolation, and keep X number of most recent snapshots for things like kill cams. Servers will hold onto X number of most recent snapshots for what's called "server reconciliation", which is great for validating things like hit detection. Hit detection validation RPC results will be bundled into the next snapshot in it's own snapshot message.

    [Snapshot Unreliable Pipeline Transport Headers]
    [snapshot id]
    [snapshot msg type] [snapshot msg length]
    [chunk id] [chunk payload type] [dirty bit flag length] [dirty bits] [chunk update data]
    [snapshot msg type] [snapshot msg length]
    [chunk id] [chunk payload type] [dirty bit flag length] [dirty bits] [chunk update data]

    [Snapshot Reliable Pipeline Transport Headers]

    [snapshot id]
    [snapshot msg type] [snapshot msg length]
    [chunk id] [chunk payload type] [dirty bit flag length] [dirty bits] [chunk update data]
    [snapshot msg type] [snapshot msg length]
    [chunk id] [chunk payload type] [chunk update data]
    [snapshot msg id] [snapshot msg length]
    [hit validation results]

    Should clients send player input as reliable when changed, or spam it's current state constantly using unreliable? Should I also send player transform data as well?
     
    Last edited: Oct 1, 2020
  12. FakeByte

    FakeByte

    Joined:
    Dec 8, 2015
    Posts:
    147
    Clients should send input for movement unreliable and for actions like casting a spell reliable. The server doesn't need transform data from the client, so this would be useless to send.
    There is a nice article from Glenn Fiedler I refer to when I have to compress my data, it explains the quaternion quantization pretty good.
    https://gafferongames.com/post/snapshot_compression/