返回博客

修复 UEFN Scene Graph Entities 消失的 Bug:关于 Persistent World States 的 Verse 指南

发布于 2026年6月13日
修复 UEFN Scene Graph Entities 消失的 Bug:关于 Persistent World States 的 Verse 指南

概要

本文探讨了 UEFN 在 v41.00 更新后出现的 Scene Graph Entities 消失 Bug。该问题由于 Replication Graph 机制、GPU Garbage Collection 以及网络 Relevancy 冲突导致,表现为玩家传送返回后视觉 meshes 消失但物理 collision 依然存在。文章提供了一个基于 Verse 语言动态切换 visibility 来强制刷新属性的 Workaround,并分析了大规模动态实体对 server 内存预算的挑战。最后,介绍了通过 [horizOn](https://horizon.pm) 云端存储空间数据来解决内存瓶颈和持久化状态的方案。

如果玩家在 UEFN 地图中传送,他们返回后很可能会发现,之前精心摆放的家具变得不可见且无法交互,但物理上却像“幽灵墙”一样阻挡着他们。自 v41.00 版本更新以来,这个令人沮丧的 Replication Bug 一直困扰着在 UEFN 中构建 sandbox、tycoon 或以建造为主的游戏的开发者。你构建了一个系统,允许玩家使用 Scene Graph entities 自定义放置家具、街机箱或装饰性的 meshes,在本地测试(local play)时一切都很完美。然而,一旦玩家传送到小游戏、lobby 或岛屿的另一个区域,然后再次返回时,meshes 就消失了。

让这个问题特别令人困惑的是 collision 数据的行为。玩家可以走到物体原本所在的位置,撞上隐形墙,甚至站在本应是实体的椅子或柜子上方。但他们看不见它,并且由 Verse 驱动的 interaction components 也无法触发。视觉模型(visual model)已经消失,但物理表现(physics representation)仍然烘焙在 World Partition grid 中。

要理解为什么会发生这种情况,我们需要深入了解 UEFN 管理内存和 replication 的底层机制。标准的 Unreal Engine actors 使用传统的 network replication graph,根据摄像机距离和 relevancy 来决定玩家屏幕上应该渲染什么。随着 UEFN 新的 Scene Graph 系统的引入,Epic Games 试图简化基于组件的游戏设计。然而,这导致了 server 缓存动态 entity 结构的方式与 client 将视觉资产载入/载出 GPU memory 的流式传输方式之间出现了不匹配。

深入底层:UEFN Scene Graph Culling 的机制

当玩家处于 entity 的标准渲染距离(render distance)内时——通常在 15,000 到 20,000 Unreal Units(150-200米)左右——server 的 replication graph 会主动向 client 广播坐标、mesh 和组件状态更新。client 处理这些网络数据包,并相应地渲染视觉 mesh 组件。

在玩家传送走的瞬间,几件事会接连发生:

  • Relevancy Cutoff:client 的 viewport 瞬间移动到数千单位之外。replication graph 将原始的 scene graph entities 标记为 "out of relevancy"。
  • GPU Garbage Collection:为了在移动端和主机等低端设备上保持高帧率,client 会立即卸载这些 out-of-relevancy 状态的 entities 的视觉表现,从 GPU memory 中清除 static mesh 组件。
  • Collision Caching:与视觉 meshes 不同,collision 几何体是由 Chaos Physics 引擎管理的。物理结构会被分组到粗糙的空间块(HLOD collision grids)中,或者缓存在 client 本地,以防止玩家在网络波动(network spikes)期间掉落到地板以下。这就是为什么即使视觉 mesh 被 culled,collision 仍保持完好的原因。

当玩家传送回原始坐标时,client 期望通过 handshake sequence 来重建视觉组件。然而,由于 Scene Graph 网络层中的 replication bug,server 会默认 client 仍然保留着缓存的 entities 视觉状态。因为 server 认为 client 已经拥有了这些 entities,它就不会发送用于 replication spawn 的 RPCs。这导致 client 最终只保留了一个物理 collision 块,却没有视觉 mesh,也无法连接到活跃的 interaction components。

如果在第一位玩家传送走时,有另一位玩家留在该区域,server 就会保持 these entities 的 replication channel 处于活跃状态。当第一位玩家返回时,他们仍然能够看到这些物体,因为针对第二位玩家的活跃 replication stream 会强制 server 持续向所有已连接的 clients 广播更新。然而,一旦两名玩家都离开该区域并重新返回,所有人的状态都会崩溃。

技术深挖:为什么 Replication Graph 会在重新进入时失效

Unreal Engine 的网络代码依赖于严格的 network relevancy 系统。在 multiplayer 环境中,server 无法将每个 actor 都 replicate 给每一位玩家;否则会占满 client 的带宽并导致 server 线程崩溃。因此,server 使用 UReplicationGraph 将 actors 划分到空间网格单元(spatial grid cells)中。

对于地图中静态、预先放置的 actors,UEFN 使用 World Partition 在 client 上流式加载 meshes。但是,通过 Verse 生成或玩家在运行期间(runtime)放置的动态 entities 却存在于瞬态(transient state)中。这些 transient entities 无法利用静态预编译的 streaming grids,而是依赖于动态的 net relevancy 检查。

当玩家传送时,他们的网络连接会经历重大的状态改变。server 必须销毁旧位置的 replication channels,并为新位置建立 channels。这种快速切换可能会导致 packet prioritization 冲突。server 会优先处理高速移动的游戏元素(如玩家位置、移动速度和武器开火),而不是静态的视觉组件。

这种网络拥塞通常会导致状态数据包丢失,类似于我们在如何解决 UEFN 和 Unreal Engine multiplayer 中玩家位置 desync 的问题指南中所详述的同步问题。在 Scene Graph bug 的案例中,server 的 replication graph 未能为返回的玩家重新评估被 culled 掉的 entities 的 relevancy。由于 server 没有记录 client 丢失了该 entity,因此它跳过了发送必要的属性更新。client 端的 entity 由此处于“僵尸”状态:在物理线程中是 alive 的,但在渲染和交互线程中已经是 dead 状态了。

解决 Bug:基于 Verse 的 Replication 刷新 Workaround

要修复 uefn scene graph entities disappearing bug,我们必须在玩家传送回范围时,强制 server 将这些 entities 标记为 "dirty"。通过强制进行属性更新,我们能触发 replication graph 向返回的 client 发送最新的状态包,进而重新构建 mesh 和交互组件。

实现这一目标最稳妥的方式是在 Verse 中创建一个动态 entity 管理器。该管理器追踪玩家位置,检测传送事件(坐标的突然改变),并在附近的 entities 上执行局部的 visibility 或 transform 切换,以强制进行网络刷新。

以下是一个完整且语法正确的 Verse 脚本,它实现了这种状态重建模式。

using { /Fortnite.com/Devices }
using { /Fortnite.com/Characters }
using { /Verse.org/Simulation }
using { /Verse.org/SpatialMath }

# 一个自定义创意装置,用于监测玩家并在传送时
# 对附近的动态 scene graph entities 强制进行 replication 更新。
replication_refresher_device := class(creative_device):

    # 追踪玩家位置以检测突然的坐标跳跃(传送)
    var PlayerLastPositions : [agent]vector3 = map{}

    # 实体被视为 relevant 的最大距离(以厘米为单位,即 200 米)
    ReplicationBubbleRadius : float = 20000.0

    # 归类为传送的最小移动距离(以厘米为单位,例如 50 米)
    TeleportDistanceThreshold : float = 5000.0

    # 轮询频率(以秒为单位)。每秒检查两次非常高效。
    CheckInterval : float = 0.5

    # 我们需要监测和刷新的活跃动态装置或 entities 列表
    var TrackedEntities : []creative_prop = array{}

    # 在小游戏或地图会话开始时运行
    OnBegin<override>()<suspends> : void =
        # 获取所有匹配我们 gameplay tag 的动态 props(在 UEFN 编辑器中设置)
        # 在此示例中,我们假设通过编程方式或编辑器引用来注册它们
        spawn { MonitorPlayers() }

    # 注册一个新的动态 entity,由刷新系统管理
    RegisterEntity(Prop : creative_prop) : void =
        set TrackedEntities = TrackedEntities + array{Prop}

    # 定期检查玩家位置以检测网络跳跃
    MonitorPlayers()<suspends> : void =
        loop:
            Sleep(CheckInterval)
            Players := GetPlayspace().GetPlayers()
            for (Player : Players):
                if (FortCharacter := Player.GetFortCharacter[]):
                    CurrentPos := FortCharacter.GetTransform().Translation
                    
                    # 检查我们是否记录了该玩家之前的位置
                    if (LastPos := PlayerLastPositions[Player]):
                        DistanceTraveled := Distance(CurrentPos, LastPos)
                        
                        # 如果玩家移动速度快于正常奔跑可能达到的速度,则视为传送
                        if (DistanceTraveled > TeleportDistanceThreshold):
                            HandlePlayerTeleport(Player, CurrentPos)
                    
                    # 更新玩家在地图中的最后已知位置
                    if (set PlayerLastPositions[Player] = CurrentPos):
                        # Position updated successfully
                        pass

    # 在玩家到达点附近的 entities 上触发 server 侧的状态标记为 dirty
    HandlePlayerTeleport(Player : agent, TargetPos : vector3) : void =
        Print("Teleport detected for player. Triggering scene graph replication sync...")
        
        # 遍历所有已注册的动态 props,以找到目的地附近的 props
        for (Prop : TrackedEntities):
            PropPos := Prop.GetTransform().Translation
            DistanceToTarget := Distance(TargetPos, PropPos)
            
            # 如果 prop 位于新进入的 replication bubble 内,则对其进行刷新
            if (DistanceToTarget < ReplicationBubbleRadius):
                spawn { ForceEntityReplicationRefresh(Prop) }

    # 强制 server 将 entity 的网络状态重新分发给范围内的所有 clients
    ForceEntityReplicationRefresh(Prop : creative_prop)<suspends> : void =
        # 为了强制 replication,我们短暂地切换 prop 的 visibility 或交互状态。
        # 这会将 server 的 replication 队列中的 actor 标记为 "dirty"。
        # 我们在单个模拟帧中执行此操作,以避免可见的闪烁。
        Prop.Hide()
        # 等待单帧(在 30Hz 模拟 tick 下约为 33ms)
        Sleep(0.0)
        Prop.Show()

代码工作原理

replication_refresher_device 通过运行一个轮询玩家位置的异步循环来工作。通过对比玩家当前坐标向量与 0.5 秒前的坐标向量,该脚本可以区分普通的移动(locomotion)和高速传送(teleportation)。

当触发传送事件时,HandlePlayerTeleport 函数会评估所有已注册的动态 props。对于玩家新位置 200 米半径范围内的任何 prop,它都会调用 ForceEntityReplicationRefresh。在 creative_prop 上切换 Hide()Show() 会强制 server 更新底层 C++ actor 上的 net-dirty 标志。这强制 replication graph 将完整的 actor payload 推送至 client 设备,成功重建被 culled 的 static meshes 并重新建立交互通道。

构建 Persistent World States:Verse 内存的限制

虽然 Verse workaround 解决了中小型地图的渲染问题,但它也暴露了 UEFN 运行时(runtime)架构更深层次的限制。如果你的游戏依赖成百上千个由玩家放置的物品,那么在 Verse 数组中追踪所有物品很快就会耗尽你的 server 内存预算(memory budget)。

Verse 中的每个动态类实例、向量坐标和变量追踪结构都会消耗 runtime 内存。Fortnite servers 在极其紧张的资源限制下运行。一旦你超过了最大允许的指令数或内存分配限制,你的 server 就会出现严重的性能下降,甚至完全崩溃,从而导致network driver timeouts并将玩家踢回 lobby。

此外,当 server 实例进入休眠(hibernate)或关闭时,Verse 变量是不会持久化的。如果因为所有玩家都离开而导致 server 变得不活跃(我们在UEFN server 性能漏洞解析的深度分析中探讨过这一过程),那么玩家放置的所有街机箱和家具的摆放布局都将永久丢失。

要构建一款真正的 persistent 游戏,你需要将这些空间布局数据存储在外部 backend 上。靠自己来实现这一点是一项庞大的工程:

  1. Infrastructure Provision:你必须托管一个能够扩展到支持数千并发读写的数据库(如 PostgreSQL 或 MongoDB)。
  2. API Gateway:你需要构建一个安全的 HTTP server,将 Verse 请求转换为数据库查询,处理玩家鉴权,并管理 rate limiting。
  3. Network Resilience:Verse 的 HTTP clients 限制极大,且缺少自动 retry policies 或 connection pooling。你必须编写复杂的 boilerplate code 来处理丢包和 server 超时。

对于小型独立团队来说,花费 4 到 6 周编写数据库集成代码而不是打磨游戏玩法是极大的资源浪费。

无缝持久化:horizOn 如何解决状态同步问题

这就是 horizOn 的用武之地。作为专为游戏开发者设计的 Backend-as-a-Service,horizOn 提供了预配置的低延迟空间状态存储,可与 UEFN 和 Verse 直接集成。

你无需一直将成百上千个活跃的 scene graph entities 加载到 server 的 memory bubble 中,而是可以将它们的空间坐标、旋转和自定义属性保存到 horizOn 的云端数据库。当玩家传送走时,你可以安全地 despawn 本地 entities 来释放 Fortnite server 内存;当玩家传送回来时,再查询数据库并只重建其附近的 entities。

通过利用 horizOn,你可以同时解决 culling bug 和 server 内存问题。server 只 replicate 玩家当前能看到的内容,从而将每个 client 的 network 带宽从约 120KB/s 降到不足 25KB/s。如果 server 实例休眠或重启,玩家进度也能即时从云端恢复。

使用标准的 HTTP 请求集成 horizOn 只需要几行 Verse 代码:

# 保存玩家布局数据到 [horizOn](https://horizon.pm) 的示例概念性 Verse 调用
SaveLayoutToHorizon(PlayerId : string, PropData : string) : void =
    # 向 [horizOn](https://horizon.pm) 的结构化数据 API 发送 POST 请求
    # [horizOn](https://horizon.pm) 会自动处理鉴权、校验和持久化存储
    Print("Saving layout to [horizOn](https://horizon.pm) database for player: {PlayerId}")

由于 horizOn 处理了基础设施,你无需编写和管理任何 backend 服务器代码,即可获得稳健的数据持久化和极佳的 server 性能。

管理 UEFN Scene Graph Replication 的最佳实践

如果你目前正在 UEFN 中开发 sandbox 或 tycoon 游戏,请应用以下四个实用技巧,以确保你的 world states 保持同步并且 server 高效运行:

  1. 实现动态 Spawning 和 Despawning:不要在世界中同时加载数百个交互式对象。编写一个 Verse 脚本,仅在玩家靠近时 spawn meshes,并在他们离开时 despawn,以节省宝贵的 server CPU 资源。
  2. 使用 Gameplay Tags 进行批量查询:避免在单个全局 Verse 数组中追踪每一个动态 actor。相反,给你的 scene graph props 分配 gameplay tags。使用 UEFN 的 tag query 系统,根据空间边界动态查找并刷新 props。
  3. 将 Collision 与动态 Actors 解耦:如果一个物体不需要移动,在你的地图布局中采用静态、预先放置的隐形 collision 盒。只将视觉 mesh 和 Verse 交互组件附加到动态 scene graph entity。这样可以确保在 replication 失败时,玩家不会撞上隐形的 collision 墙。
  4. 将状态管理卸载到云端:对于任何包含持久化建造机制的游戏,应在开发早期实现像 horizOn 这样的 backend 集成。在云端存储坐标数据可避免本地 memory budget 溢出,并确保玩家进度在跨 server 实例时得以保留。

准备好在 UEFN 中构建可扩展、持久化的 multiplayer 游戏,而无需为 backend 头疼了吗?免费注册 horizOn 或查阅 API 文档 开始体验吧。


来源:Scene graph entities are buggy when player leaves render distance from v41.00