Skip to content

GameGraph 2D 动画组件 ​

GameGraph 提供 StaticSprite2D 和 AnimatedSprite2D,用于在客户端 SceneGraph 中显示 2D 图片、Spriter 序列帧动画和 Spine 骨骼动画。它们走 2D 渲染路径,但可以放在 3D 场景里,和 3D 几何体一起被相机观察。

> 这些 API 均仅客户端可用,需要写在 #if CLIENT 内。


适用场景 ​

需求推荐 API
在 3D 场景里放一张 2D 图片或装饰牌StaticSprite2D + Sprite2D
在 3D 场景里播放 Spriter / SCML 序列帧动画AnimatedSprite2D + AnimationSet2D.Load("...scml")
在 3D 场景里显示 Spine 角色或特效AnimatedSprite2D + AnimationSet2D.Load("...skel") 或 .json
UI 界面里的 Spine 角色展示GameUI Spine 控件
Canvas 绘制型全屏序列帧游戏Sprites 或 AnimatedImageSource + CanvasAnimated

如果对象属于 UI 层,优先使用 GameUI 控件;如果对象需要进入 3D 场景的透视、遮挡和深度关系,使用 GameGraph 2D 动画组件。

StaticSprite2D / AnimatedSprite2D 挂入 SceneGraph 后会自动创建或复用场景的 Renderer2D,并自动注册到渲染器。普通场景对象不要再手动调用 Renderer2D.AddDrawable(...);该方法只用于没有通过正常场景生命周期注册的特殊对象。同一对象的重复添加会被忽略。


资源路径 ​

GameGraph 2D 动画资源优先按 UI 资源惯例放在项目 ui/ 下:Spriter / SCML 序列帧和配套图片放 ui/image/...,Spine 的 .skel / .json、.atlas 与贴图放 ui/spine/...。代码路径从 ui/ 下一级开始,不写 ui/ 前缀,并且 AnimationSet2D.Load 需要写动画数据文件的扩展名。

text
MyProject/
└── ui/
    ├── image/
    │   └── Animated/
    │       ├── coin.scml
    │       └── coin/
    │           └── coin.png
    └── spine/
        └── spineboy/
            ├── spineboy.skel
            ├── spineboy.atlas
            └── spineboy.png
csharp
#if CLIENT
var coinSet = AnimationSet2D.Load("image/Animated/coin.scml");
var spineSet = AnimationSet2D.Load("spine/spineboy/spineboy.skel");
#endif

这和 UI Spine 的路径规则不同:UI Spine 放在 ui/Spine/...,Spine.Resource 从 Spine/ 开始且不写 .skel 后缀。GameGraph AnimationSet2D 是资源缓存加载,路径要包含 .scml、.skel 或 .json。

AnimationSet2D.Load("image/...") 和 AnimationSet2D.Load("spine/...") 会按 UI 系统的依赖库优先级查找资源;显式 @gameui/spine/... 这类路径只从指定库查找。若 UI 资源路径下找不到,运行时会 fallback 到当前项目 res/,旧项目中 AnimationSet2D.Load("Animated/spineboy/spineboy.skel") 这类 res/ 路径仍可继续使用。

如果 Spine 图集引用多张 png,需要让 .atlas 中引用到的图片和导出时的相对路径保持一致。项目本地 ui/ / res/ 资源不需要额外走云端资源收集;云端资源仍按资源库和发布清单规则处理。


基本用法 ​

csharp
#if CLIENT
using GameGraph.NodeSystem;
using GameGraph.NodeSystem.Component.Graphics;
using GameGraph.ResourceSystem;
using System.Drawing;
using System.Numerics;

var sceneGraph = new SceneGraph(createOctree: true);

var cameraNode = sceneGraph.CreateChild("Camera");
cameraNode.Position = new Vector3(0f, 1.5f, -6f);
_ = cameraNode.LookAt(new Vector3(0f, 0f, 0f));

var camera = cameraNode.CreateComponent<CameraComponent>();
camera!.IsOrthographic = false;
camera.Fov = 46f;
camera.AutoAspectRatio = true;

