主题
直接 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,直接改Nodetransform。 - 只做触发范围:用 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。
物体不可见时,不要只靠截图猜。推荐顺序:
- 创建一个 PBR witness 几何体验证场景和相机。
- 用 Runtime MCP
scenegraph.materials或material.probe查运行时材质、technique path、pass、alpha、RenderOrder、CullMode、Stencil。 - RenderDoc 里确认是否有 draw call、pass 名是否被当前 RenderPath 请求。
- 再考虑替换 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。