# Unreal Iris(五)BaseLine && DeltaCompression

增量更新是 Iris Replication 的新功能,其最大的优势在于可以有效减少对象的复制内容,提高整个 Replication System 的复制效率。

# 相关配置

只有配置了的 ClassName 才会开启增量同步。

[/Script/IrisCore.ObjectReplicationBridgeConfig]
; DeltaCompressionConfigs
+DeltaCompressionConfigs=(ClassName=/Script/Engine.Pawn))
+DeltaCompressionConfigs=(ClassName=/Script/Engine.PlayerState))

# FDataStreamChannelRecord

FDataStreamChannelRecordUDataStreamChannel 中的一个环形队列,用来记录每个 FReplicationWriter::WriteObjectInBatch 时的生成的记录信息。

image-20240310165423772

首先得知道一个概念:每个连接的每次同步都是以 NetObject 为单位进行。但实际上,一个同步中很可能包含了不只一个 NetObject,通常需要同该 NetObject 的 SubObject、Dependent、Attachment 一同进行处理,使其成为一个完整的数据集。我们姑且把这个完整的数据集记录称之为 FDataStreamRecord,并会为数据集分配一个 PacketId,网络层面的 Ack 操作也会以 Packet 为单位。

# FBatchInfo

完整数据集 FBatchInfo,记录了一次对象打包所包含的关联对象信息和同步的状态内容:

image-20240310145307572

  • ParentInternalIndex:当前打包对象的 ObjectInternalIndex 编号,子对象存储的是父对象编号。
  • ObjectInfos:参与本次打包的所有对象信息:
    • InternalIndex:对象编号。
    • NewBaselineIndex:BaseLine 编号(0 or 1)。
    • AttachmentType:Attachment 类型,见 ENetObjectAttachmentType
    • AttachmentRecord:这个比较复杂后面再介绍。

# FReliableNetBlobQueue

FReliableNetBlobQueue 是一个简单的可靠环形队列,里面存储了 256 个 Unack 的数据包。分别用 Sent 和 Acked 两个队列标记哪些发送了,哪些 Acked 了,如果 Sent 且 Acked 了那么环形队列就可以把头指针后移。

image-20240310153402072

# FNetObjectAttachmentSendQueue

FNetObjectAttachmentSendQueue 包含一个可靠队列,一个不可靠队列

image-20240310153548242

# AttachmentRecord

union
{
    struct
    {
        uint32 UnreliableRecord;
        FReliableNetBlobQueue::ReplicationRecord ReliableRecord;
    };
    ReplicationRecord CombinedRecord;
};

这是一个 64 位结构,包含一个 32 位的 UnreliableRecord 和一个 32 位的 ReliableRecord

image-20240319111525356

其中 WriteSeq 标识 NetBlobs (256 个的数据包数组) 的下标,WriteCount 标识从 Seq 开始往后成功写入了多少个 Blob。由于数据包必须是保序的,如果中间断开了就会用新的字段记录。

# FBatchRecord

FBatchInfo 是完整数据集的临时载体,需要通过 FReplicationWriter::CreateObjectRecordFBatchInfo 转换为 FBatchRecord,然后做长期存储:

image-20240310155517215

# FRecordInfo

  • Index:ObjectInternalIndex,看起来 20 位就够用了。
  • ChangeMaskOrPtr:这个比较关键,是单独分配的内存,用于存储当前帧的该对象脏数据(对后面生成 BaseLine 有大用)。
  • NextIndex:下一帧的发送记录。

# ReplicationRecord

当对象的数据被写入成功后(实际上就已经算是发送成功了)。需要按照 NetObject 为单位把记录存储下来,方便之后的 Acked。所有发送记录都会被存储在 FReplicationRecord 内:

image-20240310161257684

  • ReplicatedObjectsRecordInfoLists:按对象为单位记录所有已发送记录的起始编号和结束编号。
  • FReplicationRecord::RecordInfos:记录列表,一个环形队列,里面每个记录都是一个对象的一次发包信息,多个发包信息之间又是按照时序串在一起的。
  • Record:环形队列,记录每一帧的发包数量,也就是每次的 RecordInfos num。
  • AttachmentRecords:环形队列,没有顺序的样子,就是单纯记录每个发送过的 Attachment 编号。

我们可以通过每个 Connection 的 ReplicationRecord 知道当前有哪些对象的状态被同步过且没有 Ack,以及这些 NetObject 的每次增量同步信息和 DirtyMask。但是还是差了些东西,仔细想想 RecordInfo 里面只有 ChangeMask,并没有实际的同步数据呀,因此当某个对象积攒了很多次的信息都丢失了,需要重新同步的时候中间的变化很可能会丢失。因此就需要一个结构来存储这份增量 or 快照 ——FReplicationStateStorage

# Acked

