UE 网络笔记2

本文由AI编写

Actor 所有权(Ownership)与相关性(Relevancy)

所有权链(Owner Chain)

Ownership 决定了 RPC 路由和 COND_OwnerOnly 属性的目标。

PlayerController (PC)
└── Pawn (PC->Possess(Pawn) → Pawn->Owner = PC)
    └── WeaponActor (Weapon->SetOwner(Pawn))
        └── ProjectileActor (Projectile->SetOwner(Weapon) 或 SetOwner(Pawn))

Owner 链查找:引擎沿 GetOwner() 链向上追溯,直到找到 PlayerController,该 PC 对应的 UNetConnection 就是此 Actor 的 "Owning Connection"。

// 查找逻辑伪代码
UNetConnection* GetOwningConnection(AActor* Actor)
{
    for (AActor* Owner = Actor; Owner; Owner = Owner->GetOwner())
    {
        if (APlayerController* PC = Cast<APlayerController>(Owner))
            return PC->GetNetConnection();
    }
    return nullptr;
}

简单来说就是一个Actor属性同步,条件写了COND_OwnerOnly只发给拥有这个Actor的客户端,

那拥有这个Actor的客户端是谁呢,顺着自己的Owner找到PlayerController对应的那个客户端。

网络相关性(Relevancy)

什么流程调用,见上一篇。

相关性决定了一个 Actor 是否需要复制给某个连接。

默认 IsNetRelevantFor() 逻辑:

bool AActor::IsNetRelevantFor(const AActor* RealViewer, const AActor* ViewTarget, 
                               const FVector& SrcLocation) const
{
    // 1. bAlwaysRelevant → 始终相关
    if (bAlwaysRelevant) return true;

    // 2. bNetUseOwnerRelevancy → 用 Owner 的相关性
    if (bNetUseOwnerRelevancy && Owner) return Owner->IsNetRelevantFor(...);

    // 3. bOnlyRelevantToOwner → 只对 Owner 相关
    if (bOnlyRelevantToOwner) return IsOwnedBy(ViewTarget);

    // 4. 距离检查
    if (IsWithinNetRelevancyDistance(SrcLocation)) return true;

    // 5. 在 Pawn/ViewTarget 的视口中?
    // (通过点积判断是否在视野前方)
    return IsInFrustum(ViewTarget, SrcLocation);
}

相关性的后果

Actor 对连接 相关 不相关
已有 Channel 继续复制 一段时间后关闭 Channel
无 Channel 创建 Channel 并发送 SpawnInfo 不创建
客户端已有实例 持续更新 超时后销毁

关键属性

AActor::AActor()
{
    bAlwaysRelevant = false;         // true = 对所有连接始终相关(GameState 等)
    bOnlyRelevantToOwner = false;    // true = 只对 Owner 连接相关(HUD Actor 等)
    bNetUseOwnerRelevancy = false;   // true = 使用 Owner 的相关性
    NetCullDistanceSquared = 225000000.f; // 裁剪距离平方(默认 ~15000 uu = 150m)
};

网络休眠(Net Dormancy)

enum ENetDormancy : int
{
    DORM_Never,          // 永不休眠(始终参与检查)
    DORM_Awake,          // 支持休眠但当前清醒
    DORM_DormantAll,     // 对所有连接休眠
    DORM_DormantPartial, // 对部分连接休眠
    DORM_Initial,        // 关卡放置的 Actor 初始休眠
};

休眠的 Actor 完全跳过:

  • 属性比较(CompareProperties)
  • 属性序列化
  • IsNetRelevantFor 检查(ReplicationGraph 中)
  • GatherActorLists(不会被收集到列表中)

正常 Actor: 每帧 → 检查属性 → 比较 → 变化则发送

休眠 Actor: 完全跳过 ✓(零 CPU 开销)

如何使用

AMyActor::AMyActor()
{
    NetDormancy = DORM_Initial; // ★ 推荐:初始休眠
}

