Skip to content

服务端 GameGraph SceneGraph 发布与同步 ​

GameGraph 已从早期的客户端表现层,扩展为可在客户端和服务端共同使用的场景图系统。服务端现在可以创建脚本侧 SceneGraph,按玩家发布给客户端,并让客户端通过事件观察这些发布图及其节点生命周期。

这套能力面向高级用户。它不会替代 GameCore 的 Entity / Unit 同步体系,而是补充一个更底层的场景图同步入口,适合自定义场景对象、调试工具、服务端权威的局部 2D/3D 物理对象、临时机关或非 Unit 的同步物件。

可用范围 ​

模块服务端客户端说明
SceneGraph / Node / 基础组件挂载可用可用可创建本地图,也可创建 replicated 发布图
SceneGraph.Publish(player) / Unpublish(player)可用不可用发布控制由服务端负责
SceneGraph.OnRegistered / OnUnregistered不适用可用客户端接收发布图注册/注销事件
SceneGraph.OnNodeReplicated / OnNodeUnreplicated不适用可用客户端按 SceneGraph 订阅节点复制事件
SceneGraph.IsReplicated / Node.IsReplicated / Component.IsReplicated可用可用用于判断对象是否参与复制
Node.SetSyncVar(...) / TryGetSyncVar(...)可写可读只读同步少量节点级元数据或状态;setter 仅服务端 API 包含
3D PhysicsWorld / RigidBody / CollisionShape可用可用服务端可作为权威物理端;渲染仍在客户端
2D PhysicsWorld2D / RigidBody2D / CollisionShape2D / Constraint2D可用可用服务端可作为 Box2D 权威模拟端;显示仍需客户端 Canvas/GameUI/渲染表现
图形、材质、音频、Spine不可用或不建议可用这些仍属于客户端表现层

两类场景同步 ​

SCE 默认场景 ​

SCE 数编场景仍由框架场景系统管理。普通联机流程下,同一客户端同一时刻最多绑定一个当前 SCE 场景;框架为这个当前场景维护一条默认场景同步会话,用于同步服务端场景中的 replicated 节点。

SCE 场景的加载、切换、卸载和客户端绑定由 Scene.Load()、Player.SwitchScene 以及框架场景系统负责。用户代码不需要、也不应该对 SCE 场景的内置 SceneGraph 调用 Publish(...)、Unpublish(...) 或 Dispose()。这条路径适合地图地形、预放置对象、Unit/Actor 绑定节点,以及随当前地图生命周期存在的同步节点。

从 SCE Scene 获取它对应的内置 GameGraph 包装:

csharp
using GameGraph.NodeSystem;

var scene = Scene.GetOrCreate(ScopeData.GameDataScene.new_scene);
scene.Load();

var sceneGraph = scene.GetSceneGraph();
var syncRoot = scene.GetSyncRoot();

scene.GetSceneGraph() 返回的是该 SCE 场景底层 Urho Scene 的 SceneGraph 包装;scene.GetSyncRoot() 返回当前 SCE 场景的同步挂载根。它们都可能在场景尚未加载或 native 场景尚未就绪时为 null,实际使用时应做空值检查,或在 scene.OnLoadEnd 后访问。

服务端在默认 SCE 场景中添加同步节点时,使用 Scene.GetSyncRoot() / Scene.AddSyncNode(...)。这条路径适合把节点挂到当前 SCE 场景里,而不是创建一个独立发布图。

csharp
#if SERVER
var node = new Node();
node.Position = new Vector3(2048f, 2048f, 260f);
scene.AddSyncNode(node, NodeReplication.Replicated);
#endif

如果 replicated Node 通过 UnitNodeBinding 绑定到某个 Unit,它的客户端生命周期会跟随该 Unit 的服务端视野可见性,而不是无条件随整个 SCE Scene 下发:Unit 对某个客户端不可见时,该客户端会收到绑定 Node 的 OnNodeUnreplicated;Unit 重新可见时会再次收到 OnNodeReplicated 和当前完整 transform。该语义覆盖 UnitDriven、NodeDriven 和 Manual 绑定。没有绑定 Unit 的 replicated Node 仍保持当前 SCE Scene 级同步,只要客户端在该 SCE Scene 中就会收到。