var spineSet = AnimationSet2D.Load("spine/spineboy/spineboy.skel");
if (spineSet is not null)
{
    var node = sceneGraph.CreateChild("SpineBoy");
    node.Position = new Vector3(0f, -1.2f, 0f);
    node.LocalScale = new Vector3(0.35f);

    var sprite = node.CreateComponent<AnimatedSprite2D>();
    sprite!.AnimationSet = spineSet;
    sprite.Skin = "default";
    sprite.Layer = 10;
    sprite.OrderInLayer = 0;
    sprite.Color = Color.FromArgb(255, 190, 208, 224);
    sprite.SetAnimation("walk", LoopMode2D.ForceLooped);
}

var viewport = new SceneGraphMainViewportController();
viewport.Bind(sceneGraph, camera!);
#endif

界面 Spine 与场景 Spine 的尺寸 ​

界面 Spine 和 AnimatedSprite2D 可以播放同一套 Spine 资源,但显示大小不是同一套规则。不要把控件的 Width / Height 抄成节点的 LocalScale,也不要给场景精灵设置控件式宽高,指望骨架自动装进某个矩形。

界面 Spine 把骨架装进控件的布局框:

  • 目标区域是控件的 Width 和 Height。数据模板里对应 Layout 的宽高。
  • 参照区域默认是资源里骨架的 setup 包围盒(x、y、width、height),不是贴图像素尺寸。
  • 分别用控件宽除以包围盒宽、控件高除以包围盒高,取较小的一个作为统一缩放。因此保持比例,多出来的一边留空。
  • 留空按 HorizontalContentAlignment 和 VerticalContentAlignment 摆放。这是控件内部的内容对齐,不是控件在父级里的 HorizontalAlignment / VerticalAlignment。
  • 加载时不把 Spine 坐标换成世界单位。C# 没有单独的骨架缩放属性;控件尺寸、翻转或内容对齐变化时会重算,并覆盖直接写到 Skeleton 上的缩放。翻转用 ImageFlipX / ImageFlipY。

AnimatedSprite2D 按世界单位绘制,不自动适配矩形:

  • 加载 .skel / .json 时把 Spine 坐标乘以 0.01。节点缩放为 1 时,大约 100 个 Spine 单位对应 1 个世界单位。
  • 之后骨架缩放只表示翻转,动画更新时会重置为 ±1,不会根据包围盒或某个宽高再缩放。
  • 最终大小由节点的 LocalScale 和父节点世界缩放决定,并随透视相机近大远小。上一节示例里的 node.LocalScale = new Vector3(0.35f) 就是在这个世界尺寸上再缩小。
  • GetSpineInstance() 与界面 Spine 返回同一类型,但在场景精灵上改 Skeleton 缩放同样会在下一帧动画更新时被重置。要改变显示大小,改节点缩放。

Spine 事件与高级控制 ​

事件可直接订阅组件的 OnStart、OnComplete、OnEvent 等回调。需要多轨道动画、混合或骨骼操作时,在设置 Spine AnimationSet 后调用 GetSpineInstance();它返回与 UI Spine 相同类型的 SpineInstance。先订阅,再播放动画,才能收到首次 OnStart。

csharp
sprite.AnimationSet = spineSet;
sprite.OnComplete += entry => Game.Logger.LogInformation("Completed {Name}", entry.AnimationName);

var instance = sprite.GetSpineInstance();
instance.AnimationState.OnEvent += (entry, spineEvent) =>
    Game.Logger.LogInformation("Event {Name}", spineEvent.Name);
instance.AnimationState.SetMix("idle", "walk", 0.2f);
sprite.SetAnimation("idle", LoopMode2D.ForceLooped);
var head = instance.Skeleton.FindBone("head");

订阅组件事件或 instance.AnimationState 事件中的任意一种,才会启用原生监听器并把动画事件发送到 WASM;全部取消后关闭监听器。两种入口共用同一条事件通道。更换动画集后,实例随组件指向新的动画状态与骨骼,但旧的 SpineTrackEntry、骨骼和插槽对象不可再使用。GetSpineInstance() 需要支持该能力的新版客户端二进制;仅升级 WASM 依赖库不足以启用。

完整样例 ​

map_templates/code_sample 里提供了可直接运行的 AnimatedSprite2DSample:

内容路径
示例代码code_sample/src/AnimatedSprite2DSample/
示例资源code_sample/res/AnimatedSprite2DSample/
GameModeAnimatedSprite2DSample

