主题
服务端 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 | 默认值 | 说明 |
|---|---|---|
TransformSmoothingConstant | 50 | 收敛速度。值越大越快贴近最新网络目标 |
TransformSnapThreshold | 5 | 距离目标超过该阈值时直接贴合目标,避免超远距离缓慢追随 |
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 示例 |
|---|---|
int | node.SetSyncVar("team", 1) |
bool | node.SetSyncVar("armed", true) |
float | node.SetSyncVar("charge", 0.5f) |
string | node.SetSyncVar("kind", "authority_ball") |
Vector2 | node.SetSyncVar("uv", new Vector2(0, 1)) |
Vector3 | node.SetSyncVar("target", targetPosition) |
Quaternion | node.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);
#endifcsharp
#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:
| 类型 | 值 | 说明 |
|---|---|---|
SceneGraphReplication | Local | 本地图,不参与发布同步 |
ReplicatedUnpublished | 可发布图,等待服务端 Publish(player) | |
NodeReplication | Inherit | 继承父级或所属 SceneGraph 的复制语义 |
Local | 本地节点,不同步 | |
Replicated | replicated 节点,参与网络复制 | |
ComponentReplication | Inherit | 继承节点复制语义 |
Local | 本地组件,不同步 | |
Replicated | replicated 组件,参与网络复制 |
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);
}
}
#endifOnNodeReplicated / 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 原型,仍可使用纯客户端调试模式;这与服务端发布图不是同一条运行路径。