如果 replicated Node 通过 ActorScopeNodeBinding 绑定到 ActorScopePersist,它会从属于该表现作用域,而不是独立按场景全量同步。客户端看不到对应作用域时,不会收到该 node,也不会收到 node 上的 actor slot 声明。普通持久表现优先使用 scope.SetActorSlot(...);只有需要真实 replicated Node、Node SyncVar 或 GameGraph 组件时,再看 服务端创建表现 中的 nodebinding 章节。

客户端的 SceneGraph.OnRegistered、GetRegisteredSceneGraphs() 等 API 面向服务端额外发布的脚本 SceneGraph。不要用它们查找当前 SCE 场景;当前 SCE 场景应通过 Scene / Scene.GetSceneGraph() 路径访问。

脚本创建的 SceneGraph ​

脚本创建的 SceneGraph 是独立 Urho Scene。服务端可以创建多个 replicated SceneGraph,并分别发布给不同玩家。一个客户端可以同时接收多个服务端发布的 SceneGraph。

这类图使用独立的 SceneGraphId 路由,生命周期由脚本控制:

  • new SceneGraph(SceneGraphReplication.Local):本地图,不参与网络同步,SceneGraphId == 0。
  • new SceneGraph(SceneGraphReplication.ReplicatedUnpublished):可发布图,创建后尚未发布给任何客户端。
  • Publish(player):让指定玩家订阅该图;首次发布返回 true,重复发布返回 false。
  • Unpublish(player):取消指定玩家订阅;成功取消返回 true,重复取消返回 false。
  • Unpublish():取消该图的所有订阅。
  • Dispose():销毁图及其子节点/组件。对已发布图执行 Dispose() 时,框架会清理对应发布关系并通知客户端注销。

客户端消费方式与 Viewport ​

发布图注册成功只表示客户端已经拿到这个 SceneGraph 及其 replicated 节点,不表示它会自动进入当前画面。发布图可以只作为同步数据源使用,客户端未必需要把它渲染到任何 3D Viewport。

典型数据源用法包括:SCE 默认场景继续展示主要游戏世界;服务端额外发布一个附属 SceneGraph 同步一组虚拟物理对象、机关状态或调试节点;客户端读取这些节点 transform / 状态后,在 GameUI Canvas、小地图、调试面板或自定义 UI 中绘制结果。此时不要创建 Camera,也不要接管主视口。

独立的脚本 SceneGraph 只有在需要直接渲染成 3D 画面时,客户端才需要显式准备渲染链路:

  • 调用 sceneGraph.EnsureOctree(),保证图内有可供渲染和空间查询使用的 Octree。
  • 在客户端创建 local 表现组件,例如 StaticMeshComponent、AnimatedModelComponent、粒子、音效或调试 mesh。服务端权威逻辑不应依赖这些表现组件。
  • 创建 local CameraComponent,并根据对象范围设置位置、朝向、FOV、near/far clip。
  • 使用 Renderer.SetupMainViewport(sceneGraph, camera) 绑定主视口;在编辑器 GamePlay 流程中,这是比手动 CreateViewport / SetViewport(0, viewport) 更稳妥的入口。

如果只是临时查看发布图,先用 Renderer.GetMainViewport() 保存原始 Scene / Camera,在 SceneGraph.OnUnregistered 或 Game.OnGameEnd 中恢复。否则一个调试发布图可能会长期占用主视口,让用户误以为 SCE 当前地图消失。

SceneGraphMainViewportController 是可选 convenience helper,只适合项目里频繁临时查看发布图时复用“保存原主视口、绑定发布图、注销后切到剩余图或恢复原视口”这一段重复代码。它不参与 SceneGraph 同步、SCE 场景生命周期或客户端消费策略;需要理解或调试渲染路径时,应优先看 Renderer.GetMainViewport()、Viewport.Scene、Viewport.Camera 和 Renderer.SetupMainViewport(...) 的显式逻辑。

