Skip to content

直接 SceneGraph 小游戏常见坑 ​

本文面向用 GameGraph / SceneGraph 直接写单人小游戏、3D 预览、材质 probe、模型检查器等功能的开发者。这类项目通常不走 Unit / Entity / 服务端权威流程,而是在客户端创建本地 SceneGraph、PhysicsWorld、Mesh、Camera、GameUI 和输入控件。

如果玩法以后需要联机、公平判定、排行榜结算或云存档,先看 docs/ai/skills/server-authoritative-3d-physics/SKILL.md 与 docs/systems/ServerGameGraphSceneGraphReplication.md。客户端本地 SceneGraph 只适合本地表现、单人原型、调试工具和无权威要求的轻量游戏。

适用边界 ​

需求推荐路线
单人小游戏、视觉原型、材质 / 模型 probe客户端 local SceneGraph + PhysicsWorld + GameUI
常规 3D 单位游戏、技能、AI、寻路、血量Unit / Entity 系统,必要时用 UnitNodeBinding 接入 GameGraph
需要服务端权威的 3D 物理服务端 local physics 推进 replicated Node,客户端挂 local 表现组件
只想画 2D 休闲游戏优先 Canvas / GameUI,不必引入 3D SceneGraph

不要把“本地 SceneGraph 跑得动”误当成“以后天然可联机”。客户端创建的 local SceneGraph 不会自动发布、同步、持久化,也不会被服务端校验。

GameMode 与启动时序 ​

一个 code_sample 或地图工程里可能同时存在多个 IGameClass。新增样例时,每个启动入口都应先按当前 GameMode 过滤:

csharp
public static void OnRegisterGameClass()
{
    Game.OnGameTriggerInitialization -= RegisterRuntimeCallbacks;
    Game.OnGameTriggerInitialization += RegisterRuntimeCallbacks;
}

private static void RegisterRuntimeCallbacks()
{
    if (Game.GameModeLink != MySample.ScopeData.GameMode.MySample)
    {
        return;
    }

    Game.OnGameStart -= HandleGameStart;
    Game.OnGameEnd -= HandleGameEnd;
    Game.OnGameStart += HandleGameStart;
    Game.OnGameEnd += HandleGameEnd;
}

OnGameStart 中建议等待一帧再创建 UI、SceneGraph 或 Viewport,避免和框架场景、GameMode、UI 根节点初始化顺序抢跑:

csharp
private static async Task HandleGameStartAsync()
{
    await Game.Delay(TimeSpan.Zero);
    instance?.Destroy();
    instance = new MyClientSceneGraphGame();
    instance.Initialize();
}

这一帧延迟只用于避开框架初始化抢跑,不保证客户端 SCE 场景已经加载完成。GameStart 表示游戏脚本生命周期已经开始;客户端收到场景切换通知、加载地图资源并建立底层 SceneGraph 可能发生在它之后。

Game.LocalScene 在此期间仍会返回非空场景,以便音效等不依赖地图数据的 Actor 正常创建。如果 Game.LocalScene.IsEngineFallback 为 true,它只是引擎提供的后备场景上下文,不对应 GameDataScene,也不能用于地形、导航、场景碰撞、预放置对象或 SCE SceneGraph 操作。

依赖已加载地图的初始化应绑定明确的 SceneList 场景,并等待加载完成:

csharp
var scene = Scene.GetOrCreate(ScopeData.GameDataScene.new_scene);
if (scene.Loaded)
{
    InitializeMapDependentContent(scene);
}
else
{
    scene.OnLoadEnd += () => InitializeMapDependentContent(scene);
}

客户端可通过 scene.LoadState 区分 NotLoaded、Loading、Loaded 和 Failed。OnLoadEnd 只在加载成功时触发;多场景项目可注册 EventSceneLoadStart / EventSceneLoadEnd,根据事件中的 Scene 处理每次切换。不要用固定延迟猜测地图何时可用。

结束时要反订阅、移除 UI、释放 SceneGraph,并把静态 instance 置空。否则重新调试或切换 GameMode 时,旧节点、旧输入事件和旧 viewport 容易残留。

SceneGraph 可见性 ​

创建 Node 和 StaticMeshComponent 不等于画面会显示。独立 local SceneGraph 至少需要:

  • new SceneGraph(createOctree: true) 或显式 EnsureOctree()
  • 可见 Mesh,例如 StaticMeshComponent.Mesh = ...
  • 可渲染材质
  • Camera:CameraComponent
  • Viewport:通常用 Renderer.SetupMainViewport(sceneGraph, camera)
  • 合理的 NearClip / FarClip、节点位置和 LocalScale