void AMyActor::OnStateChanged()
{
    bIsOpen = true;

    // 方法 1:FlushNetDormancy — 临时唤醒,复制一次后自动回到休眠
    FlushNetDormancy();

    // 方法 2:ForceNetUpdate — 不改变休眠状态,强制下帧复制
    ForceNetUpdate();

    // 方法 3:切换为清醒 — 持续参与复制
    SetNetDormancy(DORM_Awake);
}

void AMyActor::OnSettled()
{
    // 活动结束后重新休眠
    SetNetDormancy(DORM_DormantAll);
}

休眠的通道行为

Actor 设为 DORM_DormantAll:
  → ActorChannel 进入 Dormant 状态(不关闭!)
  → 不再参与 ReplicateActor()
  → Channel 保持打开,客户端保持 Actor 实例

Actor FlushNetDormancy():
  → Channel 恢复活跃
  → 复制当前属性差异
  → 如果 Actor 仍标记 WantsToBeDormant → 再次休眠

Actor 超出相关距离且休眠:
  → ReplicationGraph 可选择关闭 Channel
  → 客户端销毁 Actor
  → 重新进入范围时重新 Spawn

属性复制优化

Push Model(推送模型)★★★

问题:默认行为下,引擎每帧遍历每个 Actor 的所有 Replicated 属性做 memcmp。属性多但变化少时,大量无意义比较。

解决方案:开发者在修改属性时主动标记脏,引擎只比较脏属性。

// 声明
void AMyActor::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
{
    Super::GetLifetimeReplicatedProps(OutLifetimeProps);

    FDoRepLifetimeParams Params;
    Params.bIsPushBased = true; // ★ 启用 Push Model
    DOREPLIFETIME_WITH_PARAMS_FAST(AMyActor, Health, Params);
}

// 修改时标记
void AMyActor::SetHealth(float NewHealth)
{
    Health = NewHealth;
    MARK_PROPERTY_DIRTY_FROM_NAME(AMyActor, Health, this); // ★ 标记脏
}

PushModelMacros

说明
MARK_PROPERTY_DIRTY_FROM_NAME(Class, Prop, Obj) 标记单个属性脏
MARK_PROPERTY_DIRTY_FROM_NAME_STATIC_ARRAY_INDEX(C,P,I,O) 静态数组某个索引
MARK_PROPERTY_DIRTY_FROM_NAME_STATIC_ARRAY(C,P,O) 静态数组全部

NetUpdateFrequency — 复制频率

float NetUpdateFrequency = 100.f;  // 每秒最多考虑复制 100 次
float MinNetUpdateFrequency = 2.f; // 属性没变时降到每秒 2 次

引擎自适应调整:当属性不断变化时使用最大频率,属性不变时逐渐降到最小频率。

Actor属性,引擎有注释让你用接口别直接修改

NetPriority — 带宽竞争

float NetPriority = 1.0f; // 越高越优先被复制

带宽不足时,高优先级 Actor 被优先处理,低优先级的推迟到下帧。优先级还受距离影响:

实际优先级 ≈ NetPriority × (1.0 / Distance) × TimeSinceLastRep

NetCullDistanceSquared — 裁剪距离

NetCullDistanceSquared = 150000.f * 150000.f; // 超过 1500m 不复制

FFastArraySerializer 快速数组

问题和使用

复制普通 TArray<T> 复制基于索引,痛点:

  1. 插入/删除导致索引错位——需要全量比较或大量冗余数据
  2. 回调缺失——不知道具体"哪个元素被增/删/改"
  3. 带宽浪费——改了 1 个元素也要扫描整个数组

FFastArraySerializer 基于 ReplicationID 而非索引,实现增量复制:只发送变化的元素:

  • 新 ID → PostReplicatedAdd
  • 已有 ID 但 Key 变了 → PostReplicatedChange
  • 客户端有但服务器没有 → PreReplicatedRemove
