本文由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 个元素也要扫描整个数组
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时递增)
服务端序列化(发送):
- 比较旧的 BaseState(ID→Key Map)与当前状态
- ID 缺失 → 写入"删除"
- ID 新增或 Key 变了 → 写入"变更" + 序列化数据
客户端反序列化(接收):
- 读取变更/删除数量
- 构建 ID→LocalIndex 映射
- 对每个变更:查找或创建元素,反序列化 → 触发 Add/Change 回调
- 对每个删除:查找并移除 → 触发 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)
三代复制驱动系统
| 系统 | 位置 | 核心类 | 适用场景 |
|---|---|---|---|
| 默认系统 | 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