网络层面的 Acked 是按照 PacketId 为单位的,因此数据集的 Acked 要么全收到要么一个没收到。而 Acked 之后就需要清理对应的 RecordInfo:

当收到客户端的 Acked 时会执行 UDataStreamChannel::ReceivedAckFReplicationRecord::Record 中弹出最近一次的发送记录(Record 数量)然后去遍历 RecordInfos。这里上层的 UDataStreamChannel::WriteRecords 会检查 Acked 顺序,保证最早发出去的最先被 Acked。

# FDeltaCompressionBaselineManager

FDeltaCompressionBaselineManager 是 BaseLine 的管理器,通过 ObjectInternalIndex 访问对应的 FPerObjectInfo

image-20240310175622098

# FDeltaCompressionBaselineManager::FPerObjectInfo

  • BaselinesForConnections 中包含两个 BaseLine
    • LastAckedBaseLine 。如果收到客户的的 Acked,就会销毁,然后用 PendingBaseLine 替换 LastAckedBaseLine
    • PendingBaseLine。每次发包的时候都会重新基于当前对象状态进行创建。发包数据 = PendingBaseLine - LastAckedBaseLine。
  • ChangeMasksForConnections 有三个部分内容:
    • ChangeMaskConnection:当前帧计算出的 ChangeMask 会存储在这里,每一帧都会更新。
    • LastAckedBaseLine or PendingBaseLine 的 ChangeMask。这两个结构是连续的,数据来源字节拷贝的 ChangeMaskConnection。

# BaseLine

BaseLine 是一个概念,表示当前对象的同步是基于那个基准做的。例如当对象第一次同步时,实际上时基于当前时刻,对象的全量数据为基础进行。那么 BaseLine 就时当前时刻的对象状态,当来到下一帧时,对象属性发生变化,此时就存在两种状态:

  • 基于 BaseLine 同步的状态 A
  • 当前实际上的状态 B

如果始终没有收到客户端对于状态 A 的 Ack 信息,那么之后的每次同步都必须基于状态 B 做全量,一旦收到了状态 A 的 Ack,那么就可以只做状态 B 和 状态 A 的差量同步 DeltaAB。

# FDeltaCompressionBaseline

FDeltaCompressionBaseline 是临时的增量 BaseLine,虽然自身是临时的,但其中包含两个重要信息会被存储:

  • FInternalBaseline:这个结构可以通过 ObjectInfoStorage [ObjectIndexToObjectInfoIndex [ObjectInternalIndex]].BaselinesForConnections[ConnectionIndex] 访问,稍微有点绕。里面存储了该 BaseLine 的 ChangeMask(好像没啥用)和 BaselineStateInfoIndex(这个很有用,可以用来查询 FInternalBaselineStateInfo
  • FInternalBaselineStateInfo:通过 BaselineStateInfos [BaselineStateInfoIndex] 访问,里面存储了该 BaseLine 的 StateBuffer

image-20240310182110406

# 总结

通过上述几个类的介绍,我们大致可以想像出整个 DeltaCompression 的运转流程:

  • 对象开启复制:当一个对象需要复制的时候,由于还没有任何一条 BaseLine,因此会创建新的 PendingBaseLine,该 BaseLine 会使用其最新的 ReplicationState 作为基准生成全量数据 All_StateBuffer && Cur_ChangeMask。

  • PendingBaseLine 中包括本次同步的 ChangeMask 和 StateBuffer,即:数据脏标记

  • 当数据成功发送以后,会在 FReplicationRecord 记录下 Record 信息,其中包括了对象使用的哪条 BaseLine 生成的该 Record,方便 Acked 的时候变更 BaseLine。

  • 当收到客户端 Packet 的 Ack,就开始「消费」 Record,并且销毁掉 LastAckedBaseLine,然后把 PendingBaseLine 作为新的 LastAckedBaseLine 使用。

  • 当触发对象再次同步时:

    • 如果存在 LastAckedBaseLine,会使用最新的 ReplicationState 和 LastAckedBaseLine 做差量得到增量数据 Delta_StateBuffer。并通过 ReplicationState 的 ChangeMask 和 Record 的 ChangeMask 做 Combine 得到最新的 Delta_Combine_ChangeMask。注意,这里合并后的 ChangeMask 会体现在本次发包后的 Record 中,但是不会体现在 BaseLine 的 ChangeMask 上,实际看下来 BaseLine 的 ChangeMask 并没有任何作用可言。
    • 如果不存在 LastAckedBaseLine,则还是需要全量同步。而且 ChangeMask 需要合并所有没有 Acked 的 Record 以及当前帧的 ChangeMask。
  • 每个对象的两条 BaseLine,实际上是在不停的交替使用,而 FReplicationRecord 则是用来帮助恢复前几帧丢失的 ChangeMask。