// Step 1: 元素结构继承 FFastArraySerializerItem
USTRUCT()
struct FMyItem : public FFastArraySerializerItem
{
    GENERATED_USTRUCT_BODY()
    UPROPERTY()
    int32 ItemId;
    UPROPERTY()
    float Value;

    void PreReplicatedRemove(const struct FMyItemArray& Array);
    void PostReplicatedAdd(const struct FMyItemArray& Array);
    void PostReplicatedChange(const struct FMyItemArray& Array);
};

// Step 2: 容器结构继承 FFastArraySerializer
USTRUCT()
struct FMyItemArray : public FFastArraySerializer
{
    GENERATED_USTRUCT_BODY()
    UPROPERTY()
    TArray<FMyItem> Items;

    // Step 3: 实现 NetDeltaSerialize(固定写法)
    bool NetDeltaSerialize(FNetDeltaSerializeInfo& DeltaParms)
    {
        return FFastArraySerializer::FastArrayDeltaSerialize<FMyItem, FMyItemArray>(
            Items, DeltaParms, *this);
    }
};

// Step 4: 注册 Trait
template<>
struct TStructOpsTypeTraits<FMyItemArray> : public TStructOpsTypeTraitsBase2<FMyItemArray>
{
    enum { WithNetDeltaSerializer = true };
};

// Step 5: 在 Actor 中声明并正常 DOREPLIFETIME
UPROPERTY(Replicated)
FMyItemArray ItemArray;

Lyra的写法 PreReplicatedRemove 这三个函数在 FFastArraySerializer 上

相关API

// 添加
FMyItem& New = ItemArray.Items.AddDefaulted_GetRef();
New.ItemId = 42;
ItemArray.MarkItemDirty(New);          // ★ 必须

// 修改
ItemArray.Items[i].Value = 200.f;
ItemArray.MarkItemDirty(ItemArray.Items[i]); // ★ 必须

// 删除
ItemArray.Items.RemoveAt(i);
ItemArray.MarkArrayDirty();            // ★ 删除用 MarkArrayDirty,因为index不对了map要重建

内部原理

每个 Item 有两个内部字段:

  • ReplicationID:唯一标识(全局递增 int32)
  • ReplicationKey:变更版本号(MarkItemDirty 时递增)

服务端序列化(发送):

  1. 比较旧的 BaseState(ID→Key Map)与当前状态
  2. ID 缺失 → 写入"删除"
  3. ID 新增或 Key 变了 → 写入"变更" + 序列化数据

客户端反序列化(接收):

  1. 读取变更/删除数量
  2. 构建 ID→LocalIndex 映射
  3. 对每个变更:查找或创建元素,反序列化 → 触发 Add/Change 回调
  4. 对每个删除:查找并移除 → 触发 Remove 回调

子对象复制(Subobject Replication)

Actor 不仅自身属性需要复制,其组件(Component)和其他附属 UObject 也可能需要复制。这些统称为子对象(Subobject)。

Lyra EquipmentManagerComponent 有示例:

// 在 Actor 中注册需要复制的子对象
void AMyActor::BeginPlay()
{
    Super::BeginPlay();

    if (HasAuthority())
    {
        MyDynamicComponent = NewObject<UMyComponent>(this);
        MyDynamicComponent->RegisterComponent();
        AddReplicatedSubObject(MyDynamicComponent); // ★ 注册
    }
}

// 移除时注销
void AMyActor::OnComponentDestroyed()
{
    RemoveReplicatedSubObject(MyDynamicComponent); // ★ 注销
}

MyDynamicComponent 需要有 GetLifetimeReplicatedProps 那个属性

Lyra示例

UEqZeroEquipmentInstance* UEqZeroEquipmentManagerComponent::EquipItem(TSubclassOf<UEqZeroEquipmentDefinition> EquipmentClass)
{
    UEqZeroEquipmentInstance* Result = nullptr;
    if (EquipmentClass != nullptr)
    {
        Result = EquipmentList.AddEntry(EquipmentClass);
        if (Result != nullptr)
        {
            Result->OnEquipped();

            // 注册为复制子对象,使装备实例可以通过网络同步
            if (IsUsingRegisteredSubObjectList() && IsReadyForReplication())
            {
                AddReplicatedSubObject(Result);
            }
        }
    }
    return Result;
}