它同时包含 Spriter / SCML 金币序列帧和 SpineBoy 骨骼动画,并把两个 AnimatedSprite2D 放到一个带 3D 盒子、地板、透视相机和 LightGroup 的 SceneGraph 中。教程或 AI 生成代码时,优先引用这个目录作为完整参考。


3D 场景里的表现规则 ​

StaticSprite2D 和 AnimatedSprite2D 不是自动 billboard。它们会把 2D 顶点乘以节点的世界变换,作为一张位于 3D 世界中的 2D 平面或多边形渲染。

行为说明
朝向由节点旋转决定,不会自动朝向相机
近大远小透视相机会近大远小;正交相机不会
侧面观察绕到侧面会看到薄片效果;默认材质双面可见
深度关系可以和 3D 几何体形成前后遮挡关系
光照默认 Urho2D 材质不受 3D Light / Zone 光照影响
阴影默认不接收也不投射 3D 阴影

如果希望 2D 角色始终面向相机,需要项目代码自己控制节点朝向,例如每帧 LookAt(cameraNode.Position),或只绕竖直轴转向相机。默认组件不会自动做这件事。


排序、遮挡与混合 ​

2D 动画先按 Layer 和 OrderInLayer 排序,再参与材质批次和距离排序。通常使用更高的 Layer 表示更靠上的 2D 层,使用 OrderInLayer 处理同层内多个对象的前后顺序。

csharp
sprite.Layer = 10;
sprite.OrderInLayer = 3;

默认材质关闭深度写入,透明混合更稳定,但这也意味着多个透明对象之间的排序仍应依赖 Layer / OrderInLayer 和合理的节点距离设计。需要非常特殊的深度或 shader 行为时,可以通过 StaticSprite2D.CustomMaterial 指定自定义材质。

单纹理精灵可使用 MaterialUrho2D,配置主纹理、材质乘色、混合方式、深度测试及深度写入;也可从 KnownTechnique.Urho2D.Clone() 创建独立方案。它复用现有 Urho2D shader,不增加描边等新效果。自定义材质需要自行绑定纹理;动画中的所有批次会共用该材质,不能直接覆盖多图集或混合方式不同的动画部件。


射线命中与碰撞范围 ​

OctreeComponent.Raycast(...) / RaycastSingle(...) 查询 StaticSprite2D 或 AnimatedSprite2D 时,常规的 Aabb、Obb、Triangle、TriangleUv 级别均按当前绘制顶点的**世界轴对齐包围盒(AABB)**检测。选择 Triangle 或 TriangleUv 不会启用逐三角形或像素 Alpha 检测。

对象命中范围
StaticSprite2D图片绘制矩形经节点世界变换后的 AABB
Spriter / SCML 序列帧当前帧所有绘制部件的顶点合并成一个 AABB
Spine当前姿态所有绘制顶点合并成一个 AABB,随动画变化

透明像素、四肢或部件之间的空隙,以及旋转后图片外侧仍在 AABB 内的区域,都可能被命中。它适合粗略选取;需要精确战斗命中时,应由项目配置命中框、物理碰撞形状或实现更细的检测。

命中结果 OctreeRaycastResult 的含义如下:

字段2D 精灵命中时的含义
Node精灵组件所属节点
DrawablePointer被命中的精灵组件;可用 TryGetDrawable<AnimatedSprite2D>() 获取已注册的包装对象
Position / Distance射线命中包围盒的位置与距离
Normal射线方向的反向,不是图片平面或网格的真实表面法线
TextureUV未计算命中 UV,不能据此采样贴图判断透明度
SubObjectuint.MaxValue,不提供骨骼、slot、attachment 或帧内部件编号

查询掩码建议保留默认 DrawableQueryFlags.Any,再根据返回的节点或组件筛选目标。2D 精灵通过 Renderer2D 接入 Octree,该入口使用 DrawableQueryFlags.Geometry;单独传 Geometry2D 无法选中它,当前入口也不会按每个精灵的 drawableFlags 逐项筛选。Layer / OrderInLayer 是绘制排序参数,不能据此把 RaycastSingle 的结果当成屏幕最上层的不透明像素。

PhysicsWorld.Raycast(...) 与 PhysicsWorld2D.RaycastSingle(...) / RaycastAll(...) 查询的是各自配置的物理碰撞形状。仅添加 AnimatedSprite2D 不会自动生成物理碰撞,也不会把 Spine 骨骼、attachment 或透明轮廓转换成碰撞形状。上述说明补充现有行为,不改变运行时检测方式。