一个客户端可以同时订阅多个脚本创建的 SceneGraph,但它们不会自动叠加渲染到同一个画面。当前 smoke 已验证的渲染测试做法是:客户端选择一个活跃发布图绑定到主视口,图注销后恢复原视口或切换到另一个活跃发布图。这只是调试可视化策略,不是发布图的默认消费方式。若项目需要多发布图同屏显示,应先设计专门的 Viewport / RenderPath / compositing 策略并做独立验证;若项目只需要把发布图作为 Canvas/UI 数据源,则完全不需要进入 Viewport 流程。

用 MeshBuilder 或自定义网格做调试可视化时,应提供 normal,否则在常规光照下会出现发黑或光照异常。

Transform 平滑 ​

用于角色独立瞄准、TPS/FPS 或本机预测时,另读射击游戏权威与瞄准同步。瞄准输入由客户端上行,节点复制负责服务端状态下行;发送角差、发布频率与视觉平滑是不同参数。

服务端同步 replicated Node 的 transform 后,客户端不会直接把每次网络位置硬切到画面上,而是由 Urho 的 SmoothedTransform 从当前显示 transform 向最新网络目标收敛。新节点创建时会先贴合一次初始 transform,后续位置和旋转更新则进入平滑目标。

SceneGraph 暴露以下双端 API:

API默认值说明
TransformSmoothingConstant50收敛速度。值越大越快贴近最新网络目标
TransformSnapThreshold5距离目标超过该阈值时直接贴合目标,避免超远距离缓慢追随
ConfigureTransformSmoothing(float smoothingConstant, float snapThreshold)—一次设置上述两个参数

这些参数属于底层 Urho Scene,不是 replicated 网络状态。服务端设置只影响服务端本地图;要改变客户端看到的平滑效果,应在客户端取得对应 SceneGraph 后设置:发布图通常在 SceneGraph.OnRegistered 中配置,SCE 默认场景则通过 scene.GetSceneGraph() 取得内置图后配置。

当前实现是“朝最新目标平滑”的视觉收敛,不是基于历史快照和时间戳的插值缓冲。如果玩法需要预测、回滚或固定延迟插值,应为具体玩法单独建立测试闭环。

Node SyncVar ​

Replicated Node 除了 transform 和 replicated 组件外,也可以同步少量节点级变量。服务端使用 SetSyncVar(...) 写入,客户端和服务端都可以用 TryGetSyncVar(...) / HasSyncVar(...) / GetSyncVarType(...) 读取。

SyncVar 适合存放轻量元数据或小型状态,例如调试对象类型、机关阶段、阵营、目标 id、服务端生成的标记值等。它不替代 GameCore 的 Entity / Unit / Player 属性系统,也不适合存放背包、配置表、复杂对象、大字符串或高频大 payload。

支持的值类型:

类型API 示例
intnode.SetSyncVar("team", 1)
boolnode.SetSyncVar("armed", true)
floatnode.SetSyncVar("charge", 0.5f)
stringnode.SetSyncVar("kind", "authority_ball")
Vector2node.SetSyncVar("uv", new Vector2(0, 1))
Vector3node.SetSyncVar("target", targetPosition)
Quaternionnode.SetSyncVar("aim", rotation)

string key 是推荐给业务代码使用的可读变量名,内部会按 Urho StringHash 规则转为 hash。uint key 是预计算的 Urho StringHash 值,适合生成代码、常量或热路径缓存;不要把任意业务数字 id 直接当作 key。

csharp
#if SERVER
var node = graph.CreateChild("server_ball", NodeReplication.Replicated)
    ?? throw new InvalidOperationException("Failed to create replicated node.");

