🔍 Iris 网络复制系统技术分析 - 第十一部分:轮询与脏数据检测

📖 本章导读:想象你是一位繁忙餐厅的服务员,需要照顾100桌客人。你有两种工作方式:一种是每隔几分钟挨个检查每一桌(轮询模式),另一种是让客人需要服务时主动举手示意(推送模式)。Iris 的脏数据检测系统正是这两种策略的完美结合,本章将带你深入理解这个高效的"服务员"是如何工作的!
🎯 11.1 脏数据检测机制概述
💡 11.1.1 什么是"脏数据"?
在网络复制的世界里,"脏数据"(Dirty Data) 指的是自上次同步以来发生了变化的数据。
PLAINTEXT
🎮 日常类比:快递追踪系统
想象你网购了一件商品,快递公司需要实时更新物流信息:
📦 商品状态变化历程:
┌─────────────────────────────────────────────────────────┐
│ 时间点 │ 状态 │ 是否需要通知客户(脏?) │
├─────────────────────────────────────────────────────────┤
│ Day 1 │ 已发货 │ ✅ 是(状态变了) │
│ Day 1.5 │ 已发货 │ ❌ 否(状态没变) │
│ Day 2 │ 运输中 │ ✅ 是(状态变了) │
│ Day 3 │ 派送中 │ ✅ 是(状态变了) │
│ Day 3.5 │ 派送中 │ ❌ 否(状态没变) │
│ Day 4 │ 已签收 │ ✅ 是(状态变了) │
└─────────────────────────────────────────────────────────┘
只有状态发生变化时,才需要发送通知 = 只有数据变"脏"时,才需要网络同步!🔄 11.1.2 Poll 模式 vs Push 模式
Iris 支持两种脏数据检测策略,各有优劣:
PLAINTEXT
┌──────────────────────────────────────────────────────────────────────┐
│ 🔍 脏数据检测的两种模式 │
├──────────────────────────────────────────────────────────────────────┤
│ │
│ 📊 Poll 模式(轮询模式) 📢 Push 模式(推送模式) │
│ ════════════════════ ════════════════════ │
│ │
│ 🏠 类比:保安巡逻 🔔 类比:门铃报警 │
│ │
│ ┌─────────────┐ ┌─────────────┐ │
│ │ 保安室 │ │ 监控室 │ │
│ │ ┌─────┐ │ │ ┌─────┐ │ │
│ │ │ 👮 │ │ 定时巡逻 │ │ 👨💻 │ │ 等待报警 │
│ │ └─────┘ │ ────────► │ └─────┘ │ ◄──────── │
│ └─────────────┘ 每帧检查 └─────────────┘ 主动通知 │
│ │ ▲ │
│ ▼ │ │
│ ┌─────────────┐ ┌─────────────┐ │
│ │ 🏠🏠🏠🏠🏠 │ │ 🔔🔔🔔🔔🔔 │ │
│ │ 挨个检查 │ │ 有变化才响 │ │
│ └─────────────┘ └─────────────┘ │
│ │
│ ✅ 优点:简单可靠 ✅ 优点:高效省资源 │
│ ❌ 缺点:CPU 开销大 ❌ 缺点:需要代码配合 │
│ │
└──────────────────────────────────────────────────────────────────────┘特性 | 🔍 Poll 模式 | 📢 Push 模式 |
|---|---|---|
工作方式 | 系统主动检查每个属性 | 代码主动通知系统 |
CPU 开销 | 较高(需要比较所有属性) | 较低(只处理变化的) |
代码侵入性 | 无(自动工作) | 需要手动标记脏属性 |
适用场景 | 属性变化频繁的对象 | 属性变化稀少的对象 |
可靠性 | 100% 可靠 | 依赖开发者正确标记 |
🏗️ 11.1.3 Iris 的混合策略
Iris 采用智能混合策略,结合两种模式的优点:
PLAINTEXT
┌────────────────────────────────────────────────────────────────────────┐│ 🧠 Iris 智能脏数据检测流程 │├────────────────────────────────────────────────────────────────────────┤│ ││ 游戏对象属性变化 ││ │ ││ ▼ ││ ┌─────────────────────────────────────┐ ││ │ 是否启用 Push Model? │ ││ │ (net.Iris.PushModelMode) │ ││ └─────────────────────────────────────┘ ││ │ │ ││ Yes │ │ No ││ ▼ ▼ ││ ┌───────────────┐ ┌───────────────┐ ││ │ Push Model │ │ Force Poll │ ││ │ 检查脏标记 │ │ 强制比较属性 │ ││ └───────────────┘ └───────────────┘ ││ │ │ ││ │ ┌───────────────┘ ││ ▼ ▼ ││ ┌─────────────────────────────────────┐ ││ │ 对象被标记为脏? │ ││ └─────────────────────────────────────┘ ││ │ │ ││ Yes │ │ No ││ ▼ ▼ ││ ┌───────────────┐ ┌───────────────┐ ││ │ 加入脏对象 │ │ 跳过本次复制 │ ││ │ 列表等待复制 │ │ 节省带宽 │ ││ └───────────────┘ └───────────────┘ ││ │└────────────────────────────────────────────────────────────────────────┘📂 11.1.4 关键源文件索引
文件 | 路径 | 职责 |
|---|---|---|
|
| Iris 脏对象追踪器 |
|
| 全局脏对象追踪器 |
|
| 对象轮询器 |
|
| 轮询频率限制器 |
📊 11.2 DirtyNetObjectTracker(脏对象追踪器)
💡 11.2.1 追踪器的职责
FDirtyNetObjectTracker 是 Iris 系统中负责追踪哪些对象需要复制的核心组件。
PLAINTEXT
🎮 日常类比:待办事项清单
想象你有一个智能待办事项 App:
- 📝 当你添加新任务时,它会自动出现在"待处理"列表- ✅ 当你完成任务时,它会从列表中移除- 🔄 每天早上,App 会告诉你今天需要处理哪些任务
DirtyNetObjectTracker 就是网络复制的"待办事项清单":
- 📝 对象变脏 → 加入脏对象列表- ✅ 复制完成 → 从列表中移除- 🔄 每帧开始 → 告诉系统哪些对象需要复制🏗️ 11.2.2 核心数据结构
CPP
// 源码位置:DirtyNetObjectTracker.hclass FDirtyNetObjectTracker
{
private:
// 🎯 三个关键的位数组(BitArray)
// 1️⃣ 累积脏对象:记录自上次更新以来所有变脏的对象
FNetBitArray AccumulatedDirtyNetObjects;
// 2️⃣ 当前帧脏对象:本帧需要处理的脏对象
FNetBitArray DirtyNetObjects;
// 3️⃣ 强制更新对象:被 ForceNetUpdate 标记的对象
FNetBitArray ForceNetUpdateObjects;
// 📊 全局脏对象追踪器的引用
FGlobalDirtyNetObjectTracker* GlobalDirtyNetObjectTracker;
// 🆔 复制系统 ID(支持 PIE 多实例)
uint32 ReplicationSystemId;
};PLAINTEXT
┌────────────────────────────────────────────────────────────────────────┐
│ 📊 三个位数组的关系图解 │
├────────────────────────────────────────────────────────────────────────┤
│ │
│ AccumulatedDirtyNetObjects(累积脏对象) │
│ ┌─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┐ │
│ │1│0│1│1│0│0│1│0│0│1│0│0│1│0│1│0│ ← 持续累积,直到被消费 │
│ └─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┘ │
│ │ │
│ │ Update() 时合并 │
│ ▼ │
│ DirtyNetObjects(当前帧脏对象) │
│ ┌─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┐ │
│ │1│0│1│1│0│0│1│0│0│1│0│0│1│0│1│0│ ← 本帧需要处理的对象 │
│ └─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┘ │
│ │ │
│ │ OR 运算合并 │
│ ▼ │
│ ForceNetUpdateObjects(强制更新对象) │
│ ┌─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┐ │
│ │0│0│0│0│1│0│0│0│0│0│0│1│0│0│0│0│ ← ForceNetUpdate() 标记的对象 │
│ └─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┘ │
│ │
│ 最终需要复制的对象 = DirtyNetObjects | ForceNetUpdateObjects │
│ │
└────────────────────────────────────────────────────────────────────────┘📝 11.2.3 核心 API 详解
标记对象为脏
CPP
// 源码位置:DirtyNetObjectTracker.h
/**
* 🎯 标记网络对象状态为脏
*
* @param NetRefHandle - 网络对象句柄
* @param ReplicationSystemId - 复制系统 ID
*
* 💡 这是一个全局函数,可以从任何地方调用
* ⚠️ 线程安全:使用原子操作,支持多线程调用
*/IRISCORE_API void MarkNetObjectStateDirty(
FNetRefHandle NetRefHandle,
uint32 ReplicationSystemId
);
/**
* 🎯 强制网络更新
*
* 即使对象没有变脏,也强制在下一帧复制
* 常用于:休眠唤醒、重要事件触发等场景
*/IRISCORE_API void ForceNetUpdate(
FNetRefHandle NetRefHandle,
uint32 ReplicationSystemId
);实际使用示例
CPP
// 🎮 游戏代码示例:角色受伤时标记血量为脏
void AMyCharacter::TakeDamage(float DamageAmount){
// 更新血量
Health -= DamageAmount;
// 方式1:使用 Push Model 宏(推荐)
MARK_PROPERTY_DIRTY_FROM_NAME(AMyCharacter, Health, this);
// 方式2:直接调用 Iris API(底层方式)
// MarkNetObjectStateDirty(GetNetRefHandle(), GetReplicationSystemId());
}
// 🎮 强制更新示例:Boss 出现时强制同步所有客户端
void ABossEnemy::OnBossAppear(){
// 即使没有属性变化,也强制同步
ForceNetUpdate(); // Actor 的便捷方法
}🔄 11.2.4 更新流程
CPP
// 源码位置:DirtyNetObjectTracker.cpp
void FDirtyNetObjectTracker::UpdateDirtyNetObjects(){
// 1️⃣ 从全局追踪器获取脏对象
if (GlobalDirtyNetObjectTracker)
{
// 获取自上次以来所有变脏的对象
GlobalDirtyNetObjectTracker->UpdateDirtyNetObjects(
ReplicationSystemId,
AccumulatedDirtyNetObjects
);
}
// 2️⃣ 合并累积的脏对象到当前帧
// DirtyNetObjects |= AccumulatedDirtyNetObjects
DirtyNetObjects.Combine(
AccumulatedDirtyNetObjects,
FNetBitArray::OrOp
);
// 3️⃣ 清空累积数组,准备下一轮累积
AccumulatedDirtyNetObjects.Reset();
// 4️⃣ 合并强制更新的对象
// DirtyNetObjects |= ForceNetUpdateObjects
DirtyNetObjects.Combine(
ForceNetUpdateObjects,
FNetBitArray::OrOp
);
// 5️⃣ 清空强制更新数组
ForceNetUpdateObjects.Reset();
}PLAINTEXT
┌────────────────────────────────────────────────────────────────────────┐
│ 🔄 脏对象更新时序图 │
├────────────────────────────────────────────────────────────────────────┤
│ │
│ 帧 N-1 帧 N │
│ ───────────────────────────────────────────────────────────── │
│ │
│ 游戏逻辑修改属性 │
│ │ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ MARK_PROPERTY │ │
│ │ _DIRTY │ │
│ └────────┬────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ GlobalDirty │ ← 全局追踪器记录 │
│ │ NetObjectTracker│ │
│ └────────┬────────┘ │
│ │ │
│ │ ════════════════ 帧边界 ════════════════ │
│ │ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ UpdateDirty │ ← 复制系统更新阶段 │
│ │ NetObjects() │ │
│ └────────┬────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ 脏对象进入 │ │
│ │ 复制队列 │ │
│ └─────────────────┘ │
│ │
└────────────────────────────────────────────────────────────────────────┘🌐 11.2.5 全局脏对象追踪器
FGlobalDirtyNetObjectTracker 是一个跨 NetDriver 的全局追踪器,支持多个复制系统同时读取脏对象列表。
CPP
// 源码位置:GlobalDirtyNetObjectTracker.h
class FGlobalDirtyNetObjectTracker
{
public:
/**
* 🎯 添加脏对象到全局列表
*
* ⚠️ 线程安全:使用原子操作
* 📊 支持多个 Poller 同时读取
*/
void AddDirtyObject(uint32 ObjectIndex);
/**
* 🎯 获取脏对象列表并更新到目标数组
*
* @param PollerId - 轮询器 ID
* @param OutDirtyObjects - 输出的脏对象位数组
*/
void UpdateDirtyNetObjects(
uint32 PollerId,
FNetBitArray& OutDirtyObjects
);
private:
// 🔒 原子位数组,支持多线程写入
FNetBitArrayAtomic DirtyObjects;
// 📊 每个 Poller 的读取状态
TArray<FPollerState> PollerStates;
};PLAINTEXT
┌────────────────────────────────────────────────────────────────────────┐
│ 🌐 全局追踪器多 Poller 支持 │
├────────────────────────────────────────────────────────────────────────┤
│ │
│ GlobalDirtyNetObjectTracker │
│ ┌─────────────────────────┐ │
│ │ DirtyObjects (原子) │ │
│ │ ┌─┬─┬─┬─┬─┬─┬─┬─┬─┬─┐ │ │
│ │ │1│0│1│1│0│0│1│0│0│1│ │ │
│ │ └─┴─┴─┴─┴─┴─┴─┴─┴─┴─┘ │ │
│ └───────────┬─────────────┘ │
│ │ │
│ ┌────────────────────┼────────────────────┐ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌───────────────┐ ┌───────────────┐ ┌───────────────┐ │
│ │ Poller 0 │ │ Poller 1 │ │ Poller 2 │ │
│ │ (主游戏) │ │ (回放录制) │ │ (观战系统) │ │
│ └───────────────┘ └───────────────┘ └───────────────┘ │
│ │
│ 💡 每个 Poller 独立维护自己的"已读取"状态 │
│ 💡 支持不同系统以不同频率消费脏对象 │
│ │
└────────────────────────────────────────────────────────────────────────┘📢 11.3 Push Model(推送模型)
💡 11.3.1 什么是 Push Model?
Push Model 是一种主动通知的脏数据检测机制。开发者在修改复制属性时,主动调用宏来通知网络系统"这个属性变了"。
PLAINTEXT
🎮 日常类比:微信消息 vs 邮件检查
📱 Push Model = 微信消息
- 有新消息时,手机立即推送通知
- 你不需要反复打开 App 检查
- 高效、省电、实时
📧 Poll Model = 定时检查邮件
- 每隔几分钟打开邮箱看看有没有新邮件
- 即使没有新邮件也要检查
- 简单但浪费资源⚙️ 11.3.2 启用 Push Model
CPP
// 方式1:通过控制台变量// 在 DefaultEngine.ini 或运行时设置
net.IsPushModelEnabled=true
// 方式2:Iris 特定的 Push Model 模式// 在 DefaultEngine.ini 中
[/Script/IrisCore.IrisSettings]
PushModelMode=Enabled // Disabled, Enabled, ForcePollPLAINTEXT
┌────────────────────────────────────────────────────────────────────────┐
│ ⚙️ Push Model 模式对比 │
├────────────────────────────────────────────────────────────────────────┤
│ │
│ 模式 │ 行为 │ 适用场景 │
│ ───────────────────────────────────────────────────────────────── │
│ Disabled │ 不使用 Push Model │ 传统项目迁移 │
│ │ 所有对象强制轮询 │ │
│ ───────────────────────────────────────────────────────────────── │
│ Enabled (默认) │ 智能混合模式 │ 大多数项目 │
│ │ 支持 Push 的用 Push │ │
│ │ 不支持的用 Poll │ │
│ ───────────────────────────────────────────────────────────────── │
│ ForcePoll │ 强制所有对象轮询 │ 调试、性能对比 │
│ │ 忽略 Push 标记 │ │
│ │
└────────────────────────────────────────────────────────────────────────┘📝 11.3.3 MARK_PROPERTY_DIRTY 宏家族
Push Model 提供了一系列宏来标记属性为脏:
CPP
// 源码位置:PushModel.h
// ═══════════════════════════════════════════════════════════════════════// 🎯 核心宏:标记单个属性为脏// ═══════════════════════════════════════════════════════════════════════
/**
* MARK_PROPERTY_DIRTY_FROM_NAME
*
* 最常用的宏,通过类名和属性名标记属性为脏
* ✅ 编译时检查:类名或属性名错误会编译失败
* ✅ 高效:使用编译时常量而非运行时查找
*/#define MARK_PROPERTY_DIRTY_FROM_NAME(ClassName, PropertyName, Object)
// 使用示例void AMyCharacter::SetHealth(float NewHealth){
Health = NewHealth;
MARK_PROPERTY_DIRTY_FROM_NAME(AMyCharacter, Health, this);
}
// ═══════════════════════════════════════════════════════════════════════// 🎯 静态数组宏:标记数组单个元素// ═══════════════════════════════════════════════════════════════════════
/**
* MARK_PROPERTY_DIRTY_FROM_NAME_STATIC_ARRAY_INDEX
*
* 用于静态数组(C风格数组)的单个元素
*/#define MARK_PROPERTY_DIRTY_FROM_NAME_STATIC_ARRAY_INDEX(
ClassName, PropertyName, ArrayIndex, Object)
// 使用示例void AMyCharacter::SetInventorySlot(int32 Index, UItem* Item){
InventorySlots[Index] = Item;
MARK_PROPERTY_DIRTY_FROM_NAME_STATIC_ARRAY_INDEX(
AMyCharacter, InventorySlots, Index, this);
}
// ═══════════════════════════════════════════════════════════════════════// 🎯 静态数组宏:标记整个数组// ═══════════════════════════════════════════════════════════════════════
/**
* MARK_PROPERTY_DIRTY_FROM_NAME_STATIC_ARRAY
*
* 用于标记整个静态数组为脏
*/#define MARK_PROPERTY_DIRTY_FROM_NAME_STATIC_ARRAY(
ClassName, PropertyName, Object)
// 使用示例void AMyCharacter::ResetInventory(){
FMemory::Memzero(InventorySlots, sizeof(InventorySlots));
MARK_PROPERTY_DIRTY_FROM_NAME_STATIC_ARRAY(
AMyCharacter, InventorySlots, this);
}
// ═══════════════════════════════════════════════════════════════════════// 🎯 便捷宏:比较并标记// ═══════════════════════════════════════════════════════════════════════
/**
* COMPARE_ASSIGN_AND_MARK_PROPERTY_DIRTY
*
* 只有值真的变化时才标记为脏
* 适用于简单类型(int, float, pointer)
*/#define COMPARE_ASSIGN_AND_MARK_PROPERTY_DIRTY(
ClassName, PropertyName, NewValue, Object)
// 使用示例void AMyCharacter::SetLevel(int32 NewLevel){
// 只有 Level 真的变化时才标记脏
COMPARE_ASSIGN_AND_MARK_PROPERTY_DIRTY(
AMyCharacter, Level, NewLevel, this);
}🏗️ 11.3.4 完整的 Push Model 使用示例
CPP
// ═══════════════════════════════════════════════════════════════════════// 📁 MyCharacter.h - 头文件// ═══════════════════════════════════════════════════════════════════════
#pragma once
#include "CoreMinimal.h"#include "GameFramework/Character.h"#include "MyCharacter.generated.h"
USTRUCT()
struct FCharacterStats
{
GENERATED_BODY()
UPROPERTY()
int32 Strength = 10;
UPROPERTY()
int32 Agility = 10;
UPROPERTY()
int32 Intelligence = 10;
};
UCLASS()
class MYGAME_API AMyCharacter : public ACharacter
{
GENERATED_BODY()
public:
// ════════════════════════════════════════════════════════════════
// 🎯 Setter 方法(推荐方式)
// ════════════════════════════════════════════════════════════════
/** 设置血量 - 简单类型 */
void SetHealth(float NewHealth);
/** 设置等级 - 使用比较优化 */
void SetLevel(int32 NewLevel);
/** 设置角色属性 - 结构体类型 */
void SetStats(const FCharacterStats& NewStats);
/** 修改单个属性值 */
void ModifyStrength(int32 Delta);
/** 设置装备槽 - 静态数组单元素 */
void SetEquipmentSlot(int32 SlotIndex, UItem* Item);
/** 清空所有装备 - 静态数组整体 */
void ClearAllEquipment();
// ════════════════════════════════════════════════════════════════
// 🎯 Getter 方法(返回引用时的特殊处理)
// ════════════════════════════════════════════════════════════════
/** 获取属性的可变引用 - ⚠️ 需要预先标记脏 */
FCharacterStats& GetStats_Mutable();
/** 获取属性的只读引用 - ✅ 安全 */
const FCharacterStats& GetStats() const { return Stats; }
protected:
virtual void GetLifetimeReplicatedProps(
TArray<FLifetimeProperty>& OutLifetimeProps) const override;
private:
// ════════════════════════════════════════════════════════════════
// 📊 复制属性
// ════════════════════════════════════════════════════════════════
UPROPERTY(Replicated)
float Health = 100.0f;
UPROPERTY(Replicated)
int32 Level = 1;
UPROPERTY(Replicated)
FCharacterStats Stats;
UPROPERTY(Replicated)
UItem* EquipmentSlots[6]; // 静态数组:头、胸、手、腿、脚、武器
};
// ═══════════════════════════════════════════════════════════════════════// 📁 MyCharacter.cpp - 实现文件// ═══════════════════════════════════════════════════════════════════════
#include "MyCharacter.h"#include "Net/UnrealNetwork.h"#include "Net/Core/PushModel/PushModel.h"
void AMyCharacter::GetLifetimeReplicatedProps(
TArray<FLifetimeProperty>& OutLifetimeProps) const{
Super::GetLifetimeReplicatedProps(OutLifetimeProps);
// 🎯 关键:设置 bIsPushBased = true 启用 Push Model
FDoRepLifetimeParams Params;
Params.bIsPushBased = true;
DOREPLIFETIME_WITH_PARAMS_FAST(AMyCharacter, Health, Params);
DOREPLIFETIME_WITH_PARAMS_FAST(AMyCharacter, Level, Params);
DOREPLIFETIME_WITH_PARAMS_FAST(AMyCharacter, Stats, Params);
DOREPLIFETIME_WITH_PARAMS_FAST(AMyCharacter, EquipmentSlots, Params);
}
// ════════════════════════════════════════════════════════════════════════// 🎯 简单类型 Setter// ════════════════════════════════════════════════════════════════════════
void AMyCharacter::SetHealth(float NewHealth){
Health = NewHealth;
// ✅ 标记 Health 属性为脏
MARK_PROPERTY_DIRTY_FROM_NAME(AMyCharacter, Health, this);
}
// ════════════════════════════════════════════════════════════════════════// 🎯 使用比较优化的 Setter// ════════════════════════════════════════════════════════════════════════
void AMyCharacter::SetLevel(int32 NewLevel){
// ✅ 只有值变化时才标记脏,避免不必要的网络流量
COMPARE_ASSIGN_AND_MARK_PROPERTY_DIRTY(AMyCharacter, Level, NewLevel, this);
}
// ════════════════════════════════════════════════════════════════════════// 🎯 结构体类型 Setter// ════════════════════════════════════════════════════════════════════════
void AMyCharacter::SetStats(const FCharacterStats& NewStats){
Stats = NewStats;
// ✅ 标记整个结构体为脏
// ⚠️ 注意:即使只改了一个字段,也要标记整个结构体
MARK_PROPERTY_DIRTY_FROM_NAME(AMyCharacter, Stats, this);
}
void AMyCharacter::ModifyStrength(int32 Delta){
Stats.Strength += Delta;
// ✅ 修改结构体内部字段,仍然标记整个结构体
MARK_PROPERTY_DIRTY_FROM_NAME(AMyCharacter, Stats, this);
}
// ════════════════════════════════════════════════════════════════════════// 🎯 静态数组 Setter// ════════════════════════════════════════════════════════════════════════
void AMyCharacter::SetEquipmentSlot(int32 SlotIndex, UItem* Item){
if (SlotIndex >= 0 && SlotIndex < 6)
{
EquipmentSlots[SlotIndex] = Item;
// ✅ 只标记改变的那个槽位
MARK_PROPERTY_DIRTY_FROM_NAME_STATIC_ARRAY_INDEX(
AMyCharacter, EquipmentSlots, SlotIndex, this);
}
}
void AMyCharacter::ClearAllEquipment(){
for (int32 i = 0; i < 6; ++i)
{
EquipmentSlots[i] = nullptr;
}
// ✅ 标记整个数组为脏
MARK_PROPERTY_DIRTY_FROM_NAME_STATIC_ARRAY(
AMyCharacter, EquipmentSlots, this);
}
// ════════════════════════════════════════════════════════════════════════// 🎯 返回可变引用的 Getter(特殊处理)// ════════════════════════════════════════════════════════════════════════
FCharacterStats& AMyCharacter::GetStats_Mutable(){
// ⚠️ 重要:返回引用前先标记脏
// 因为调用者可能会修改返回的引用
MARK_PROPERTY_DIRTY_FROM_NAME(AMyCharacter, Stats, this);
return Stats;
}⚠️ 11.3.5 Push Model 使用注意事项
PLAINTEXT
┌────────────────────────────────────────────────────────────────────────┐
│ ⚠️ Push Model 常见陷阱与解决方案 │
├────────────────────────────────────────────────────────────────────────┤
│ │
│ ❌ 陷阱 1:忘记标记脏 │
│ ────────────────────────────────────────────────────────────────── │
│ void SetHealth(float NewHealth) │
│ { │
│ Health = NewHealth; │
│ // 💀 忘记调用 MARK_PROPERTY_DIRTY! │
│ // 结果:客户端永远收不到新血量 │
│ } │
│ │
│ ✅ 解决:始终在 Setter 中调用 MARK_PROPERTY_DIRTY │
│ │
│ ────────────────────────────────────────────────────────────────── │
│ │
│ ❌ 陷阱 2:直接访问属性绕过 Setter │
│ ────────────────────────────────────────────────────────────────── │
│ // 在某处代码中... │
│ MyCharacter->Health = 50.0f; // 💀 直接赋值,没有标记脏! │
│ │
│ ✅ 解决:将属性设为 private,强制使用 Setter │
│ │
│ ────────────────────────────────────────────────────────────────── │
│ │
│ ❌ 陷阱 3:持有引用后修改 │
│ ────────────────────────────────────────────────────────────────── │
│ FCharacterStats& Stats = Character->GetStats_Mutable(); │
│ // ... 很久之后 ... │
│ Stats.Strength = 100; // 💀 此时修改不会被检测到! │
│ │
│ ✅ 解决: │
│ 1. 避免持有引用 │
│ 2. 每次修改后手动调用 MARK_PROPERTY_DIRTY │
│ 3. 使用 Setter 方法而非直接修改引用 │
│ │
│ ────────────────────────────────────────────────────────────────── │
│ │
│ ❌ 陷阱 4:在非服务器端标记脏 │
│ ────────────────────────────────────────────────────────────────── │
│ void AMyCharacter::ClientOnlyFunction() │
│ { │
│ Health = 100.0f; │
│ MARK_PROPERTY_DIRTY_FROM_NAME(...); // 💀 客户端调用无意义 │
│ } │
│ │
│ ✅ 解决:只在服务器端(HasAuthority)调用 MARK_PROPERTY_DIRTY │
│ │
└────────────────────────────────────────────────────────────────────────┘🔄 11.3.6 LegacyPushModel 兼容层
Iris 提供了 FNetHandleLegacyPushModelHelper 来桥接传统 Push Model 和 Iris 系统:
CPP
// 源码位置:LegacyPushModel.h
/**
* 🔄 Legacy Push Model 兼容层
*
* 将传统的 Push Model 脏标记转换为 Iris 的脏对象追踪
*/class FNetHandleLegacyPushModelHelper
{
public:
/**
* 初始化 Push Model 支持
*/
void InitPushModel(uint32 InReplicationSystemId);
/**
* 设置对象的 Push ID
*/
void SetNetPushID(
UObject* Object,
FNetRefHandle NetRefHandle,
const FReplicationProtocol* Protocol
);
/**
* 清除对象的 Push ID
*/
void ClearNetPushID(UObject* Object);
private:
uint32 ReplicationSystemId = 0;
};📊 11.3.7 性能优势分析
PLAINTEXT
┌────────────────────────────────────────────────────────────────────────┐│ 📊 Push Model vs Poll Model 性能对比 │├────────────────────────────────────────────────────────────────────────┤│ ││ 测试场景:1000 个 Actor,每个有 20 个复制属性 ││ 每帧只有 5% 的属性发生变化 ││ ││ ┌─────────────────────────────────────────────────────────────────┐ ││ │ CPU 时间对比(微秒/帧) │ ││ ├─────────────────────────────────────────────────────────────────┤ ││ │ │ ││ │ Poll Model: ████████████████████████████████████ 2000 μs │ ││ │ 检查 1000 × 20 = 20000 个属性 │ ││ │ │ ││ │ Push Model: ████ 200 μs │ ││ │ 只处理 1000 个脏对象标记 │ ││ │ │ ││ │ 性能提升: ~10x 🚀 │ ││ │ │ ││ └─────────────────────────────────────────────────────────────────┘ ││ ││ 💡 结论: ││ - 属性变化越少,Push Model 优势越大 ││ - 对象数量越多,Push Model 优势越大 ││ - 适合:大世界游戏、MMO、属性相对稳定的对象 ││ │└────────────────────────────────────────────────────────────────────────┘🔄 11.4 ObjectPoller(对象轮询器)
💡 11.4.1 轮询器的职责
FObjectPoller 负责执行对象的轮询阶段,检测哪些对象的属性发生了变化。
PLAINTEXT
🎮 日常类比:图书馆管理员
想象一位图书馆管理员需要检查哪些书被借出或归还:
📚 传统方式(强制轮询):
- 每天检查每一本书的状态
- 无论书有没有被动过都要检查
- 工作量大,但绝对可靠
📱 现代方式(Push Model):
- 借还书时系统自动记录
- 只检查有记录变动的书
- 高效,但依赖系统正确记录
🎯 FObjectPoller 就是这位管理员,它会:
- 根据配置选择检查方式
- 记录哪些"书"(对象)需要更新
- 生成变化掩码(ChangeMask)🏗️ 11.4.2 核心结构
CPP
// 源码位置:ObjectPoller.h
class FObjectPoller
{
public:
/**
* 🎯 轮询并复制对象
*
* 这是轮询阶段的主入口,处理所有需要轮询的对象
*/
void PollAndCopyObjects(FPollAndCopyObjectsParams& Params);
/**
* 🎯 轮询单个对象
*
* 用于需要立即轮询特定对象的场景
*/
void PollAndCopySingleObject(
FNetRefHandle RefHandle,
FReplicationInstanceProtocol* InstanceProtocol
);
private:
// 📊 轮询参数
struct FPollAndCopyObjectsParams
{
// 需要轮询的对象列表
const FNetBitArray& ObjectsToCompare;
// 脏对象追踪器
FDirtyNetObjectTracker& DirtyNetObjectTracker;
// 复制系统引用
UReplicationSystem* ReplicationSystem;
};
};🔄 11.4.3 轮询流程详解
CPP
// 源码位置:ObjectPoller.cpp
void FObjectPoller::PollAndCopyObjects(FPollAndCopyObjectsParams& Params){
// 遍历所有需要轮询的对象
for (uint32 ObjectIndex : Params.ObjectsToCompare)
{
FNetRefHandle RefHandle = GetNetRefHandle(ObjectIndex);
// 获取对象的复制协议实例
FReplicationInstanceProtocol* InstanceProtocol =
GetInstanceProtocol(RefHandle);
if (!InstanceProtocol)
{
continue;
}
// 🎯 根据 Push Model 配置选择轮询策略
if (IsIrisPushModelEnabled())
{
// Push Model 模式:只轮询脏对象或周期到期的对象
PushModelPollObject(RefHandle, InstanceProtocol, Params);
}
else
{
// 强制轮询模式:比较所有属性
ForcePollObject(RefHandle, InstanceProtocol, Params);
}
}
}PLAINTEXT
┌────────────────────────────────────────────────────────────────────────┐
│ 🔄 轮询流程图解 │
├────────────────────────────────────────────────────────────────────────┤
│ │
│ PollAndCopyObjects() │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────┐ │
│ │ 遍历 ObjectsToCompare 中的对象 │ │
│ └─────────────────┬───────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────┐ │
│ │ IsIrisPushModelEnabled()? │ │
│ └─────────────────┬───────────────────┘ │
│ ┌────────┴────────┐ │
│ │ │ │
│ Yes ▼ No ▼ │
│ ┌─────────────┐ ┌─────────────┐ │
│ │ PushModel │ │ ForcePoll │ │
│ │ PollObject │ │ Object │ │
│ └──────┬──────┘ └──────┬──────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌─────────────────────────────────────┐ │
│ │ 检查对象是否有脏属性 │ │
│ └─────────────────┬───────────────────┘ │
│ │ │
│ ┌────────┴────────┐ │
│ │ │ │
│ 有 ▼ 无 ▼ │
│ ┌─────────────┐ ┌─────────────┐ │
│ │ 比较属性 │ │ 跳过对象 │ │
│ │ 生成掩码 │ │ 节省 CPU │ │
│ └──────┬──────┘ └─────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────┐ │
│ │ 将变化的属性记录到 ChangeMask │ │
│ └─────────────────────────────────────┘ │
│ │
└────────────────────────────────────────────────────────────────────────┘📝 11.4.4 两种轮询策略实现
强制轮询(ForcePoll)
CPP
// 源码位置:ObjectPoller.cpp
void FObjectPoller::ForcePollObject(
FNetRefHandle RefHandle,
FReplicationInstanceProtocol* InstanceProtocol,
FPollAndCopyObjectsParams& Params){
// 获取复制片段列表
const TArray<FReplicationFragment*>& Fragments =
InstanceProtocol->GetFragments();
// 🔍 遍历所有复制片段
for (FReplicationFragment* Fragment : Fragments)
{
// 获取源数据(游戏对象的当前状态)
const uint8* SourceData = Fragment->GetSourceData();
// 获取量化状态(上次发送的状态)
uint8* QuantizedState = Fragment->GetQuantizedState();
// 🎯 比较并复制变化的数据
// 这会更新 ChangeMask 来记录哪些属性变化了
Fragment->PollAndCopy(SourceData, QuantizedState);
}
// 如果有任何属性变化,标记对象为脏
if (InstanceProtocol->HasAnyChanges())
{
Params.DirtyNetObjectTracker.MarkObjectDirty(RefHandle);
}
}Push Model 轮询
CPP
// 源码位置:ObjectPoller.cpp
void FObjectPoller::PushModelPollObject(
FNetRefHandle RefHandle,
FReplicationInstanceProtocol* InstanceProtocol,
FPollAndCopyObjectsParams& Params){
// 🎯 检查对象是否被标记为脏
const bool bIsDirty = Params.DirtyNetObjectTracker.IsObjectDirty(RefHandle);
// 🎯 检查是否到了周期轮询时间
const bool bPollPeriodExpired =
Params.PollFrequencyLimiter.IsPollPeriodExpired(RefHandle);
// 只有脏对象或周期到期的对象才需要轮询
if (!bIsDirty && !bPollPeriodExpired)
{
// ✅ 跳过轮询,节省 CPU
return;
}
// 获取复制片段列表
const TArray<FReplicationFragment*>& Fragments =
InstanceProtocol->GetFragments();
// 🔍 只轮询标记为脏的片段
for (FReplicationFragment* Fragment : Fragments)
{
// 检查片段是否支持 Push Model
if (Fragment->SupportsPushModel())
{
// 获取脏属性掩码
const FChangeMask& DirtyMask = Fragment->GetDirtyMask();
// 只比较脏属性
Fragment->PollAndCopyDirtyProperties(DirtyMask);
}
else
{
// 不支持 Push Model 的片段,强制比较所有属性
Fragment->PollAndCopy();
}
}
}⏱️ 11.5 ObjectPollFrequencyLimiter(轮询频率限制器)
💡 11.5.1 频率限制的目的
FObjectPollFrequencyLimiter 用于控制对象的轮询频率,避免每帧都轮询所有对象。
PLAINTEXT
🎮 日常类比:体检频率
不同人群需要不同的体检频率:
👴 老年人(高风险):每年体检 1 次
→ 对应:重要对象,每帧轮询
👨 中年人(中风险):每 2 年体检 1 次
→ 对应:普通对象,每 2 帧轮询
👶 年轻人(低风险):每 3 年体检 1 次
→ 对应:次要对象,每 4 帧轮询
🎯 ObjectPollFrequencyLimiter 就是这个"体检调度系统":
- 为每个对象设置轮询周期
- 确保重要对象更频繁地被检查
- 分散轮询负载,避免帧率波动🏗️ 11.5.2 核心数据结构
CPP
// 源码位置:ObjectPollFrequencyLimiter.h
class FObjectPollFrequencyLimiter
{
public:
/**
* 🎯 设置对象的轮询周期
*
* @param ObjectIndex - 对象索引
* @param PollPeriod - 轮询周期(帧数),0 = 每帧轮询,255 = 最大周期
*/
void SetPollPeriod(uint32 ObjectIndex, uint8 PollPeriod);
/**
* 🎯 更新轮询状态
*
* 每帧调用,递增帧计数器,确定哪些对象需要轮询
*/
void Update(
const FNetBitArray& DirtyObjects,
FNetBitArray& OutObjectsToCompare
);
private:
// 📊 每个对象的轮询周期(0-255 帧)
TArray<uint8> PollPeriods;
// 📊 每个对象的帧计数器
TArray<uint8> FrameCounters;
// 📊 当前帧号
uint32 CurrentFrame = 0;
};PLAINTEXT
┌────────────────────────────────────────────────────────────────────────┐│ ⏱️ 轮询周期工作原理 │├────────────────────────────────────────────────────────────────────────┤│ ││ 对象 A: PollPeriod = 0 (每帧轮询) ││ ┌─┬─┬─┬─┬─┬─┬─┬─┬─┬─┐ ││ │✓│✓│✓│✓│✓│✓│✓│✓│✓│✓│ 每帧都轮询 ││ └─┴─┴─┴─┴─┴─┴─┴─┴─┴─┘ ││ 0 1 2 3 4 5 6 7 8 9 帧号 ││ ││ 对象 B: PollPeriod = 2 (每 2 帧轮询) ││ ┌─┬─┬─┬─┬─┬─┬─┬─┬─┬─┐ ││ │✓│ │✓│ │✓│ │✓│ │✓│ │ 每 2 帧轮询一次 ││ └─┴─┴─┴─┴─┴─┴─┴─┴─┴─┘ ││ 0 1 2 3 4 5 6 7 8 9 帧号 ││ ││ 对象 C: PollPeriod = 4 (每 4 帧轮询) ││ ┌─┬─┬─┬─┬─┬─┬─┬─┬─┬─┐ ││ │✓│ │ │ │✓│ │ │ │✓│ │ 每 4 帧轮询一次 ││ └─┴─┴─┴─┴─┴─┴─┴─┴─┴─┘ ││ 0 1 2 3 4 5 6 7 8 9 帧号 ││ ││ 💡 脏对象优先:即使未到轮询周期,脏对象也会被轮询 ││ │└────────────────────────────────────────────────────────────────────────┘📊 11.5.3 轮询周期配置建议
对象类型 | 推荐周期 | 说明 |
|---|---|---|
玩家角色 | 0 (每帧) | 最重要,需要实时同步 |
敌人 NPC | 1-2 帧 | 较重要,需要较快响应 |
环境物体 | 4-8 帧 | 不太重要,可以延迟 |
远处物体 | 16-32 帧 | 优先级低,大幅延迟 |
静态物体 | 255 帧 | 几乎不变,极少轮询 |
CPP
// 配置示例:根据对象类型设置轮询周期
void UMyReplicationBridge::ConfigurePollFrequency(AActor* Actor){
uint8 PollPeriod = 0; // 默认每帧
if (ACharacter* Character = Cast<ACharacter>(Actor))
{
if (Character->IsPlayerControlled())
{
PollPeriod = 0; // 玩家:每帧
}
else
{
PollPeriod = 2; // NPC:每 2 帧
}
}
else if (AStaticMeshActor* StaticMesh = Cast<AStaticMeshActor>(Actor))
{
PollPeriod = 255; // 静态物体:极少轮询
}
else
{
// 根据距离动态调整
float Distance = GetDistanceToNearestPlayer(Actor);
if (Distance < 1000.0f)
{
PollPeriod = 1;
}
else if (Distance < 5000.0f)
{
PollPeriod = 4;
}
else
{
PollPeriod = 16;
}
}
PollFrequencyLimiter->SetPollPeriod(
Actor->GetNetRefHandle().GetIndex(),
PollPeriod
);
}🎭 11.6 ChangeMask(变化掩码)
💡 11.6.1 什么是 ChangeMask?
ChangeMask 是一个位数组,用于记录对象的哪些属性发生了变化。
PLAINTEXT
🎮 日常类比:购物清单勾选
想象你有一张购物清单:
┌─────────────────────────────────┐
│ 🛒 购物清单 │
├─────────────────────────────────┤
│ [✓] 牛奶 ← 已购买(变化) │
│ [ ] 面包 ← 未购买(无变化)│
│ [✓] 鸡蛋 ← 已购买(变化) │
│ [ ] 苹果 ← 未购买(无变化)│
│ [✓] 香蕉 ← 已购买(变化) │
└─────────────────────────────────┘
ChangeMask 就是这个勾选状态:
位数组: [1, 0, 1, 0, 1]
牛奶 面包 鸡蛋 苹果 香蕉
只有被勾选(位为1)的属性才需要发送到客户端!🏗️ 11.6.2 核心数据结构
CPP
// 源码位置:ChangeMaskUtil.h
/**
* 🎯 变化掩码存储
*
* 智能存储:小掩码内联存储,大掩码堆分配
*/struct FChangeMaskStorageOrPointer
{
union
{
// 内联存储:最多 64 位(8 字节)
uint64 InlineStorage;
// 指针存储:超过 64 位时使用堆分配
uint32* HeapStorage;
};
// 掩码位数
uint16 BitCount;
// 是否使用堆存储
bool bIsHeapAllocated;
};PLAINTEXT
┌────────────────────────────────────────────────────────────────────────┐│ 🎭 ChangeMask 内存布局 │├────────────────────────────────────────────────────────────────────────┤│ ││ 小对象(≤ 64 个属性):内联存储 ││ ┌────────────────────────────────────────────┐ ││ │ FChangeMaskStorageOrPointer (16 bytes) │ ││ ├────────────────────────────────────────────┤ ││ │ InlineStorage: 0x00000000_0000001F │ ← 直接存储位掩码 ││ │ BitCount: 5 │ ││ │ bIsHeapAllocated: false │ ││ └────────────────────────────────────────────┘ ││ ││ 对应属性:[Health, Mana, Level, XP, Gold] ││ 掩码值: [ 1, 1, 1, 1, 1 ] = 0x1F ││ ││ ───────────────────────────────────────────────────────────────── ││ ││ 大对象(> 64 个属性):堆分配 ││ ┌────────────────────────────────────────────┐ ││ │ FChangeMaskStorageOrPointer (16 bytes) │ ││ ├────────────────────────────────────────────┤ ││ │ HeapStorage: 0x7FFF_1234_5678 ──────────┐ │ ││ │ BitCount: 128 │ │ ││ │ bIsHeapAllocated: true │ │ ││ └──────────────────────────────────────────┼─┘ ││ │ ││ ▼ ││ ┌───────────────────────────┐ ││ │ 堆内存 (16 bytes) │ ││ │ [uint32, uint32, │ ││ │ uint32, uint32] │ ││ └───────────────────────────┘ ││ │└────────────────────────────────────────────────────────────────────────┘📊 11.6.3 ChangeMask 带宽节省示例
PLAINTEXT
┌────────────────────────────────────────────────────────────────────────┐
│ 📊 ChangeMask 带宽节省示例 │
├────────────────────────────────────────────────────────────────────────┤
│ │
│ 角色对象:20 个复制属性 │
│ 本帧只有 Health 和 Mana 变化 │
│ │
│ ❌ 无 ChangeMask(全量发送): │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ [Health][Mana][Level][XP][Gold][Str][Agi][Int][...共20个属性] │ │
│ │ 4字节 4字节 4字节 4字节 4字节 ... │ │
│ │ 总计:约 80 字节 │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │
│ ✅ 有 ChangeMask(增量发送): │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ [ChangeMask: 0x03][Health][Mana] │ │
│ │ 3字节 4字节 4字节 │ │
│ │ 总计:约 11 字节 │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │
│ 📊 带宽节省:(80 - 11) / 80 = 86% 🚀 │
│ │
└────────────────────────────────────────────────────────────────────────┘📋 11.7 总结与最佳实践
🎯 11.7.1 核心概念回顾
PLAINTEXT
┌────────────────────────────────────────────────────────────────────────┐
│ 📋 第十一部分知识点总结 │
├────────────────────────────────────────────────────────────────────────┤
│ │
│ 🔍 脏数据检测 │
│ ├── Poll 模式:系统主动检查,简单可靠但 CPU 开销大 │
│ ├── Push 模式:代码主动通知,高效但需要开发者配合 │
│ └── Iris 混合策略:智能结合两种模式的优点 │
│ │
│ 📊 DirtyNetObjectTracker │
│ ├── AccumulatedDirtyNetObjects:累积脏对象 │
│ ├── DirtyNetObjects:当前帧脏对象 │
│ └── ForceNetUpdateObjects:强制更新对象 │
│ │
│ 📢 Push Model │
│ ├── MARK_PROPERTY_DIRTY_FROM_NAME:标记单个属性 │
│ ├── MARK_PROPERTY_DIRTY_FROM_NAME_STATIC_ARRAY_INDEX:数组元素 │
│ ├── COMPARE_ASSIGN_AND_MARK_PROPERTY_DIRTY:比较后标记 │
│ └── bIsPushBased = true:在 GetLifetimeReplicatedProps 中启用 │
│ │
│ 🔄 ObjectPoller │
│ ├── ForcePollObject:强制比较所有属性 │
│ └── PushModelPollObject:只比较脏属性 │
│ │
│ ⏱️ ObjectPollFrequencyLimiter │
│ ├── 轮询周期:0-255 帧 │
│ └── 脏对象优先:即使未到周期也会轮询 │
│ │
│ 🎭 ChangeMask │
│ ├── 记录哪些属性变化 │
│ ├── 智能存储:小掩码内联,大掩码堆分配 │
│ └── 带宽节省:只发送变化的属性 │
│ │
└────────────────────────────────────────────────────────────────────────┘✅ 11.7.2 最佳实践清单
PLAINTEXT
┌────────────────────────────────────────────────────────────────────────┐
│ ✅ 轮询与脏数据检测最佳实践 │
├────────────────────────────────────────────────────────────────────────┤
│ │
│ 📢 Push Model 使用 │
│ ───────────────────────────────────────────────────────────────── │
│ ✅ 将复制属性设为 private,通过 Setter 访问 │
│ ✅ 在 Setter 中始终调用 MARK_PROPERTY_DIRTY │
│ ✅ 使用 COMPARE_ASSIGN_AND_MARK_PROPERTY_DIRTY 避免无效标记 │
│ ✅ 在 GetLifetimeReplicatedProps 中设置 bIsPushBased = true │
│ ❌ 避免直接访问复制属性 │
│ ❌ 避免持有复制属性的引用 │
│ ❌ 避免在客户端调用 MARK_PROPERTY_DIRTY │
│ │
│ ⏱️ 轮询频率配置 │
│ ───────────────────────────────────────────────────────────────── │
│ ✅ 玩家角色:每帧轮询(PollPeriod = 0) │
│ ✅ 重要 NPC:每 1-2 帧轮询 │
│ ✅ 环境物体:每 4-8 帧轮询 │
│ ✅ 远处物体:每 16-32 帧轮询 │
│ ✅ 根据距离动态调整轮询频率 │
│ │
│ 🎭 ChangeMask 优化 │
│ ───────────────────────────────────────────────────────────────── │
│ ✅ 尽量将属性数量控制在 64 个以内(内联存储) │
│ ✅ 将相关属性分组到结构体中 │
│ ✅ 使用条件复制减少不必要的属性 │
│ │
│ 🔧 调试技巧 │
│ ───────────────────────────────────────────────────────────────── │
│ ✅ 使用 net.Iris.PushModelMode=2 强制轮询进行对比测试 │
│ ✅ 监控脏对象数量和轮询耗时 │
│ ✅ 使用可视化工具查看脏对象分布 │
│ │
└────────────────────────────────────────────────────────────────────────┘📊 11.7.3 性能优化建议
优化策略 | 预期收益 | 实现难度 |
|---|---|---|
启用 Push Model | 🚀🚀🚀 高 | ⭐ 简单 |
合理配置轮询频率 | 🚀🚀 中 | ⭐⭐ 中等 |
使用 COMPARE_ASSIGN | 🚀 低 | ⭐ 简单 |
减少复制属性数量 | 🚀🚀 中 | ⭐⭐⭐ 困难 |
自定义轮询策略 | 🚀🚀🚀 高 | ⭐⭐⭐ 困难 |
本文档基于 Unreal Engine 5.5.0 Iris 源代码分析(源码目录:Engine/Source/Runtime/Experimental/Iris/)
