主题
2D 动画与序列帧选型
WasiCore 有三种常用 2D 动画路线:
Sprites:原生 GameUI 控件,适合 UI 元素上的简单序列帧。AnimatedImageSource+CanvasAnimated:脚本层 Canvas 绘制,适合 2D 游戏对象和复杂动画数据。AnimatedSprite2D+AnimationSet2D:GameGraph 场景对象,适合把 Spriter 或 Spine 放进 3DSceneGraph。
先判断需求是“UI 控件动画”“Canvas 绘制对象”还是“3D 场景中的 2D 动画对象”。不要因为它们都能显示 2D 图片就混用。
选型规则
| 需求 | 选择 |
|---|---|
| 加载动画、按钮光效、头像框、面板装饰、一次性 UI 特效 | Sprites |
| 单张图或文件夹序列按 FPS 循环播放,无命名状态 | Sprites |
| 需要参与 GameUI 布局、点击、透明度、ZIndex、UI inspector | Sprites |
| 2D 角色、敌人、飞行物、平台游戏对象 | AnimatedImageSource + CanvasAnimated |
| 一张图集有 idle/walk/attack/death 等命名动画 | AnimatedImageSource + CanvasAnimated |
| 需要图层、命中框、挂点、逐帧偏移、播放进度或调试框 | AnimatedImageSource + CanvasAnimated |
| 很多对象共享同一个渲染循环和相机/物理坐标转换 | CanvasAnimated |
| Spriter / SCML 动画要放进 3D 场景 | AnimatedSprite2D |
| Spine 角色或特效要参与 3D 场景相机、透视或深度遮挡 | AnimatedSprite2D |
| UI 面板里的 Spine 展示 | GameUI Spine 控件,不用 AnimatedSprite2D |
Sprites 用法
普通单图 sprite sheet 必须配置帧尺寸、总帧数和每行帧数:
csharp
#if CLIENT
using GameUI.Control.Extensions;
using GameUI.Control.Primitive;
var pulse = new Sprites()
.Image("image/effects/energy_pulse_sheet.png")
.SpriteSheet(frameWidth: 128, frameHeight: 128, frameCount: 8, framesPerRow: 8)
.SpriteFrames(startFrame: 0, endFrame: 7)
.Size(128, 128)
.AddToVisualTree();
pulse.FramePerSecond = 8;
pulse.IsLooping = true;
pulse.Play();
#endif文件夹序列通常只需要设置图片路径、速度和播放状态:
csharp
#if CLIENT
var sparkle = new Sprites()
.Image("image/effects/sparkle_sequence")
.Size(96, 96)
.AddToVisualTree();
sparkle.FramePerSecond = 12;
sparkle.IsLooping = true;
sparkle.Play();
#endifSprites 语义要点:
SpriteSize/FrameSize:普通单图模式下的单帧像素尺寸;文件夹序列模式忽略。FrameCount:普通单图模式下的总帧数;文件夹序列模式下是播放上限,实际不超过子图数量。RowFrameCount/FramesPerRow:普通单图模式下每行帧数;文件夹序列模式忽略。StartFrame/EndFrame:播放范围,0 起始。.Image("image/foo.png")使用 UI 图片路径,不写ui/前缀,不放user_files。
AnimatedImageSource 用法
需要游戏循环、命名动画、图层或命中框时,用 GameDataAnimatedImage + AnimatedImageSource,在 CanvasAnimated.OnAnimatedRender 中绘制:
csharp
#if CLIENT
using GameCore.Animation;
using GameUI.Control.Primitive;
using GameUI.Control.Primitive.Struct;
private static CanvasAnimated? canvas;
private static AnimatedImageSource? hero;
private static void CreateScene()
{
hero = new AnimatedImageSource(MyGameData.HeroAnimatedImage);
hero.PlayAnimation("idle", canvasElapsedMilliseconds: 0);
canvas = new CanvasAnimated();
canvas.FullScreen();
canvas.StartTiming();
canvas.OnAnimatedRender += DrawFrame;
canvas.AddToVisualTree();
}
private static void DrawFrame(object? sender, CanvasAnimatedEventArgs e)
{
if (canvas == null || hero == null)
{
return;
}
canvas.ResetState();
canvas.DrawAnimatedImage(hero, x: 320f, y: 480f);
}
#endifAnimatedSprite2D 用法
需要让 2D 动画成为 3D 场景中的对象时,用 GameGraph AnimatedSprite2D。新项目优先按 UI 资源惯例放资源:Spriter/序列帧和图片放 ui/image/...,Spine 放 ui/spine/...。代码路径从 ui/ 下一级开始,不写 ui/ 前缀,并且保留 .scml、.skel 或 .json 扩展名。旧项目的当前项目 res/... 路径仍会作为 fallback 兼容。
csharp
#if CLIENT
using GameGraph.NodeSystem;
using GameGraph.NodeSystem.Component.Graphics;
using GameGraph.ResourceSystem;
using System.Numerics;
var sceneGraph = new SceneGraph(createOctree: true);
var spineSet = AnimationSet2D.Load("spine/spineboy/spineboy.skel");
if (spineSet != 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.SetAnimation("walk", LoopMode2D.ForceLooped);
}
#endifAnimatedSprite2D 不是自动 billboard,不受 3D 灯光照亮;它按节点世界变换参与透视相机和深度关系,场景融合通常通过 Color / Alpha 手动调色。完整样例见 map_templates/code_sample/src/AnimatedSprite2DSample/。
界面 Spine 与 AnimatedSprite2D 的尺寸
同一套 Spine 资源在两条路线里的大小算法不同。生成代码时按目标路线选尺寸,不要互相抄数值。
界面 Spine:
- 用
Width/Height(数据里是Layout宽高)作为装入区域。 - 用资源骨架 setup 包围盒对控件做等比缩放,取宽、高两个比例中较小的一个,不拉伸。
- 剩余空间用
HorizontalContentAlignment/VerticalContentAlignment对齐,不是父级对齐用的HorizontalAlignment/VerticalAlignment。 - 不要设置不存在的骨架缩放属性。布局刷新会覆盖手动写入
Skeleton的缩放;翻转用ImageFlipX/ImageFlipY。
AnimatedSprite2D:
- 加载时 Spine 坐标乘以
0.01,约 100 个 Spine 单位为 1 个世界单位。 - 骨架缩放在动画更新时重置为翻转用的 ±1,不会按包围盒自动适配。
- 用节点
LocalScale/ 世界缩放改变大小,不要给精灵设置界面式的Width/Height。
完整说明见 GameGraph 2D 动画组件。
场景精灵射线命中
为 StaticSprite2D / AnimatedSprite2D 生成点击选取或命中逻辑前,先区分 Octree 绘制对象查询与物理碰撞查询:
OctreeComponent.Raycast/RaycastSingle的常规Aabb、Obb、Triangle、TriangleUv级别均检测当前绘制顶点的世界 AABB。Spriter 序列帧与 Spine 都把当前帧或姿态的全部部件合并成一个包围盒;透明区域、部件间空隙及旋转后的 AABB 空白也可能命中。不要承诺TriangleUv能识别不透明像素。- 结果指向精灵所属
Node与组件DrawablePointer;Normal是射线方向的反向,TextureUV未计算,SubObject为uint.MaxValue,不能识别 Spine 骨骼、slot 或 attachment。 - 使用默认
DrawableQueryFlags.Any,再按节点或组件筛选;2D 精灵经Renderer2D的Geometry入口进入 Octree,单独传Geometry2D无法选中该入口。不要把Layer/OrderInLayer当成RaycastSingle的最上层不透明像素保证。 PhysicsWorld/PhysicsWorld2D只查询另外配置的碰撞形状。需要精确战斗命中时使用项目命中框、碰撞形状或更细的检测;渲染组件不会自动生成这些数据。
完整字段和范围见 GameGraph 2D 动画组件。
纹理发白排查
当 GameGraph Spine 或 Spriter / SCML 动画比原始 PNG 明显发白时,先检查 sRGB 解码,不要直接生成用 Color 压暗的补偿代码。对于按 sRGB 创作的颜色贴图,为 atlas 页或 Spriter 散图的每张 PNG 添加同目录、同文件名的纹理参数 XML,例如 hero.png 对应 hero.xml:
xml
<texture>
<srgb enable="true" />
</texture>- Spine 会按
.atlas引用的每张页图片读取同名 XML;多页 atlas 每页都要配置。 - Spriter / SCML 的多张图片应使用完全一致的纹理参数。参数一致时运行时仍可合并内部图集;只有部分图片存在 XML 或参数冲突时会分别加载并记录警告。
- 法线、遮罩、查找表等线性数据纹理不要开启 sRGB。
- 修改后重新启动 Game View 或重新进入游戏局,避免动画集和纹理缓存影响验证。
- 只有颜色已经正确、项目仍希望适配场景氛围时,才使用
Color/Alpha做艺术性调色。 - 黑边、白边等透明边缘问题应另行检查 Spine atlas 的
pma: true与贴图是否实际预乘;PMA 和 sRGB 是两个不同问题。
生成代码时避免的错误
- 不要为了简单 UI 光效创建
CanvasAnimated。 - 不要用
Sprites实现复杂角色状态机、命中框或多图层动画。 - 不要把需要出现在 3D 场景里的 Spine 先画到 Canvas 再转贴图;优先用
AnimatedSprite2D。 - 不要把 UI
Spine.Resource = "Spine/hero/hero"的路径照搬给AnimationSet2D.Load(...);GameGraph 可以使用AnimationSet2D.Load("spine/hero/hero.skel"),但必须写出.skel或.json后缀,且不写ui/前缀。 - 不要用界面
Spine的Width/Height去设置AnimatedSprite2D的节点缩放,也不要给场景 Spine 查找控件式宽高。尺寸规则见“界面 Spine 与 AnimatedSprite2D 的尺寸”。 - 不要在动画比原图发白时直接用
Color压暗;先按“纹理发白排查”检查同名纹理 XML 和 sRGB 设置。 - UI Spine 出现黑边时,检查
.atlas页是否与贴图一致地声明pma: true。PMA 读取不区分 4.x 和 3.8,UISpine也不会因 3.8.skel自动开启预乘;只有确认贴图本身已经预乘时才补该标记。完整说明见docs/guides/ResourcePathGuide.md#ui-spine-动画。 - 不要把单图 sprite sheet 只写成
.Image(...).Size(...);必须加.SpriteSheet(...)。 CanvasAnimated必须调用StartTiming();动态绘制每帧开头通常调用canvas.ResetState()。- 不再复用的
CanvasAnimated要取消事件订阅并Destroy();不要只RemoveFromParent(),也不要只依赖父容器ClearChildren()。 - Canvas 绘制内容通常不出现在
ui.snapshot中,视觉验证用截图;Sprites可以用 UI inspector 查控件树。
继续阅读
docs/systems/SpriteAnimationChoiceGuide.mddocs/systems/GameGraph2DAnimation.mddocs/systems/CanvasDrawingSystem.mddocs/systems/AnimatedImageSourceSystem.mddocs/guides/ResourcePathGuide.md