node.SetSyncVar("kind", "authority_ball");
node.SetSyncVar("team", 1);
node.SetSyncVar("armed", true);
#endif
csharp
#if CLIENT
graph.OnNodeReplicated += node =>
{
    if (node.TryGetSyncVar("kind", out string kind) && kind == "authority_ball")
    {
        node.TryGetSyncVar("team", out int team);
        Game.Logger.LogInformation("Authority node replicated: team={Team}", team);
    }
};
#endif

写入语义是服务端权威:客户端 SDK 不包含 SetSyncVar(...)。如果客户端需要改变同步状态,应发送请求给服务端,由服务端校验后写入 SyncVar。对 local node 调用读取 API 只是读取本地变量,不会产生网络同步。当前 API 不暴露删除/清空变量;需要清除语义时优先写入新的状态值,或通过节点生命周期重建。

已验证的 smoke 流程 ​

2026-06-01 的 published_scenegraph_sync_smoke 测试已覆盖以下链路:

流程覆盖内容结论
单客户端server-authority-physics + lifecycle-boundary服务端权威 3D 物理 transform 同步、客户端 local mesh 可视化、主视口绑定/恢复、发布图注销、late node 删除事件均通过
双客户端late-join-catch-up + server-authority-physics + multi-client-isolation + player-disconnect-boundary多图订阅、按玩家发布隔离、后加入追赶、断线后发布图清理、发布图主视口切换均通过

服务端权威物理验证使用 replicated Node + local RigidBody / local CollisionShape / local PhysicsWorld:服务端采样到动态节点移动,玩家 1 客户端采样到同步 transform 推进,玩家 2 未收到只发布给玩家 1 的 authority physics 图。该测试说明“服务端模拟 3D 物理,客户端接收节点 transform”的基础闭环已经可用。

断线边界测试中,玩家 1 客户端可能在本地最终 verdict 时仍持有 disconnect boundary 图;这是因为该客户端 verdict 先于真实断线完成。服务端 verdict 会继续等待玩家 1 离线,并以 disconnected=True dispose 发布图来验证服务端清理路径。

复制模式 ​

GameGraph 使用三层显式复制模式,不再使用旧的 NodeSyncMode / NodePhysicsMode:

类型值说明
SceneGraphReplicationLocal本地图,不参与发布同步
ReplicatedUnpublished可发布图,等待服务端 Publish(player)
NodeReplicationInherit继承父级或所属 SceneGraph 的复制语义
Local本地节点,不同步
Replicatedreplicated 节点,参与网络复制
ComponentReplicationInherit继承节点复制语义
Local本地组件,不同步
Replicatedreplicated 组件,参与网络复制

SceneGraph.CreateChild(...) 默认使用 NodeReplication.Inherit,通常会继承 replicated 图的同步语义。Node.CreateChild(...) 默认使用 NodeReplication.Local,需要同步时应显式传 NodeReplication.Replicated 或 Inherit。

组件也是同样的原则。挂在 replicated 节点上的本地组件不会同步;需要同步的组件应显式使用 ComponentReplication.Replicated 或在明确继承语义时使用 Inherit。

服务端权威 3D 物理的常见做法是:动态对象使用 replicated Node 同步 transform,RigidBody 保持服务端 local;静态障碍尽量双端 local;模型、材质、粒子、音效等表现组件放在客户端。完整分层建议见 物理系统 - 服务端权威 3D 物理。

服务端示例 ​

csharp
#if SERVER
using GameGraph.NodeSystem;
using GameGraph.NodeSystem.Component.Physics;

private static SceneGraph CreateAndPublish(Player player)
{
    var graph = new SceneGraph(SceneGraphReplication.ReplicatedUnpublished);

    var node = graph.CreateChild("server_ball", NodeReplication.Replicated)
        ?? throw new InvalidOperationException("Failed to create replicated node.");
    node.Position = new Vector3(2048f, 2048f, 520f);
    node.SetSyncVar("kind", "authority_ball");
    node.SetSyncVar("debug_team", 1);

    var body = node.CreateComponent<RigidBody>(ComponentReplication.Local);
    if (body != null)
    {
        body.Mass = 1f;
        body.UseGravity = true;
        body.LinearVelocity = new Vector3(120f, 0f, 0f);
    }

    var shape = node.CreateComponent<CollisionShape>(ComponentReplication.Replicated);
    shape?.SetSphere(80f);

    graph.Publish(player);
    return graph;
}
#endif