Lit 材质还需要正常光照和法线。用代码生成 Mesh 时,至少为每个顶点写入 position、normal;如果 technique 或材质使用贴图,再写 UV。没有 normal 的几何体可能看起来全黑、受光异常,或让材质排查误判成 shader 问题。

调试不可见物体时,先在旁边创建一个已知可见的 PBR witness,例如白色球或金色金字塔。这样可以快速区分:

  • 场景、相机、viewport 没到位
  • 节点位置 / scale / clipping 有问题
  • 只有某个材质或 technique 不可见

物理驱动与节点同步 ​

客户端本地物理推荐让 RigidBody 驱动连续运动:

csharp
body.LinearVelocity = new Vector3(input.X, input.Y, 0f) * speed;
body.WakeUp();

不要在同一帧里一边写 node.Position,一边又让动态 RigidBody 模拟同一个对象。物理世界会把刚体结果回写到节点 transform,手动改节点容易造成抖动、穿透或下一帧被物理覆盖。

常见规则:

  • 连续移动:改 RigidBody.LinearVelocity、施力或 impulse。
  • 瞬移 / 重置:设置节点位置后清理速度,必要时 WakeUp()。
  • 不参与物理、只做视觉动画:不用 RigidBody,直接改 Node transform。
  • 只做触发范围:用 trigger 碰撞体或脚本距离检测,不要给它不必要的动态质量。

如果以后迁移到服务端权威物理,服务端仍然让 local RigidBody 推进 replicated Node;客户端接收 replicated transform 后再挂 local mesh、材质、粒子和音效。

输入控件 ​

纯 SceneGraph 游戏没有 Unit 或 MainUnit,但仍可复用 GameSystemUI 的平台自适应移动控件。使用输入事件模式:

csharp
var root = GSUI.GameDataControl.MoveControl.Data!.CreateControl();
root.AddToVisualTree();

var move = root.GetMoveControl();
if (move != null)
{
    move.UseMainUnit = false;
    move.BindUnit = null;
    move.OnMoveInput += OnMoveInput;
}

常规相机相对世界平面控制读取 MoveInputEventArgs.WorldDirection;固定沿 Z 轴观察 XY 平面、画面上方为世界 +Y 的二维正交相机,应读取原始 Direction 并映射为 (X, -Y)。默认镜头补偿在相机正前方向没有 XY 投影时,会回退到 SCE 游戏镜头的 Yaw + 90°,不保证符合该二维视图。UseMainUnit=false 并不关闭事件中的镜头补偿;转换输入时也不要重复翻转 TMX 元数据的位置。详细说明见 移动控件的输入坐标与镜头补偿。

销毁时必须取消 OnMoveInput 订阅并移除 UI 控件。

材质与 Technique 排查 ​

外部项目优先使用 SDK 的已知材质类型:

  • 普通纯色 / debug 几何:MaterialPBR 或 MaterialFlatLambert
  • 水面:MaterialWater
  • 特殊 technique:MaterialCustom + Technique.Find(...)

严格无光照 Unlit 或自定义 technique 可能依赖引擎资源和 shader cache 覆盖。把 technique XML 放到项目 res/ 下本地试验可以验证加载路径,但它不保证发布环境已有对应 shader variant。

物体不可见时,不要只靠截图猜。推荐顺序:

  1. 创建一个 PBR witness 几何体验证场景和相机。
  2. 用 Runtime MCP scenegraph.materials 或 material.probe 查运行时材质、technique path、pass、alpha、RenderOrder、CullMode、Stencil。
  3. RenderDoc 里确认是否有 draw call、pass 名是否被当前 RenderPath 请求。
  4. 再考虑替换 technique 或新增材质封装。

更多细节见 docs/ai/skills/gamegraph-materials/SKILL.md。

验证闭环 ​

每次改完可见行为后,至少做一次:

powershell
dotnet build src\GameEntry.csproj -c Client-Debug
dotnet build src\GameEntry.csproj -c Server-Debug

纯客户端调试时,构建后还要确认最新 GameEntry.dll 已进入 ui/AppBundle/managed。用项目生成的 ai/tools/Start-SceClientOnlyDebug.ps1 -ResolveOnly 可以检查启动参数、工作目录、日志目录和 DLL 新鲜度。

可见性验证优先使用 Runtime MCP:

  • debug.ping:确认连接到当前客户端。
  • debug.capture_screenshot:保存真实画面。
  • scenegraph.materials / material.probe:检查材质与 pass。
  • ui.snapshot / ui.find:检查 GameUI 控件是否存在、是否可见。

日志位置不要猜项目根目录。编辑器调试通常在编辑器运行目录的 logs/client / logs/server,纯客户端 helper 会输出实际 logsRoot。