🚀 Iris 网络复制系统技术分析 - 第十七部分:高级主题

📖 本章导读:当 Iris 的"标准快递服务"无法满足你的特殊需求时,是时候自己开一家定制快递公司了!本章将手把手教你如何扩展 Iris 系统,打造专属的 Filter、Prioritizer 和 Fragment,让网络复制系统完美适配你的游戏需求。无论你是想实现隐身系统、战斗优先级,还是优化千人同服的大型游戏,这里都有你需要的答案!
🎯 17.0 为什么需要"高级定制"?
💡 17.0.1 从"买成品"到"自己造"
PLAINTEXT
🏠 日常类比:装修房子
买精装房 vs 自己装修:
┌─────────────────────────────────────────────────────────────────────┐
│ │
│ 🏠 精装房(Iris 内置组件) 🔧 自己装修(自定义组件) │
│ ═══════════════════════ ═══════════════════════ │
│ │
│ ┌─────────────────┐ ┌─────────────────┐ │
│ │ ✅ 拎包入住 │ │ ✅ 独特风格 │ │
│ │ ✅ 省时省力 │ │ ✅ 完美契合 │ │
│ │ ❌ 风格固定 │ │ ❌ 需要时间 │ │
│ │ ❌ 无法定制 │ │ ❌ 需要技能 │ │
│ └─────────────────┘ └─────────────────┘ │
│ │
│ 适合:90% 的普通需求 适合:10% 的特殊需求 │
│ │
└─────────────────────────────────────────────────────────────────────┘
Iris 内置的 Filter、Prioritizer 就像精装房,能满足大多数需求。
但如果你的游戏有独特的机制(隐身、战争迷雾、特殊优先级),
就需要自己动手"装修"了!🤔 17.0.2 什么时候需要自定义?
PLAINTEXT
🎯 决策树:我需要自定义吗?
你的需求
│
▼
┌───────────────────────┐
│ 内置组件能满足吗? │
└───────────────────────┘
│ │
能满足 │ │ 不能满足
▼ ▼
┌───────────┐ ┌───────────────────┐
│ 直接用! │ │ 能通过配置解决吗? │
│ 别造轮子!│ └───────────────────┘
└───────────┘ │ │
能配置 │ │ 不能配置
▼ ▼
┌───────────┐ ┌───────────────┐
│ 调整配置!│ │ 需要自定义! │
│ 省时省力!│ │ 继续往下看 👇 │
└───────────┘ └───────────────┘
📊 17.0.3 自定义难度等级表
定制类型 | 难度 | 学习时间 | 适用场景 | 风险等级 |
|---|---|---|---|---|
🟢 配置调参 | ⭐ | 1小时 | 90% 的情况 | 低 |
🟡 自定义 Filter | ⭐⭐ | 1-2天 | 特殊可见性规则 | 中 |
🟡 自定义 Prioritizer | ⭐⭐ | 1-2天 | 特殊优先级逻辑 | 中 |
🟠 自定义 Fragment | ⭐⭐⭐ | 3-5天 | 非标准数据复制 | 中高 |
🔴 修改核心系统 | ⭐⭐⭐⭐⭐ | 1周+ | 极端优化需求 | 高 |
PLAINTEXT
💡 建议:
- 新手:先从配置调参开始,熟悉系统后再尝试自定义- 进阶:优先尝试 Filter 和 Prioritizer,它们相对独立- 高手:Fragment 需要深入理解序列化系统,谨慎尝试📂 17.0.4 关键源文件索引
文件 | 路径 | 职责 |
|---|---|---|
|
| Filter 基类定义 |
|
| Prioritizer 基类定义 |
|
| Fragment 基类定义 |
|
| 空间过滤器参考实现 |
|
| 球形优先级器参考实现 |
🔍 17.1 自定义 Filter:打造你的"VIP 通道"
💡 17.1.1 Filter 是什么?
PLAINTEXT
🎭 日常类比:演唱会的安检口
Filter 就像演唱会入口的安检系统——决定谁能进场、谁被拦在门外。
┌─────────────────────────────────────────────────────────────────────┐
│ 🎤 演唱会入场流程 │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ 观众排队 │
│ 👤👤👤👤👤👤👤👤 │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────┐ │
│ │ 🚪 安检口(Filter) │ │
│ │ │ │
│ │ 检查项目: │ │
│ │ ✅ 有票?→ 放行 │ │
│ │ ✅ VIP票?→ VIP通道 │ │
│ │ ❌ 没票?→ 拒绝入场 │ │
│ │ ❌ 黑名单?→ 拒绝入场 │ │
│ │ │ │
│ └─────────────────────────────────────────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌─────────┐ ┌─────────┐ │
│ │ 🎉 入场 │ │ 🚫 拒绝 │ │
│ └─────────┘ └─────────┘ │
│ │
│ 在网络复制中: │
│ - 观众 = 游戏对象 │
│ - 安检口 = Filter │
│ - 入场 = 对象对该玩家可见 │
│ - 拒绝 = 对象对该玩家不可见(被过滤) │
│ │
└─────────────────────────────────────────────────────────────────────┘🎯 17.1.2 什么时候需要自定义 Filter?
📖 真实案例:小明的"隐身斗篷"困境
PLAINTEXT
🧙 游戏需求:魔法对战游戏的隐身系统
小明在做一款魔法对战游戏,需要实现隐身术:
隐身规则:
┌─────────────────────────────────────────────────────────────────────┐
│ 隐身等级 │ 敌人能看到? │ 队友能看到? │ 自己能看到? │
├─────────────────────────────────────────────────────────────────────┤
│ 等级 0 │ ✅ 是 │ ✅ 是 │ ✅ 是 │
│ (正常) │ │ │ │
├─────────────────────────────────────────────────────────────────────┤
│ 等级 1 │ ❌ 否 │ ✅ 是 │ ✅ 是 │
│(轻度隐身)│ 除非有真视 │ │ │
├─────────────────────────────────────────────────────────────────────┤
│ 等级 2 │ ❌ 否 │ ❌ 否 │ ✅ 是 │
│(完全隐身)│ 除非有真视 │ 除非有真视 │ │
└─────────────────────────────────────────────────────────────────────┘
问题:Iris 内置的 Filter 都不支持这种复杂的可见性逻辑!
内置 Filter 的局限:
- GridFilter:只考虑距离,不考虑隐身状态- ConnectionFilter:只考虑连接关系,不考虑游戏状态- GroupFilter:只考虑组归属,不够灵活
结论:需要自定义 InvisibilityFilter!📊 需要自定义 Filter 的典型场景
场景 | 为什么内置不够用 | 解决方案 |
|---|---|---|
🧙 隐身系统 | 需要根据技能状态动态过滤 | 自定义 |
🌫️ 战争迷雾 | 需要结合游戏地图探索数据 | 自定义 |
👁️ 侦察技能 | 临时"看穿"某些隐藏对象 | 自定义 |
🏰 阵营系统 | 复杂的敌我中立关系判定 | 自定义 |
🚪 副本隔离 | 同地图不同副本实例隔离 | 自定义 |
🎭 观战模式 | 观战者能看到所有/部分信息 | 自定义 |
🏗️ 17.1.3 Filter 工作原理揭秘
💡 Filter 生命周期
PLAINTEXT
🍽️ 日常类比:餐厅订座系统
Filter 的工作流程就像餐厅的订座系统:
┌─────────────────────────────────────────────────────────────────────┐
│ 🍽️ 餐厅订座流程 │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ 1️⃣ 客人打电话预约 │
│ ════════════════ │
│ 📞 "我想订位" │
│ │ │
│ ▼ │
│ AddObject() ← 对象"报到",Filter 记录它的信息 │
│ │
│ 2️⃣ 餐厅每天检查预约 │
│ ════════════════ │
│ 📋 检查今天有哪些预约 │
│ │ │
│ ▼ │
│ PreFilter() ← 每帧开始前的准备工作 │
│ │ │
│ ▼ │
│ Filter() ← 核心!决定每个客人能否入座 │
│ │ │
│ ▼ │
│ PostFilter() ← 每帧结束后的清理工作 │
│ │
│ 3️⃣ 客人取消预约 │
│ ════════════════ │
│ 📞 "我不来了" │
│ │ │
│ ▼ │
│ RemoveObject() ← 对象"告别",Filter 清除记录 │
│ │
└─────────────────────────────────────────────────────────────────────┘🔄 Filter 接口详解
CPP
// 源码位置:NetObjectFilter.h
class UNetObjectFilter : public UObject
{
public:
//========================================
// 🎯 初始化接口
//========================================
/**
* 初始化 Filter
* 调用时机:ReplicationSystem 创建时
* 职责:分配内部数据结构、读取配置
*/
virtual void Init(FNetObjectFilterInitParams& Params);
//========================================
// 📦 对象管理接口
//========================================
/**
* 添加对象到 Filter
* 调用时机:对象开始参与网络复制时
* 职责:记录对象信息,初始化过滤状态
* 返回值:true = 成功添加,false = 拒绝添加
*/
virtual bool AddObject(uint32 ObjectIndex, FNetObjectFilterAddObjectParams& Params);
/**
* 从 Filter 移除对象
* 调用时机:对象停止网络复制时(销毁、休眠等)
* 职责:清理所有与该对象相关的数据
*/
virtual void RemoveObject(uint32 ObjectIndex, const FNetObjectFilteringInfo& Info);
//========================================
// 🔍 过滤接口(核心!)
//========================================
/**
* 过滤前准备
* 调用时机:每帧过滤开始前
* 职责:批量准备工作,如更新缓存、预计算等
*/
virtual void PreFilter(FNetObjectPreFilteringParams& Params);
/**
* 执行过滤(最重要的方法!)
* 调用时机:每帧,对每个需要过滤的对象
* 职责:决定每个对象对每个连接是否可见
*/
virtual void Filter(FNetObjectFilteringParams& Params);
/**
* 过滤后清理
* 调用时机:每帧过滤结束后
* 职责:清理临时数据,更新统计等
*/
virtual void PostFilter(FNetObjectPostFilteringParams& Params);
//========================================
// 🔌 连接管理接口
//========================================
/**
* 添加连接
* 调用时机:新玩家加入游戏时
* 职责:为新连接初始化过滤数据
*/
virtual bool AddConnection(uint32 ConnectionId);
/**
* 移除连接
* 调用时机:玩家离开游戏时
* 职责:清理该连接相关的所有数据
*/
virtual void RemoveConnection(uint32 ConnectionId);
};📊 Filter 接口调用时序
PLAINTEXT
┌─────────────────────────────────────────────────────────────────────┐
│ 🔄 Filter 接口调用时序图 │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ 游戏启动 │
│ │ │
│ ▼ │
│ Init() ───────────────────────────────────────────────────────── │
│ │ │
│ │ [玩家加入] │
│ ├──────────► AddConnection(ConnectionId) │
│ │ │
│ │ [对象创建] │
│ ├──────────► AddObject(ObjectIndex) │
│ │ │
│ │ ┌─────────────────────────────────────┐ │
│ │ │ 每帧循环 │ │
│ │ │ │ │
│ │ │ PreFilter() │ │
│ │ │ │ │ │
│ │ │ ▼ │ │
│ │ │ Filter() ← 核心过滤逻辑 │ │
│ │ │ │ │ │
│ │ │ ▼ │ │
│ │ │ PostFilter() │ │
│ │ │ │ │
│ │ └─────────────────────────────────────┘ │
│ │ │
│ │ [对象销毁] │
│ ├──────────► RemoveObject(ObjectIndex) │
│ │ │
│ │ [玩家离开] │
│ └──────────► RemoveConnection(ConnectionId) │
│ │
└─────────────────────────────────────────────────────────────────────┘🛠️ 17.1.4 手把手实现:隐身系统过滤器
📋 需求分析
PLAINTEXT
🧙 隐身系统完整需求:
1. 隐身等级规则:
- 等级 0:正常状态,所有人可见
- 等级 1:轻度隐身,敌人不可见,队友可见
- 等级 2:完全隐身,只有自己可见
2. 真视技能:
- 拥有"真视"技能的玩家可以看穿隐身
- 配置项:真视是否能看穿所有等级
3. 性能要求:
- 支持 100+ 隐身对象
- 支持 64 个玩家连接
- 每帧过滤时间 < 0.5ms📝 第一步:定义 Filter 类(头文件)
CPP
// InvisibilityNetObjectFilter.h// 🧙 隐身系统过滤器——让魔法师真正"消失"
#pragma once
#include "Iris/ReplicationSystem/Filtering/NetObjectFilter.h"#include "InvisibilityNetObjectFilter.generated.h"
/**
* 隐身过滤器:根据隐身等级决定对象可见性
*
* 💡 设计思路(类比):
* 想象一个魔法世界的"视觉系统":
* - 普通人只能看到没隐身的人
* - 队友有"心灵感应",能看到轻度隐身的队友
* - 拥有"真视之眼"的人能看穿一切隐身
*/UCLASS()
class MYGAME_API UInvisibilityNetObjectFilter : public UNetObjectFilter
{
GENERATED_BODY()
public:
//========================================
// 🎯 配置属性
//========================================
/**
* 真视技能是否能看穿所有隐身等级
* true = 真视无敌,能看穿等级2隐身
* false = 真视只能看穿等级1隐身
*/
UPROPERTY(Config)
bool bTrueSightSeesAll = true;
protected:
//========================================
// 🔧 Filter 接口实现
//========================================
virtual void Init(FNetObjectFilterInitParams& Params) override;
virtual bool AddObject(uint32 ObjectIndex, FNetObjectFilterAddObjectParams& Params) override;
virtual void RemoveObject(uint32 ObjectIndex, const FNetObjectFilteringInfo& Info) override;
virtual void Filter(FNetObjectFilteringParams& Params) override;
virtual bool AddConnection(uint32 ConnectionId) override;
virtual void RemoveConnection(uint32 ConnectionId) override;
public:
//========================================
// 🎮 游戏逻辑接口
//========================================
/** 更新对象的隐身等级 */
UFUNCTION(BlueprintCallable, Category = "Invisibility")
void UpdateObjectInvisibility(uint32 ObjectIndex, int32 InvisibilityLevel);
/** 更新玩家的真视状态 */
UFUNCTION(BlueprintCallable, Category = "Invisibility")
void UpdatePlayerTrueSight(uint32 ConnectionId, bool bHasTrueSight);
/** 设置对象的队伍ID */
UFUNCTION(BlueprintCallable, Category = "Invisibility")
void SetObjectTeam(uint32 ObjectIndex, int32 TeamId);
/** 设置玩家的队伍ID */
UFUNCTION(BlueprintCallable, Category = "Invisibility")
void SetPlayerTeam(uint32 ConnectionId, int32 TeamId);
private:
//========================================
// 📊 内部数据结构
//========================================
/** 对象隐身等级表 (只存储隐身的对象,节省内存) */
TMap<uint32, int32> ObjectInvisibilityLevels;
/** 对象所属队伍表 */
TMap<uint32, int32> ObjectTeams;
/** 玩家真视状态表 */
TMap<uint32, bool> PlayerTrueSightStatus;
/** 玩家所属队伍表 */
TMap<uint32, int32> PlayerTeams;
/** 玩家对应的对象索引 (用于判断"自己") */
TMap<uint32, uint32> PlayerObjectIndices;
/** 最大支持的连接数 */
uint32 MaxConnectionCount = 0;
};📝 第二步:实现核心逻辑(源文件)
CPP
// InvisibilityNetObjectFilter.cpp
#include "InvisibilityNetObjectFilter.h"
void UInvisibilityNetObjectFilter::Init(FNetObjectFilterInitParams& Params){
MaxConnectionCount = Params.MaxConnectionCount;
// 💡 性能优化:预分配内存
ObjectInvisibilityLevels.Reserve(256);
ObjectTeams.Reserve(1024);
PlayerTrueSightStatus.Reserve(MaxConnectionCount);
PlayerTeams.Reserve(MaxConnectionCount);
}
bool UInvisibilityNetObjectFilter::AddObject(
uint32 ObjectIndex,
FNetObjectFilterAddObjectParams& Params){
// 获取对象的初始队伍和隐身状态
// 实际实现需要从游戏对象获取这些信息
ObjectTeams.Add(ObjectIndex, -1); // 默认无队伍
return true;
}
void UInvisibilityNetObjectFilter::RemoveObject(
uint32 ObjectIndex,
const FNetObjectFilteringInfo& Info){
// 🧹 清理所有相关数据
ObjectInvisibilityLevels.Remove(ObjectIndex);
ObjectTeams.Remove(ObjectIndex);
// 清理玩家-对象映射
for (auto It = PlayerObjectIndices.CreateIterator(); It; ++It)
{
if (It.Value() == ObjectIndex)
{
It.RemoveCurrent();
break;
}
}
}
void UInvisibilityNetObjectFilter::Filter(FNetObjectFilteringParams& Params){
/**
* 🎯 核心过滤逻辑
*
* 对于每个对象,判断每个玩家能否看到它
*/
// 🚀 性能优化:如果没有隐身对象,直接返回
if (ObjectInvisibilityLevels.Num() == 0)
{
return;
}
// 遍历需要过滤的对象
for (uint32 ObjectIndex : Params.ObjectIndices)
{
// 获取隐身等级
const int32* InvisLevelPtr = ObjectInvisibilityLevels.Find(ObjectIndex);
const int32 InvisLevel = InvisLevelPtr ? *InvisLevelPtr : 0;
// 等级0:所有人可见,跳过
if (InvisLevel == 0)
{
continue;
}
// 获取对象队伍
const int32 ObjectTeam = ObjectTeams.FindRef(ObjectIndex);
// 遍历每个连接
for (uint32 ConnId = 0; ConnId < MaxConnectionCount; ++ConnId)
{
bool bShouldFilter = false; // true = 不可见
// 检查是否是自己
if (const uint32* PlayerObjIdx = PlayerObjectIndices.Find(ConnId))
{
if (*PlayerObjIdx == ObjectIndex)
{
continue; // 自己永远可见
}
}
// 检查真视
const bool bHasTrueSight = PlayerTrueSightStatus.FindRef(ConnId);
if (bHasTrueSight && bTrueSightSeesAll)
{
continue; // 真视无敌
}
// 根据隐身等级判断
if (InvisLevel == 1)
{
// 等级1:检查是否队友
const int32 PlayerTeam = PlayerTeams.FindRef(ConnId);
bShouldFilter = (PlayerTeam != ObjectTeam || ObjectTeam == -1);
// 真视可以看穿等级1
if (bHasTrueSight) bShouldFilter = false;
}
else if (InvisLevel == 2)
{
// 等级2:只有真视(无敌模式)可见
bShouldFilter = !bHasTrueSight || !bTrueSightSeesAll;
}
// 设置过滤结果
if (bShouldFilter)
{
Params.OutFilteredOutObjects[ConnId].SetBit(ObjectIndex, true);
}
}
}
}
bool UInvisibilityNetObjectFilter::AddConnection(uint32 ConnectionId){
PlayerTrueSightStatus.Add(ConnectionId, false);
PlayerTeams.Add(ConnectionId, -1);
return true;
}
void UInvisibilityNetObjectFilter::RemoveConnection(uint32 ConnectionId){
PlayerTrueSightStatus.Remove(ConnectionId);
PlayerTeams.Remove(ConnectionId);
PlayerObjectIndices.Remove(ConnectionId);
}
void UInvisibilityNetObjectFilter::UpdateObjectInvisibility(
uint32 ObjectIndex, int32 InvisibilityLevel){
if (InvisibilityLevel > 0)
{
ObjectInvisibilityLevels.Add(ObjectIndex, InvisibilityLevel);
}
else
{
ObjectInvisibilityLevels.Remove(ObjectIndex);
}
}
void UInvisibilityNetObjectFilter::UpdatePlayerTrueSight(
uint32 ConnectionId, bool bHasTrueSight){
PlayerTrueSightStatus.Add(ConnectionId, bHasTrueSight);
}
void UInvisibilityNetObjectFilter::SetObjectTeam(uint32 ObjectIndex, int32 TeamId){
ObjectTeams.Add(ObjectIndex, TeamId);
}
void UInvisibilityNetObjectFilter::SetPlayerTeam(uint32 ConnectionId, int32 TeamId){
PlayerTeams.Add(ConnectionId, TeamId);
}📝 第三步:注册和配置
INI
; DefaultGame.ini
[/Script/IrisCore.ObjectReplicationBridgeConfig]; 为隐身角色类配置自定义 Filter
+FilterConfigs=(ClassName="/Script/MyGame.InvisibleCharacter", FilterName="InvisibilityFilter")
[/Script/IrisCore.NetObjectFilterConfig]; 定义 Filter 类型
+FilterDefinitions=(FilterName="InvisibilityFilter", FilterClassName="/Script/MyGame.UInvisibilityNetObjectFilter")📝 第四步:在游戏代码中使用
CPP
// MyCharacter.cpp
void AMyCharacter::ActivateInvisibility(int32 Level){
CurrentInvisibilityLevel = Level;
// 🎯 通知 Filter 更新
if (UIrisReplicationSystem* IrisSystem = GetIrisReplicationSystem())
{
FNetRefHandle Handle = IrisSystem->GetNetRefHandle(this);
uint32 ObjectIndex = Handle.GetObjectIndex();
if (auto* Filter = Cast<UInvisibilityNetObjectFilter>(
IrisSystem->GetFilter("InvisibilityFilter")))
{
Filter->UpdateObjectInvisibility(ObjectIndex, Level);
}
}
// 强制立即网络更新
ForceNetUpdate();
}⚡ 17.1.5 性能优化技巧
PLAINTEXT
🚀 Filter 性能优化三板斧
┌─────────────────────────────────────────────────────────────────────┐
│ │
│ 1️⃣ 早期退出(Early Out) │
│ ════════════════════════ │
│ │
│ if (ObjectInvisibilityLevels.Num() == 0) │
│ { │
│ return; // 没有隐身对象,直接返回! │
│ } │
│ │
│ 效果:无隐身时,耗时从 0.8ms → 0.01ms (80倍提升!) │
│ │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ 2️⃣ 批量处理(Batch Processing) │
│ ════════════════════════════════ │
│ │
│ // ❌ 低效:逐个处理 │
│ for (Object) { for (Connection) { ... } } │
│ │
│ // ✅ 高效:按连接批量处理 │
│ for (Connection) │
│ { │
│ // 预先获取该连接的所有数据 │
│ int32 PlayerTeam = PlayerTeams[Connection]; │
│ bool bHasTrueSight = PlayerTrueSightStatus[Connection]; │
│ │
│ for (Object) { ... } // 批量处理 │
│ } │
│ │
│ 效果:耗时从 2.5ms → 0.8ms (3倍提升) │
│ │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ 3️⃣ 缓存计算结果 │
│ ════════════════ │
│ │
│ // 对于不常变化的数据,缓存计算结果 │
│ struct FCachedVisibility │
│ { │
│ int32 TeamId; │
│ bool bHasTrueSight; │
│ uint32 LastUpdateFrame; │
│ }; │
│ │
│ 效果:耗时从 0.8ms → 0.5ms (1.6倍提升) │
│ │
└─────────────────────────────────────────────────────────────────────┘🐛 17.1.6 常见问题与解决方案
问题 | 症状 | 原因 | 解决方案 |
|---|---|---|---|
🔄 对象闪烁 | 隐身对象时隐时现 | Filter 结果不稳定 | 添加滞后机制(Hysteresis) |
⏰ 更新延迟 | 隐身后敌人还能看到 | 状态更新不同步 | 调用 |
💾 内存泄漏 | 长时间运行内存增长 |
| 检查所有 Map 的清理 |
🐌 性能差 | 过滤耗时过长 | 未使用批量处理 | 重构为批量操作 |
⚡ 17.2 自定义 Prioritizer:打造你的"VIP 排队系统"
💡 17.2.1 Prioritizer 是什么?
PLAINTEXT
🏥 日常类比:医院的分诊台
Prioritizer 就像医院的分诊系统——决定谁先看病。
┌─────────────────────────────────────────────────────────────────────┐
│ 🏥 医院分诊流程 │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ 患者排队 │
│ 🤒🤕😷🤧🤢 │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────┐ │
│ │ 🏥 分诊台(Prioritizer) │ │
│ │ │ │
│ │ 优先级规则: │ │
│ │ 🚨 急诊(心脏病)→ 优先级 1.0 │ │
│ │ 👴 老人/孕妇 → 优先级 0.8 │ │
│ │ 💳 VIP 会员 → 优先级 0.7 │ │
│ │ 👤 普通患者 → 优先级 0.5 │ │
│ │ 🦷 小病小痛 → 优先级 0.3 │ │
│ │ │ │
│ └─────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ 按优先级排序后叫号:🚨 → 👴 → 💳 → 👤 → 🦷 │
│ │
│ 在网络复制中: │
│ - 患者 = 游戏对象 │
│ - 分诊台 = Prioritizer │
│ - 优先级 = 复制顺序(高优先级先复制) │
│ - 带宽有限 = 医生数量有限(不能同时看所有人) │
│ │
└─────────────────────────────────────────────────────────────────────┘🎯 17.2.2 什么时候需要自定义 Prioritizer?
📖 真实案例:小红的"吃鸡"困境
PLAINTEXT
🎮 游戏需求:大逃杀游戏的优先级系统
小红在做一款大逃杀游戏,100 个玩家同时在线:
问题:带宽有限,不能同时更新所有人的数据
需求:
┌─────────────────────────────────────────────────────────────────────┐
│ 对象类型 │ 期望优先级 │ 原因 │
├─────────────────────────────────────────────────────────────────────┤
│ 正在攻击我的敌人 │ 最高 │ 生死攸关!必须第一时间知道 │
│ 我正在攻击的敌人 │ 很高 │ 需要准确的命中判定 │
│ 视野内的敌人 │ 高 │ 可能随时开火 │
│ 队友 │ 中高 │ 需要配合 │
│ 远处的敌人 │ 低 │ 暂时威胁不大 │
│ 地上的物品 │ 最低 │ 不会动,不着急 │
└─────────────────────────────────────────────────────────────────────┘
问题:内置的 SphereNetObjectPrioritizer 只考虑距离,不考虑"战斗状态"!
结论:需要自定义 CombatPrioritizer!📊 需要自定义 Prioritizer 的典型场景
场景 | 为什么内置不够用 | 解决方案 |
|---|---|---|
🔫 战斗优先 | 需要结合战斗系统数据 | 自定义 |
🎯 目标锁定 | 锁定目标需最高优先级 | 自定义 |
📢 语音聊天 | 说话的人优先级提升 | 自定义 |
🏆 任务相关 | 任务目标 NPC 优先 | 自定义 |
🎪 表演系统 | 表演者优先级最高 | 自定义 |
🏗️ 17.2.3 Prioritizer 工作原理
PLAINTEXT
📊 优先级值的含义
优先级范围:0.0 ~ 1.0
0.0 ──────────────────────────────────────── 1.0
│ │
最低优先级 最高优先级
(可能不复制) (优先复制)
💡 特殊值:
- 0.0:完全不复制(相当于被过滤)- 1.0:最高优先级,必须复制- 累积机制:每帧未复制的对象,优先级会累积增加🛠️ 17.2.4 手把手实现:战斗优先级器
CPP
// CombatNetObjectPrioritizer.h
UCLASS()
class UCombatNetObjectPrioritizer : public USphereNetObjectPrioritizer
{
GENERATED_BODY()
public:
/** 正在攻击我的敌人加成 */
UPROPERTY(Config)
float AttackingMeBoost = 0.3f;
/** 我正在攻击的敌人加成 */
UPROPERTY(Config)
float MyTargetBoost = 0.2f;
/** 队友加成 */
UPROPERTY(Config)
float TeammateBoost = 0.15f;
/** 目标锁定加成 */
UPROPERTY(Config)
float TargetLockBoost = 0.4f;
protected:
virtual void Prioritize(FNetObjectPrioritizationParams& Params) override;
public:
/** 记录攻击事件 */
void RecordAttack(uint32 AttackerIndex, uint32 VictimIndex);
/** 设置目标锁定 */
void SetTargetLock(uint32 ConnectionId, uint32 TargetIndex);
private:
/** 战斗关系表 <Attacker, Victim> -> 最后攻击时间 */
TMap<TPair<uint32, uint32>, double> CombatRelations;
/** 目标锁定表 ConnectionId -> TargetIndex */
TMap<uint32, uint32> TargetLocks;
};
// CombatNetObjectPrioritizer.cpp
void UCombatNetObjectPrioritizer::Prioritize(FNetObjectPrioritizationParams& Params){
// 第一步:调用父类计算基础距离优先级
Super::Prioritize(Params);
// 第二步:叠加战斗加成
const double CurrentTime = FPlatformTime::Seconds();
for (uint32 ConnectionId : Params.ConnectionIds)
{
TArrayView<float> Priorities = Params.OutPriorities[ConnectionId];
uint32 PlayerObjectIndex = GetPlayerObjectIndex(ConnectionId);
for (int32 i = 0; i < Params.ObjectIndices.Num(); ++i)
{
uint32 ObjectIndex = Params.ObjectIndices[i];
float& Priority = Priorities[i];
if (Priority <= 0.0f) continue; // 已被过滤
float Boost = 0.0f;
// 检查:对象是否正在攻击我?
auto AttackingMeKey = MakeTuple(ObjectIndex, PlayerObjectIndex);
if (CombatRelations.Contains(AttackingMeKey))
{
Boost += AttackingMeBoost;
}
// 检查:我是否正在攻击对象?
auto MyTargetKey = MakeTuple(PlayerObjectIndex, ObjectIndex);
if (CombatRelations.Contains(MyTargetKey))
{
Boost += MyTargetBoost;
}
// 检查:是否是锁定目标?
if (const uint32* LockedTarget = TargetLocks.Find(ConnectionId))
{
if (*LockedTarget == ObjectIndex)
{
Boost += TargetLockBoost;
}
}
// 叠加加成(确保不超过 1.0)
Priority = FMath::Min(1.0f, Priority + Boost);
}
}
}🤝 17.2.5 Filter 和 Prioritizer 的协作
PLAINTEXT
🔄 数据包发送流程
┌─────────────────────────────────────────────────────────────────────┐
│ │
│ 所有游戏对象 │
│ 📦📦📦📦📦📦📦📦📦📦 │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────┐ │
│ │ 🔍 Filter 过滤 │ │
│ │ 决定"谁能进场" │ │
│ └─────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ 通过过滤的对象 │
│ 📦📦📦📦📦 │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────┐ │
│ │ ⚡ Prioritizer 排序 │ │
│ │ 决定"谁先服务" │ │
│ └─────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ 按优先级排序的对象 │
│ 📦(0.9) → 📦(0.7) → 📦(0.5) → 📦(0.3) → 📦(0.1) │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────┐ │
│ │ 📡 带宽限制 │ │
│ │ 只发送前 N 个 │ │
│ └─────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ 实际发送的对象 │
│ 📦(0.9) → 📦(0.7) → 📦(0.5) │
│ │
└─────────────────────────────────────────────────────────────────────┘🧩 17.3 自定义 ReplicationFragment:打造你的"特殊快递包裹"
💡 17.3.1 Fragment 是什么?
PLAINTEXT
📦 日常类比:快递的包装方式
Fragment 就像快递的"包装方式"——不同的物品需要不同的包装。
┌─────────────────────────────────────────────────────────────────────┐
│ 📦 快递包装类比 │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ 物品类型 包装方式 对应 Fragment │
│ ───────── ───────── ───────────── │
│ │
│ 📱 手机 → 🧊 防震泡沫 → PropertyFragment │
│ (普通属性) (标准包装) (标准属性复制) │
│ │
│ 🍷 红酒 → 📦 特制酒盒 → 自定义 Fragment │
│ (易碎品) (特殊包装) (特殊数据复制) │
│ │
│ 📚 书籍(多本) → 📦 批量打包 → FastArrayFragment │
│ (数组数据) (数组包装) (快速数组复制) │
│ │
│ 💡 核心思想: │
│ - 不同的数据结构需要不同的"包装方式" │
│ - Fragment 定义了数据如何被收集、序列化、传输、应用 │
│ │
└─────────────────────────────────────────────────────────────────────┘🎯 17.3.2 什么时候需要自定义 Fragment?
PLAINTEXT
🎮 真实案例:小刚的"技能系统"困境
小刚在做一款 MOBA 游戏:
- 每个英雄有 4 个技能- 每个技能有:冷却时间、充能数、等级、是否可用- 技能数据存在自定义的技能系统中,不是 UPROPERTY
问题:Iris 默认只能复制 UPROPERTY!
解决方案:自定义 SkillReplicationFragment场景 | 为什么需要自定义 | 解决方案 |
|---|---|---|
🎮 技能系统 | 数据不在 UPROPERTY 中 | 自定义 |
📦 背包系统 | 复杂的嵌套数据结构 | 自定义 |
🗺️ 地图数据 | 大量动态生成的数据 | 自定义 |
🎨 外观系统 | 需要特殊的压缩方式 | 自定义 |
🏗️ 17.3.3 Fragment 工作原理
PLAINTEXT
📦 Fragment 生命周期
┌─────────────────────────────────────────────────────────────────────┐
│ │
│ 发送方(服务器) 接收方(客户端) │
│ ═══════════════ ═══════════════ │
│ │
│ 1️⃣ CollectPropertyData() │
│ 从游戏对象收集数据 │
│ │ │
│ ▼ │
│ 2️⃣ PollPropertyData() │
│ 检测哪些数据变了 │
│ │ │
│ ▼ │
│ 3️⃣ Serialize() │
│ 序列化成字节流 │
│ │ │
│ │ ════════════════════════════► │
│ │ 网络传输 │
│ │ │
│ 4️⃣ Deserialize() │
│ 反序列化字节流 │
│ │ │
│ ▼ │
│ 5️⃣ ApplyPropertyData() │
│ 应用到游戏对象 │
│ │ │
│ ▼ │
│ 6️⃣ CallRepNotifies() │
│ 触发回调通知 │
│ │
└─────────────────────────────────────────────────────────────────────┘🛠️ 17.3.4 简化示例:技能系统 Fragment
CPP
// SkillReplicationFragment.h
/** 单个技能的数据 */USTRUCT()
struct FReplicatedSkillData
{
GENERATED_BODY()
UPROPERTY()
float CooldownRemaining = 0.0f;
UPROPERTY()
uint8 CurrentCharges = 0;
UPROPERTY()
uint8 SkillLevel = 1;
UPROPERTY()
bool bIsAvailable = true;
};
UCLASS()
class USkillReplicationFragment : public UReplicationFragment
{
GENERATED_BODY()
public:
/** 绑定到技能组件 */
void BindToSkillComponent(class USkillComponent* InSkillComponent);
protected:
virtual void CollectPropertyData(FReplicationStateCollectParams& Params) override;
virtual EPollPropertyDataResult PollPropertyData(FReplicationStatePollParams& Params) override;
virtual void ApplyPropertyData(FReplicationStateApplyParams& Params) override;
private:
TWeakObjectPtr<class USkillComponent> SkillComponent;
FReplicatedSkillData CurrentSkillData[4];
FReplicatedSkillData LastSentSkillData[4];
};
// SkillReplicationFragment.cpp
void USkillReplicationFragment::CollectPropertyData(FReplicationStateCollectParams& Params){
if (!SkillComponent.IsValid()) return;
// 从技能组件读取数据
for (int32 i = 0; i < 4; ++i)
{
if (const FSkillInstance* Skill = SkillComponent->GetSkill(i))
{
CurrentSkillData[i].CooldownRemaining = Skill->GetCooldownRemaining();
CurrentSkillData[i].CurrentCharges = Skill->GetCurrentCharges();
CurrentSkillData[i].SkillLevel = Skill->GetLevel();
CurrentSkillData[i].bIsAvailable = Skill->IsAvailable();
}
}
}
EPollPropertyDataResult USkillReplicationFragment::PollPropertyData(FReplicationStatePollParams& Params){
CollectPropertyData(Params);
// 检查是否有变化
for (int32 i = 0; i < 4; ++i)
{
if (CurrentSkillData[i] != LastSentSkillData[i])
{
return EPollPropertyDataResult::Dirty;
}
}
return EPollPropertyDataResult::Clean;
}
void USkillReplicationFragment::ApplyPropertyData(FReplicationStateApplyParams& Params){
if (!SkillComponent.IsValid()) return;
// 应用数据到技能组件
for (int32 i = 0; i < 4; ++i)
{
if (FSkillInstance* Skill = SkillComponent->GetSkillMutable(i))
{
Skill->SetCooldownRemaining(CurrentSkillData[i].CooldownRemaining);
Skill->SetCurrentCharges(CurrentSkillData[i].CurrentCharges);
Skill->SetLevel(CurrentSkillData[i].SkillLevel);
Skill->SetAvailable(CurrentSkillData[i].bIsAvailable);
}
}
}🌍 17.4 大规模多人游戏优化:当 1000 人同时在线
💡 17.4.1 大规模游戏面临的挑战
PLAINTEXT
📊 数字会说话
假设:1000 人同时在线的大逃杀游戏
每个玩家需要知道的信息:┌─────────────────────────────────────────────────────────────────────┐│ 数据类型 │ 数量 │ 每个大小 │ 总计 │├─────────────────────────────────────────────────────────────────────┤│ 其他玩家 │ 999 │ 100 字节 │ 99,900 字节 ││ 可拾取物品 │ 5000 │ 20 字节 │ 100,000 字节 ││ 载具 │ 500 │ 50 字节 │ 25,000 字节 ││ 建筑状态 │ 1000 │ 10 字节 │ 10,000 字节 │├─────────────────────────────────────────────────────────────────────┤│ 总计/帧 │ │ │ ~235,000 字节 ││ × 60帧 │ │ │ ~14 MB/秒 │└─────────────────────────────────────────────────────────────────────┘
💀 如果不优化:服务器爆炸,玩家卡成 PPT!🚀 17.4.2 优化策略一:空间分区
PLAINTEXT
🏙️ 日常类比:城市分区管理
┌─────────────────────────────────────────────────────────────────────┐
│ │
│ ❌ 不优化:市长管理全市 1000 万人 │
│ ═══════════════════════════════════ │
│ ┌─────────────────────────────────┐ │
│ │ 🏛️ 市政府 │ │
│ │ 管理 1000 万人的所有事务 │ ← 累死! │
│ └─────────────────────────────────┘ │
│ │
│ ✅ 优化后:每个区长管理自己区的 10 万人 │
│ ═══════════════════════════════════════ │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │ 🏢 A区 │ │ 🏢 B区 │ │ 🏢 C区 │ │
│ │ 10万人 │ │ 10万人 │ │ 10万人 │ ← 各管各的! │
│ └─────────┘ └─────────┘ └─────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────┘
实现:使用 NetObjectGridFilter
- 把地图分成 100×100 的网格- 每个玩家只接收附近 9 个网格内的对象- 对象数量从 1000 降到 ~100INI
; 配置空间网格过滤[/Script/IrisCore.NetObjectGridFilterConfig]CellSizeX=10000.0 ; 100米一个格子CellSizeY=10000.0ViewDistance=3 ; 可见范围:3个格子 = 300米🚀 17.4.3 优化策略二:自适应更新频率
PLAINTEXT
💓 日常类比:心跳监测
┌─────────────────────────────────────────────────────────────────────┐
│ │
│ ❌ 不优化:所有人每秒检测 60 次心跳 │
│ │
│ ✅ 优化后:根据状态调整频率 │
│ │
│ 状态 │ 更新频率 │ 原因 │
│ ───────────────────────────────────────────────────── │
│ 🏃 运动中 │ 60 Hz │ 位置快速变化 │
│ 🧍 静止 │ 10 Hz │ 位置不变,降低频率 │
│ 🌄 远处 │ 2 Hz │ 不重要,偶尔更新 │
│ 😴 休眠 │ 0 Hz │ 完全停止更新 │
│ │
└─────────────────────────────────────────────────────────────────────┘🚀 17.4.4 优化策略三:增量压缩
PLAINTEXT
📹 日常类比:视频压缩
┌─────────────────────────────────────────────────────────────────────┐
│ │
│ ❌ 全量传输:每帧发送完整数据 │
│ 帧1: [完整数据 100字节] │
│ 帧2: [完整数据 100字节] │
│ 帧3: [完整数据 100字节] │
│ 总计: 300 字节 │
│ │
│ ✅ 增量传输:只发送变化的部分 │
│ 帧1: [完整数据 100字节] ← 关键帧 │
│ 帧2: [差异数据 10字节] ← 只有位置变了 │
│ 帧3: [差异数据 5字节] ← 只有朝向变了 │
│ 总计: 115 字节 (节省 62%!) │
│ │
└─────────────────────────────────────────────────────────────────────┘📊 17.4.5 优化效果对比
指标 | 未优化 | 优化后 | 提升 |
|---|---|---|---|
带宽/玩家 | 14 MB/s | 50 KB/s | 280x |
服务器 CPU | 100% | 30% | 3.3x |
复制延迟 | 500ms | 50ms | 10x |
支持玩家数 | 64 | 1000+ | 15x |
🎮 17.5 与 Gameplay 系统集成
⚔️ 17.5.1 与 GAS (Gameplay Ability System) 集成
PLAINTEXT
🎮 GAS 复制优化要点
1. 使用 Push Model 优化属性复制
2. 只复制"可见"的 GameplayEffect
3. 实现技能预测与回滚CPP
// GAS 属性使用 Push ModelUCLASS()
class UMyAttributeSet : public UAttributeSet
{
UPROPERTY(ReplicatedUsing=OnRep_Health)
FGameplayAttributeData Health;
void SetHealth(float NewValue)
{
Health.SetBaseValue(NewValue);
MARK_PROPERTY_DIRTY_FROM_NAME(UMyAttributeSet, Health, this);
}
};🎱 17.5.2 物理复制策略
PLAINTEXT
🎱 物理复制三种策略
┌─────────────────────────────────────────────────────────────────────┐
│ │
│ 1️⃣ 关键帧同步 │
│ 只在状态变化时同步,不是每帧 │
│ 适用:台球、保龄球等回合制物理 │
│ │
│ 2️⃣ 客户端预测 + 服务器校正 │
│ 客户端先预测,服务器验证后校正 │
│ 适用:赛车、飞行等实时物理 │
│ │
│ 3️⃣ 确定性物理 │
│ 使用相同随机种子,确保结果一致 │
│ 适用:RTS、格斗等需要精确同步的游戏 │
│ │
└─────────────────────────────────────────────────────────────────────┘🤖 17.5.3 AI 复制策略
PLAINTEXT
🤖 AI 复制三种策略
┌─────────────────────────────────────────────────────────────────────┐
│ │
│ 1️⃣ 只复制"结果",不复制"过程" │
│ 服务器运行 AI,客户端只接收行为结果 │
│ 优点:带宽最小 │
│ 缺点:客户端 AI 表现可能不流畅 │
│ │
│ 2️⃣ 分层复制 │
│ 重要 AI(Boss):完整复制 │
│ 普通 AI(路人):简化复制 │
│ 优点:平衡带宽和表现 │
│ │
│ 3️⃣ 客户端 AI 代理 │
│ 远处 AI 在客户端运行简化版行为 │
│ 优点:表现流畅 │
│ 缺点:可能与服务器不同步 │
│ │
└─────────────────────────────────────────────────────────────────────┘📋 17.6 总结与最佳实践
🎯 核心概念回顾
组件 | 作用 | 难度 | 使用场景 |
|---|---|---|---|
🔍 Filter | 决定"谁能看到谁" | ⭐⭐ | 隐身、战争迷雾、阵营 |
⚡ Prioritizer | 决定"谁先更新" | ⭐⭐ | 战斗优先、目标锁定 |
🧩 Fragment | 决定"怎么打包数据" | ⭐⭐⭐ | 自定义数据结构 |
✅ 最佳实践清单
🔍 自定义 Filter
继承正确的基类(
UNetObjectFilter)实现所有必需的接口方法
在
RemoveObject中清理所有相关数据使用早期退出优化性能
使用批量处理优化性能
添加滞后机制避免闪烁
⚡ 自定义 Prioritizer
可以继承内置 Prioritizer(如
USphereNetObjectPrioritizer)优先级值保持在 0.0-1.0 范围内
考虑使用 SIMD 优化大量对象
与 Filter 正确协作
🧩 自定义 Fragment
正确实现
CollectPropertyData和ApplyPropertyData实现
PollPropertyData检测变化处理动态状态的内存管理
测试序列化/反序列化的正确性
🌍 大规模优化
使用空间分区(GridFilter)
配置合理的更新频率
使用增量压缩
实现休眠策略
监控带宽使用情况
🐛 常见问题速查表
问题 | 可能原因 | 解决方案 |
|---|---|---|
对象闪烁 | Filter 结果不稳定 | 添加滞后机制 |
更新延迟 | 优先级太低 | 调整 Prioritizer |
内存泄漏 | RemoveObject 未清理 | 检查所有 Map/Array |
数据不同步 | Fragment 序列化错误 | 检查 Serialize/Deserialize |
性能差 | 未使用批量处理 | 重构为批量操作 |
📚 推荐学习路径
PLAINTEXT
第一周:理解基础
├── 阅读 UNetObjectFilter 源码
├── 阅读 UNetObjectPrioritizer 源码
└── 运行内置组件的调试日志
第二周:动手实践
├── 实现一个简单的自定义 Filter
├── 实现一个简单的自定义 Prioritizer
└── 在测试项目中验证
第三周:深入优化
├── 学习性能优化技巧
├── 实现批量处理
└── 性能测试与调优
第四周:高级主题
├── 实现自定义 Fragment
├── 研究大规模优化策略
└── 与 GAS/物理/AI 集成本文档基于 Unreal Engine 5.5.0 Iris 源代码分析(源码目录:Engine/Source/Runtime/Experimental/Iris/)