这段代码中,节点和碰撞形状参与复制,RigidBody 保持服务端本地。客户端关心的是服务端权威模拟后的节点 transform 和 replicated 组件状态,而不是复制一个客户端也运行的刚体实例。

客户端观察事件 ​

csharp
#if CLIENT
using GameGraph.NodeSystem;

private static void InstallSceneGraphObservers()
{
    SceneGraph.OnRegistered += graph =>
    {
        if (!graph.IsReplicated)
        {
            return;
        }

        Game.Logger.LogInformation("SceneGraph registered: {SceneGraphId}", graph.SceneGraphId);

        graph.OnNodeReplicated += node =>
        {
            node.TryGetSyncVar("kind", out string kind);

            Game.Logger.LogInformation(
                "Node replicated: graph={SceneGraphId}, node={NodeId}, replicated={Replicated}, kind={Kind}",
                graph.SceneGraphId,
                node.Id,
                node.IsReplicated,
                kind);
        };

        graph.OnNodeUnreplicated += node =>
        {
            Game.Logger.LogInformation(
                "Node unreplicated: graph={SceneGraphId}, node={NodeId}",
                graph.SceneGraphId,
                node.Id);
        };
    };

    SceneGraph.OnUnregistered += graph =>
    {
        Game.Logger.LogInformation("SceneGraph unregistered: {SceneGraphId}", graph.SceneGraphId);
    };

    foreach (var graph in SceneGraph.GetRegisteredSceneGraphs())
    {
        Game.Logger.LogInformation("Already registered graph: {SceneGraphId}", graph.SceneGraphId);
    }
}
#endif

OnNodeReplicated / OnNodeUnreplicated 是按 SceneGraph 注册的事件。只有在确实需要观察节点级生命周期时再订阅,避免不必要的跨 WASM/native 事件交互。

生命周期语义 ​

  • 客户端收到发布图时触发 SceneGraph.OnRegistered。
  • 如果图中已有 replicated 节点,客户端会在注册后收到这些节点的 replicated 事件或可通过图遍历观察到节点。
  • 服务端发布后新增 replicated 节点,客户端会收到对应节点复制事件。
  • 服务端移除 replicated 节点,客户端会收到 OnNodeUnreplicated。
  • 服务端 Unpublish(player) 或 Dispose() 发布图后,客户端会收到 SceneGraph.OnUnregistered。
  • 一个图可以发布给多个玩家;对某个玩家 Unpublish(player) 不影响其他玩家继续订阅同一图。
  • 一个客户端可以同时订阅多个脚本创建的 SceneGraph;这不改变 SCE 默认场景每客户端一条同步会话的框架现状。

调试建议 ​

  • 服务端和客户端都可用 SceneGraph.IsReplicated、Node.IsReplicated、INodeComponent.IsReplicated 验证复制语义。
  • 不要用 Node.Id 的正负或是否为 0 来推断复制状态;用 IsReplicated。
  • 需要节点级小状态时,服务端写 SetSyncVar(...),客户端读 TryGetSyncVar(...);不要在客户端试图写 replicated node 的同步变量。
  • 客户端 transform 看起来过慢、过硬切或远距离追随异常时,优先检查对应 SceneGraph 的 TransformSmoothingConstant 和 TransformSnapThreshold。
  • 对测试和远端诊断,流程结束前调用 Game.FlushLogs(),让服务端、客户端以及编辑器接收到的远端日志尽快落盘。该能力需要 SDK 与客户端/服务端运行时二进制同时包含 IViewGame.FlushLogs;兼容旧运行时时可把它当作可选诊断调用。
  • 如果只是客户端本地预览、UI 调试或无服务端的 GameGraph 原型,仍可使用纯客户端调试模式;这与服务端发布图不是同一条运行路径。