贴图色彩空间、光照融合与手动 tint ​

Urho2D 默认 shader 是 unlit,颜色主要来自:

text
texture color * vertex color * material MatDiffColor

因此场景里的 DirectionalLight、PointLight、Zone 环境光不会直接照亮默认材质的 AnimatedSprite2D。但精灵绘制后的全屏 Bloom、色调映射或调色仍会改变画面,其中一些处理会读取 LightGroup/Zone 的氛围参数。专用二维主视图的路径、颜色出口与可复用样例见二维渲染路径与颜色指南。混合 3D 场景应保留其所需渲染步骤。要让 Spine 或 Spriter 更贴合场景,常用做法是根据场景环境手动设置 Color 或 Alpha。

如果动画未经项目调色就比原图明显发白,应先检查贴图色彩空间,而不是用 Color 掩盖差异。对于按 sRGB 创作和导出的颜色贴图,在每张 PNG 同目录添加同名纹理参数 XML;例如 hero.png 对应 hero.xml:

xml
<texture>
    <srgb enable="true" />
</texture>

GameGraph Spine 会按 .atlas 中的页图片逐张读取对应 XML。Spriter / SCML 也支持读取每张散图的同名 XML;多帧使用完全相同的纹理参数时仍会合并成一张内部图集,只有部分图片存在 XML 或参数不一致时会改为分别加载并在日志中给出警告。法线、遮罩、查找表等本来就按线性空间使用的数据纹理不要开启 sRGB。

修改 XML 后应重新启动 Game View 或重新进入游戏局,避免已经加载的动画集和纹理缓存继续显示旧结果。若颜色已经与原图一致,只是希望它更贴合场景氛围,再使用 Color / Alpha 做艺术性调色:

csharp
sprite.Color = Color.FromArgb(255, 190, 208, 224); // 偏冷、略压暗
sprite.Alpha = 0.9f;

这不是独立的光照通道,而是贴图乘色。真正的受光材质需要自定义 shader、法线或额外顶点数据,成本明显更高。


API 速查 ​

AnimationSet2D ​

成员说明
Load(path)加载 .scml、.skel 或 .json 动画集
NumAnimations动画数量
GetAnimation(index)按索引获取动画名
HasAnimation(name)检查动画是否存在

AnimatedSprite2D ​

成员说明
AnimationSet要播放的动画集
Animation当前动画名;设置时使用默认循环策略
SetAnimation(name, loopMode)播放指定动画并控制循环策略
EntitySpriter entity 名;Spine 下等价于 skin
Skin / SetSkin(name)Spine skin 语义别名
Speed播放速度倍率
Color / Alpha继承自 StaticSprite2D,用于手动 tint 和淡入淡出
Layer / OrderInLayer继承自 Drawable2D,用于 2D 排序

LoopMode2D ​

值说明
Default使用资源中的默认循环设置
ForceLooped强制循环
ForceClamped强制停在最后一帧

常见问题 ​

问题处理方式
加载 Spine 返回 null新项目优先检查文件是否在 ui/spine/... 下、代码是否不含 ui/ 前缀、是否写了 .skel 或 .json 扩展名;旧 res/ 路径继续兼容
Spine / Spriter 比原图明显发白先为每张颜色 PNG 添加同名纹理参数 XML,并配置 <srgb enable="true" />;修改后重新启动 Game View。不要先用 Color 掩盖色彩空间错误
动画颜色正确,但需要贴合场景氛围使用 Color / Alpha 手动调色
角色没有跟着镜头转这是默认行为;需要项目代码自己控制节点朝向
远处角色变小透视相机正常现象;改用正交相机或根据距离动态缩放
UI Spine 路径照搬后加载失败UI Spine 和 GameGraph AnimationSet2D 路径规则不同
同一套 Spine 在界面和场景里大小差很多界面 Spine 按控件宽高等比装入;场景 AnimatedSprite2D 先按大约 100 个 Spine 单位对应 1 个世界单位换算,再用节点 LocalScale 缩放
多个透明对象前后不稳定调整 Layer / OrderInLayer,并避免大量透明面在同一深度重叠