void UEqZeroEquipmentManagerComponent::UnequipItem(UEqZeroEquipmentInstance* ItemInstance)
{
    if (ItemInstance != nullptr)
    {
        if (IsUsingRegisteredSubObjectList())
        {
            RemoveReplicatedSubObject(ItemInstance);
        }

        ItemInstance->OnUnequipped();
        EquipmentList.RemoveEntry(ItemInstance);
    }
}

旧版 ReplicateSubobjects 方式(Legacy)

bool AMyActor::ReplicateSubobjects(UActorChannel* Channel, FOutBunch* Bunch, 
                                    FReplicationFlags* RepFlags)
{
    bool WroteSomething = Super::ReplicateSubobjects(Channel, Bunch, RepFlags);

    for (UMySubobject* Obj : DynamicSubobjects)
    {
        WroteSomething |= Channel->ReplicateSubobject(Obj, *Bunch, *RepFlags);
    }

    return WroteSomething;
}

用新的还是旧的的区别是 bReplicateUsingRegisteredSubObjectList

ActorComponent 默认复制

UActorComponent::UActorComponent()
{
    SetIsReplicatedByDefault(true); // 或
    SetIsReplicated(true);
}

默认复制的组件(标记为 Replicated)自动参与子对象复制。组件的 Replicated 属性和 RPC 通过 Owner Actor 的 ActorChannel 传输。

角色移动复制(CharacterMovement Replication)

https://www.cwlgame.cn/2024/06/27/justmakegame-4-%e8%99%9a%e5%b9%bb%e7%a7%bb%e5%8a%a8%e7%bb%84%e4%bb%b6%e7%bd%91%e7%bb%9c%e5%90%8c%e6%ad%a5%e7%9a%84%e4%b8%80%e4%b8%aa%e7%bb%86%e8%8a%82%e5%a4%84%e7%90%86/

三代复制驱动系统

系统 位置 核心类 适用场景
默认系统 Engine/Source/Runtime/Engine/ UNetDriver::ServerReplicateActors 小型项目、原型
ReplicationGraph Engine/Plugins/Runtime/ReplicationGraph/ UReplicationGraph + Node 系统 生产级 DS(Fortnite)
Iris(实验性) Engine/Source/Runtime/Experimental/Iris/ UReplicationSystem 下一代

ReplicationGraph:用图节点(Node)将 Actor 分类管理,避免每帧全量遍历。等写完Lyra示例再来。

Iris:下次一定

DS连接

客户端发起连接

// 通常在登录界面点击"加入游戏"后调用
APlayerController->ClientTravel("IP:PORT", TRAVEL_Absolute);
// 或
GEngine->Browse(WorldContext, FURL("IP:PORT"), Error);

连接流程在前面说过。

NMT_Welcome 的时候发地图名。然后后面流程都连起来了。

客户端                          DS
  │──── NMT_Hello ────────────→│  发版本号
  │←─── NMT_Challenge ─────────│  返回挑战码(防伪造)
  │──── NMT_Login ─────────────→│  发玩家名、Options字符串
  │                              │  → GameMode::PreLogin()  ← 你的验证逻辑在这
  │←─── NMT_Welcome ────────────│  发当前地图名
  │──── NMT_Netspeed ──────────→│  发网速
  │                              │  → GameMode::Login()     ← 创建 PlayerController
  │                              │  → GameMode::PostLogin()
  │←─── 复制 PlayerController ──│  PC 开始同步给客户端
  │──── NMT_Join ──────────────→│  确认收到 PC
  │                              │  → GameMode::HandleStartingNewPlayer()
  │                              │  → RestartPlayer() → Spawn Pawn
上一篇
下一篇