Skip to content

GameGraph 3D 骨骼动画 ​

WasiCore 提供两级 3D 骨骼动画 API。两级共用同一份底层资源,可以从高层下钻到低层做精细控制,反之亦然。

层级模型动画控制适用场景
Actor 层ActorModel(实现 IActorAnimationPlayable)Animation / AnimationSequence / AnimationBracket数编驱动,与 Unit / Entity 绑定,优先级压制、序列编排、生死元组
GameGraph 层AnimatedModelComponentAnimationController纯代码控制,直接操作 Node,适合 SceneGraph 独立游戏、过场 prop、装饰物

Actor 层底层用到的就是同一对 AnimatedModelComponent / AnimationController,所以 GameGraph 层的任意 API 都可以拿来对 Actor 角色生效。两层并不互斥 —— Actor 节点上同时可以用 GameGraph wrapper 下钻控制;最佳实践是 Actor 驱动的对象用 Actor API,其他场景用 GameGraph API。

> 两级 API 均仅客户端可用(#if CLIENT)。


选型速查 ​

需求推荐方案
数编配置的单位 / 角色动画ActorModel + IActorAnimationPlayable
技能 / Buff 释放时的动画GameDataAbilityActive.Animation + Actor 层
复杂序列(多段动画 + 触发事件 + 临时生成 Actor)AnimationSequence
生死元组(出生 → 待机 → 死亡)AnimationBracket
SceneGraph 独立游戏中的角色AnimatedModelComponent + AnimationController
过场动画里的临时模型(不进入战斗体系)AnimatedModelComponent + AnimationController
装饰物 / 背景动画(风车、旗帜、机关)AnimatedModelComponent + AnimationController
拿到 Actor 内部的 AnimationController 做精细控制actor.GetAnimationController()
拿到 Actor 内部的 AnimatedModel 改属性actor.GetAnimatedModel()
从 Actor 的 Animation 拿对应运行时 AnimationStateanimation.GetAnimationState()
查询动画长度做 UI 进度条AnimationResource.Load(...).Length
把武器 / 特效挂到角色骨骼上挂点名用 GetSocket("socket_weapon_r");真实骨骼名用 GetBoneNode("Bip001 R Hand")
按宿主骨架自动对齐装备、外套等附加模型自动挂载模型表现的 GameGraph 下钻接口:Prefab 主骨骼模型的 AttachAdditionModel(...)
遍历模型骨骼(调试 UI 等)animatedModel.NumBones + GetBoneByIndex(i)
上半身转向(yaw)animatedModel.SetBoneFacing("bone_spine_01", yaw)
任意 3 轴朝向(pitch / yaw / roll)animatedModel.SetBoneFacing("bone_spine_01", Quaternion.CreateFromYawPitchRoll(y, p, r))
改骨骼 PostProcess transform(IK / 物理 solver)animatedModel.SetBonePostProcessTransform("...", pos, rot, scale)
暂时禁用某根骨骼的动画驱动animatedModel.SetBoneAnimated("...", false)
取 humanoid 模型的烘焙动画路径animatedModel.ComputeBakedAnimationPath("run.ani")

AnimatedModelComponent — 骨骼模型组件 ​

继承自 StaticMeshComponent。挂载带骨架的 .mdl 资源后,配合 AnimationController 驱动播放动画。

基本用法 ​

csharp
#if CLIENT
var characterNode = sceneGraph.CreateChild("Character");
var animated = characterNode.CreateComponent<AnimatedModelComponent>();

// 设 Mesh 自动建立骨架
animated.Mesh = Mesh.Load("Models/Character/hero.mdl");
animated.Material = Material.Load("Materials/Hero.xml");
animated.CastShadows = true;            // 继承自 Drawable

// 同节点再挂控制器
var ctrl = characterNode.CreateComponent<AnimationController>();
ctrl.Play("Animations/Idle.ani", layer: 0, looped: true);
#endif

完整 SetModel(手动控制骨架创建) ​

csharp
#if CLIENT
// 默认 Mesh setter 等价于以下完整签名:
animated.SetModel(mesh, createBones: true);

// 不建立骨架(把骨骼模型当静态用,性能更好)
animated.SetModel(mesh, createBones: false);
// 或:
animated.SetModelWithoutSkeleton(mesh);
#endif

用 prefab 一行装配(项目主流路径) ​

项目里大部分美术资产是 prefab(含 mesh / 材质 / 美术挂的灯 / sockets 等),一行装配最常见:

csharp
#if CLIENT
var prefab = GameGraph.ResourceSystem.Prefab.Load("characters/General/SK_Basic2/model.prefab");
var instance = prefab!.CreateInstance(parentNode);
#endif

⚠️ 坑:返回的 instance 是 PrefabInstance 根节点,组件不一定在它身上。 骨骼模型 prefab 通常使用两层节点结构:返回根节点负责组织,AnimatedModelComponent、材质和 sockets 位于其子节点。

直接 instance.GetComponent<AnimatedModelComponent>() 会返回 null。正确做法是用 GetComponentInChildren<T> 递归找:

csharp
#if CLIENT
var animatedModel = instance.GetComponentInChildren<AnimatedModelComponent>();
var controller = instance.GetComponentInChildren<AnimationController>();
var modelNode = animatedModel?.Node;   // 真正承载组件的子节点
// 后续 GetBoneNode / SetBoneFacing / SetStartBone 等操作基于 modelNode 或 animatedModel
#endif

与 .mdl 手动装配的对比:

维度.prefab + CreateInstance.mdl + Mesh.Load
代码量~5 行~30 行(含遍历材质设置)
美术配的灯 / 自定义脚本 / 物理一并实例化,与编辑器预览一致不带
材质参数用 prefab 里美术调好的参数走 .material 文件默认值
组件位置在 ArchetypeInstance 子节点上(需 GetComponentInChildren)自己 AddComponent,所在节点自己定
何时用默认(绝大多数场景)runtime 程序化拼装、自定义材质组合、模型只有 .mdl 没 .prefab

AnimatedModelComponent API 表 ​

附加模型的挂载、换装与清理见 GameGraph 附加模型挂载。它是“自动挂载模型表现”的下钻接口,要求宿主为 Prefab 实例的主骨骼模型,附加资源为骨骼 Prefab;上面的 .mdl 手动装配示例不满足这项条件。

属性 / 方法类型说明
MeshMesh?骨骼模型。设置时自动建立骨架
Material / MaterialsMaterial? / Material[]材质(继承 StaticMeshComponent)
SetModel(mesh, createBones)void完整 SetModel 接口
SetModelWithoutSkeleton(mesh)void不建骨架
AttachAdditionModel(prefab, options)AdditionModelAttachment挂载骨骼附加 Prefab,返回可 Detach() / Dispose() 的句柄;仅支持 Prefab 主骨骼宿主
AnimationLodBiasfloat (set)动画 LOD 偏置
UpdateInvisiblebool不可见时是否继续更新(布娃娃等建议 true)
UpdateBoneBoundingBox()void手动重算骨骼包围盒
SimpleSkinningbool普通硬件骨骼蒙皮开关;受设备最大骨骼数限制,开启后优先于其它路径
ComputeShaderSkinningboolCompute Shader 蒙皮开关;需 GPU 支持,且在 SimpleSkinning 关闭时生效
Move(Vector3 delta)void应用根运动位移
TrackOptimizationEnabledbool动画轨道优化开关
GetBoneNode(name)Node?取骨骼对应的场景 Node(挂载武器 / 特效首选)
SetBoneFacing(name, yaw)bool设骨骼 yaw 偏移(上半身转向,IndieRotation 机制)
SetBoneFacing(name, Quaternion)bool任意 3 轴朝向偏移(同上机制)
CancelBoneFacing(name)void取消骨骼朝向偏移
SetBonePostProcessTransform(name, pos, rot, scale)bool设骨骼 PostProcess 三字段(local space,一次性)
SetBonePostProcessWorldTransform(name, worldPos, worldRot)bool同上 world space 版本(scale 必须用 local)
SetBonePostProcessPosition / Rotation / Scale(name, ...)bool单字段 PostProcess 设置(不动其他字段)
ClearBonePostProcessTransform(name)bool清除指定骨骼 PostProcess 重置为默认
ClearAllBonesPostProcessTransform()void批量清骨架上所有 PostProcess
SetBoneAnimated(name, bool)boolper-bone 动画 enable 开关(跨所有动画)
GetSocket(name)Socket?取挂点完整信息(含相对偏移)
NumBonesuint骨骼总数
RootBoneIndexuint根骨骼索引
GetBoneByIndex(i)Bone?按 index 取骨骼快照
GetBoneByName(name)Bone?按名取骨骼快照
ComputeBakedAnimationPath(animName)string拼接 anim/{modelName}/{animName},用于 humanoid 烘焙动画解析

蒙皮方式与切换 ​

AnimatedModelComponent 提供三种骨骼蒙皮路径。它们使用同一套骨架和动画 API,区别在于顶点受骨骼变换的计算位置、资源共享方式和适用场景:

蒙皮方式切换方式特点与适用场景
SimpleSkinning(普通硬件蒙皮)model.SimpleSkinning = true顶点着色器直接读取当前模型的骨骼矩阵。路径最传统、调试最直观,不依赖场景级蒙皮纹理或 Compute Shader;适合小地图、画中画等需要优先规避共享蒙皮缓存问题的模型。骨骼数量和 GPU uniform 上限仍受设备限制;需要客户端能力版本 3。
TextureSkinning(纹理蒙皮)model.SimpleSkinning = false; model.ComputeShaderSkinning = false将骨骼矩阵写入蒙皮纹理图集,再由顶点着色器采样。通常是 CE 角色的默认非 Compute 路径,能支持更多骨骼并减少常量寄存器压力;当前场景级 atlas / Drawable 状态尚未完全按 View 隔离,同一角色被多个 View 同时绘制时应谨慎使用。
ComputeSkinning(Compute Shader 蒙皮)model.SimpleSkinning = false; model.ComputeShaderSkinning = true由 Compute Shader 预先计算蒙皮顶点,再交给后续绘制阶段。适合骨骼多、角色多或顶点量较大的场景,但需要 GPU/平台支持 Compute Shader;其输出 Geometry 也属于渲染状态,多个 View 复用同一 Drawable 时仍应注意 View 隔离问题。

切换时建议显式设置两个开关,避免沿用对象之前的状态:

csharp
#if CLIENT
var model = actor.GetAnimatedModel();
if (model != null)
{
    // 临时规避多 View 共享蒙皮状态:使用普通硬件蒙皮
    model.SimpleSkinning = true;
    model.ComputeShaderSkinning = false;
}
#endif

需要恢复默认 CE 非 Compute 路径时:

csharp
model.SimpleSkinning = false;
model.ComputeShaderSkinning = false; // TextureSkinning

需要使用 Compute 路径时:

csharp
model.SimpleSkinning = false;
model.ComputeShaderSkinning = true;  // ComputeSkinning

SimpleSkinning 是模型级开关,不是 View 级开关;它只能选择该模型当前使用的计算路径,不能解决同一 Drawable 在多个 View 间共享蒙皮状态的问题。View-local 蒙皮状态隔离属于后续引擎改进项;在该能力完成前,画中画/小地图可优先对角色使用 SimpleSkinning,或在小地图中过滤角色。

疑难解答:多镜头同时渲染同一骨骼模型时出现蒙皮撕裂 ​

典型症状:主镜头单独渲染正常,小地图 / 画中画单独渲染也正常;只有两个 View 同时看到同一个骨骼模型时,才出现骨骼拉裂、肢体拉伸、姿态跳变或两个镜头的结果互相影响。

这类现象优先按多 View 蒙皮状态共享问题排查,不要先把它当成动画文件损坏、骨架绑定错误或普通相机参数问题。当前风险主要来自:

  • CE 纹理蒙皮的 SkinTextureBuffer、骨骼 atlas 和 Drawable 级状态还没有完全按 View 隔离;
  • Compute Shader 蒙皮生成的输出 Geometry 也可能写入共享 Drawable 状态;
  • 不同 View 的可见集合和更新 / 渲染顺序可能让后一个 View 覆盖前一个 View 使用的结果。

快速确认:依次只保留主 View、只保留辅助 View、再同时打开两个 View。如果只有第三种情况撕裂,优先套用下面的兼容性规避;如果单 View 也异常,再回到动画、骨架、材质和模型资源本身排查。

短期规避:把需要出现在多个 View 的每个 AnimatedModelComponent 都切到普通硬件蒙皮:

csharp
#if CLIENT
var model = actor.GetAnimatedModel();
if (model != null)
{
    model.SimpleSkinning = true;
    model.ComputeShaderSkinning = false;
}
#endif

如果角色由换装或多 part 组成,不要只切换一个模型组件;应确认所有实际绘制的 AnimatedModelComponent 都使用同一路径。若模型超过设备可用的骨骼 / uniform 上限,普通蒙皮可能不适用,此时可让辅助 View 过滤该角色。

SimpleSkinning 需要客户端能力版本 3,并且只是模型级路径选择:不能让同一个模型在主 View 使用纹理蒙皮、在 PIP View 使用普通蒙皮,也不能证明两个 View 已经完成状态隔离。真正的 View-local 隔离(蒙皮 atlas、骨骼 UV、Compute 输出 Geometry)属于引擎长期工作,当前由 WasiAsync #127 跟踪。

多 part 模型 ​

一个 Node 下挂多个 AnimatedModelComponent 可以实现换装等多 part 角色:

  • 第一个 AnimatedModel 自动成为 master,负责骨架更新和 SkinMatrices 计算
  • 后续 AnimatedModel 默认复用 master 的 SkinMatrices,只有 master 计算一次
  • 不要把不同 part 挂到不同 Node 下(会算多遍)

Socket — 挂点 ​

Socket 是建模时定义的骨骼相对位置,用于把子节点(武器、特效、装备)按指定 offset 挂到骨骼上。

GetSocket 优先读取当前模型骨架下的预制体挂点节点,再查模型 XML 挂点。使用官方资源索引中的挂点时,应通过 Prefab.Load(...).CreateInstance(parent) 加载完整预制体;仅加载 .mdl 不会创建预制体中配置的挂点。预制体挂点查询修复需更新客户端或编辑器二进制。

csharp
#if CLIENT
var socket = animatedModel.GetSocket("socket_weapon_r");
if (socket.HasValue)
{
    var swordNode = socket.Value.BoneNode.CreateChild("Sword");
    swordNode.LocalPosition = socket.Value.LocalPosition;
    swordNode.LocalRotation = socket.Value.LocalRotation;
    swordNode.LocalScale    = socket.Value.LocalScale;
    var sm = swordNode.CreateComponent<StaticMeshComponent>();
    sm.Mesh = Mesh.Load("Models/Weapons/Sword.mdl");
}
#endif

Socket 类型 ​

csharp
public readonly record struct Socket(
    Node BoneNode,
    Vector3 LocalPosition,
    Quaternion LocalRotation,
    Vector3 LocalScale);

BoneNode 是骨骼对应的场景 Node —— 挂子节点到这个 Node 上即可跟随动画移动。LocalXxx 是相对该 Node 的局部 transform,从建模时定义读取。

> 跟 GetBoneNode("Bip001 R Hand") 的区别:GetBoneNode 按模型的真实骨骼名查询,只返回骨骼 Node;挂点名不是骨骼名。GetSocket 按挂点名查询,一次返回骨骼 Node 和挂点相对该骨骼的位移、旋转、缩放,挂接时需同时应用这三项变换。


Bone — 骨骼只读快照 ​

用于遍历骨骼、读取引用 pose、查询骨骼层级。只读 —— 修改骨骼 transform(PostProcess transform / BoneScaleData 捏脸)需要等可写 wrapper(在 Backlog 中)。

csharp
#if CLIENT
// 遍历所有骨骼
for (uint i = 0; i < animatedModel.NumBones; i++)
{
    var bone = animatedModel.GetBoneByIndex(i);
    if (bone.HasValue)
    {
        Game.Logger.LogInformation(
            "Bone {Index}: parent={Parent}, initPos={Pos}",
            i, bone.Value.ParentIndex, bone.Value.InitialPosition);
    }
}

// 找到根骨骼
var root = animatedModel.GetBoneByIndex(animatedModel.RootBoneIndex);

// 按名查骨骼
var spine = animatedModel.GetBoneByName("bone_spine_01");
if (spine.HasValue && spine.Value.Node != null)
{
    // 拿到引用 pose 旋转作为基准
    var refRotation = spine.Value.InitialRotation;
}
#endif

Bone 类型 ​

csharp
public readonly record struct Bone(
    Node? Node,                  // 骨骼对应的场景 Node。可能为 null(骨骼未关联场景节点)
    uint ParentIndex,            // 父骨骼 index。根骨骼对照 RootBoneIndex
    Vector3 InitialPosition,     // 引用 pose 局部位置
    Quaternion InitialRotation,  // 引用 pose 局部旋转
    Vector3 InitialScale,        // 引用 pose 局部缩放
    bool Animated);              // 是否参与动画驱动

骨骼写入机制 —— 三层并存 ​

骨骼姿态可以从三个不同位置被改写,它们作用阶段不同、生效方式不同、用途也不同。理解这三层的关系对写自定义动画逻辑至关重要。

三种机制的对比 ​

机制操作对象生效阶段数据所在典型用途GameGraph API
Bone Facing(朝向)骨骼对应的场景 Node每帧计算世界变换时结构性的朝向覆盖瞄准、转头、上半身朝向SetBoneFacing(name, yaw) / SetBoneFacing(name, Quaternion)
PostProcess transform骨骼的局部后处理姿态动画采样之后、蒙皮之前额外的位置、旋转和缩放IK solver、物理 / 布娃娃、永久骨骼调整SetBonePostProcessTransform(...) 等
Animated(per-bone enable)单根骨骼的动画启用状态动画更新阶段是否接受动画轨道驱动暂停某根骨骼跟随动画SetBoneAnimated(name, bool)
text
[1] 动画状态按各自骨骼遮罩采样,并写入骨骼 Node 的局部姿态
        ↓ 跳过 SetBoneAnimated(name, false) 的骨骼
[2] 星火动画后处理阶段(计算并写入额外的骨骼姿态;当前公共 API 只开放写入结果)
        ↓
[3] 骨骼 Node 计算世界变换,并应用 SetBoneFacing 的结构性朝向
        ↓
[4] 蒙皮合并骨骼世界变换与 PostProcess transform
        ↓
渲染

PostProcess transform 详解 ​

PostProcess transform 是星火动画后处理阶段使用的额外骨骼姿态。IK、布娃娃、物理动画和 RigDriver 等系统会在动画采样之后计算新姿态,并在蒙皮前合并这些局部位置、旋转和缩放。

目前 GameGraph 暴露的是骨骼写入面:

csharp
#if CLIENT
// 一次性 set 三字段(最常用)
animatedModel.SetBonePostProcessTransform("Bip001 Spine",
    localPosition: new Vector3(0, 0.02f, 0),
    localRotation: Quaternion.Identity,
    localScale: Vector3.One);

// World space 输入(solver 友好;scale 必须 local 版本)
animatedModel.SetBonePostProcessWorldTransform("Bip001 Head",
    worldPosition: aimTarget,
    worldRotation: lookAtRotation);

// 清除(恢复正常 skinning)
animatedModel.ClearBonePostProcessTransform("Bip001 Spine");
animatedModel.ClearAllBonesPostProcessTransform();  // 批量
#endif

目前 GameGraph 没有开放在动画采样后挂接自定义 solver 的回调,所以用户需要自己驱动每帧调用,比如:

csharp
public class FakeFootIK : IThinker
{
    public void Think(int delta)
    {
        // 自己实现 IK 算法,每帧调一次 set
        var (footPos, footRot) = ComputeFootPlacement(...);
        model.SetBonePostProcessWorldTransform("Bip001 L Foot", footPos, footRot);
    }
}

时序上 IThinker.Think 在动画采样之前运行,写入的 PostProcess 结果通常在后续蒙皮阶段生效。当前公共 API 没有开放在动画采样后插入自定义 solver 的回调;需要精确逐帧 IK 时,应把这一时序限制纳入设计和验证。

animated 标志的语义 ​

SetBoneAnimated(name, false) 让该骨骼跨所有动画在 sample 阶段被跳过——不接受任何动画驱动。与 AnimationController.SetStartBone / SetExcludedBone 的区别:

机制作用域控制位置
SetBoneAnimatedper-bone 全局是否让该骨骼接受任何动画轨道驱动
SetStartBone / SetExcludedBoneper-animation单条动画可影响的骨骼范围

典型用途:让某根骨骼完全脱离动画系统(比如挂在手上的物体骨骼,由游戏逻辑驱动),用 SetBoneAnimated(false) 后即使所有动画包含这根骨骼的 track 也不会被采样。


查模型的骨骼名 / 挂点名 ​

骨骼名和挂点名是每个模型自己的资产属性,由建模和导出流程决定。本引擎里至少有两套常见命名约定并存:

  • Biped 流派(3DS Max Biped 默认):带空格,如 Bip001 Spine1、p_weapon_r
  • 自定义流派:下划线,如 bone_spine_01、socket_weapon_r

新模型入库时可能用任何一套,没有引擎层强制约定。要查具体某个模型用什么名字,按下面三种方式:

方式一:编辑器"美术资源库" ​

编辑器自带的美术资源库工具会列出项目里所有可用的美术资源(包括引擎内置的),点开任一带骨架的模型就能看到完整骨骼层级和挂点列表。这是首选 —— 看到的内容就是 native 加载后的实际数据,不会有同步问题。

方式二:程序化 dump ​

写一段代码遍历当前角色全部骨骼,打到日志里看:

csharp
#if CLIENT
for (uint i = 0; i < animatedModel.NumBones; i++)
{
    var bone = animatedModel.GetBoneByIndex(i);
    Game.Logger.LogInformation("Bone {Index}: {Name}", i,
        bone?.Node?.Name.ToString() ?? "(no node)");
}
#endif

排查"为什么 SetBoneFacing("spine_01", ...) 没生效"之类的问题时这一招很快。

方式三:资产索引(仅 socket) ​

docs/resources/characters.json 每个 skeletal 模型条目下的 extraSockets 字段列出该模型的额外挂点:

json
{"name":"AnimalWarrior","type":"skeletal",
 "extraSockets":["socket_back","socket_chest","socket_weapon_l","socket_weapon_r", ...]}

骨骼名暂未进资产索引,要查骨骼还是走方式一或方式二。

跨模型代码的建议 ​

如果一段代码可能跑在多种角色模型上(比如通用的换装、武器挂载、上半身转向逻辑),别硬编一个骨骼/挂点名。建议传入一个候选名列表按优先顺序探测:

csharp
#if CLIENT
private static readonly string[] CandidateSpineBones =
[
    "Bip001 Spine1",   // Biped 流派
    "bone_spine_01",   // 自定义流派
    "Spine",           // 最简命名
];

string? FindSpineBone(AnimatedModelComponent model)
{
    foreach (var name in CandidateSpineBones)
    {
        if (model.GetBoneByName(name).HasValue) return name;
    }
    return null;
}
#endif

AnimationController — 动画控制器 ​

继承自 EngineNodeComponent(不是 Drawable)。同节点需先挂 AnimatedModelComponent。

AnimationHandle —— 性能优化句柄 ​

动画名参数有两套 API:

  • 快路径:用 AnimationHandle(uint hash)—— 全程零字符串 marshaling,适合每帧调用的 SetTime / SetWeight / SetSpeed 等热点路径
  • 便利路径:直接传 GameCore.ResourceType.Animation 字符串路径 —— 每次调用付一次 C# 端 SDBM hash + uint marshaling 开销,适合一次性查询或控制
csharp
#if CLIENT
// 一次性查询 —— 便利路径
if (ctrl.IsPlaying("Animations/Idle.ani")) { ... }

// 热点路径 —— 缓存 handle
var runHandle = ctrl.PlayExclusive("Animations/Run.ani",
    layer: 0, looped: true)!.Value;
// ... 每帧 ...
ctrl.SetSpeed(runHandle, currentSpeed);     // 全程 uint,零 string marshaling
ctrl.SetWeight(runHandle, weightCurve.At(t));

// 预先构造 handle(不需要 Play 已成功)
var attackHandle = AnimationHandle.For("Animations/Attack.ani");
if (ctrl.IsPlaying(attackHandle)) { ... }
#endif

基本用法 ​

csharp
#if CLIENT
var ctrl = characterNode.CreateComponent<AnimationController>();

// 简单循环
ctrl.Play("Animations/Idle.ani", layer: 0, looped: true);

// 切换到跑步动画,淡出 idle
ctrl.PlayExclusive("Animations/Run.ani",
    layer: 0, looped: true, fadeInTime: 0.2f, fadeOutTime: 0.2f);

// 上半身覆盖动画(不同 layer 叠加)
ctrl.Play("Animations/Wave.ani", layer: 1, looped: false, fadeInTime: 0.1f);
ctrl.SetAutoFade("Animations/Wave.ani", 0.2f);

// 控制速度和权重
ctrl.SetSpeed("Animations/Run.ani", 1.5f);
ctrl.SetWeight("Animations/Run.ani", 0.8f);

// 停止
ctrl.Stop("Animations/Run.ani", fadeOutTime: 0.15f);
ctrl.StopLayer(layer: 1, fadeOutTime: 0.1f);
ctrl.StopAll(fadeOutTime: 0.3f);

// 暂停整个控制器
ctrl.SetPause(true);
#endif

动画混合模型:Layer / Weight / Mask / Suppression ​

骨骼动画混合涉及四个相互正交但相互影响的维度。先把数据流讲清楚,再分别说每个维度。

数据流(每帧) ​

text
1. 所有动画状态按 layer 升序稳定排序
2. 从高 layer 向下检查遮蔽,标记被完全覆盖、可跳过采样的下层动画
3. 按排序顺序逐个应用未被跳过的动画:
   - 仅遍历该动画骨骼遮罩允许的范围
   - weight == 1.0 时直接覆盖当前姿态
   - weight < 1.0 时按权重与当前姿态混合

关键性质:后 apply 的动画在它能写到的骨骼上覆盖/混合前面的结果。

维度 1:Layer —— Apply 顺序 ​

layer 是一个 byte(0-255),决定动画的 apply 顺序:低 layer 先,高 layer 后。作用是排序,不是分组:

  • 高 layer 在低 layer 之后写入 → 在它能写到的骨骼上覆盖或混合低 layer 的结果
  • 同 layer 的多个动画:apply 顺序由 stable sort 决定(插入顺序),不要依赖这种顺序——同 layer 应当只放互斥的动画(如 idle 替换为 walk)
  • PlayExclusive(layer) 用来在同 layer 上做替换播放:自动淡出同层旧动画

维度 2:Weight —— 单动画对最终姿态的贡献强度 ​

weight ∈ [0, 1] 在 apply 时决定混合方式:

  • weight == 1.0:直接 SetPose(newPose) 覆盖当前骨骼姿态(当前姿态是前面已 apply 的动画累积结果)
  • weight < 1.0:Lerp(currentPose, newPose, weight) 与当前姿态线性混合
  • weight == 0:完全无效果

典型用法:Fade(handle, 0, 0.3) 用 0.3 秒淡出;SetWeight(handle, 0.5) 让 attack 与底图各占一半。

维度 3:Bone Mask —— 单动画作用的骨骼范围 ​

SetStartBone(handle, boneName) / SetExcludedBone(handle, boneName) 裁剪单条动画的骨骼作用范围,即该动画在应用时可以写入哪些骨骼:

  • SetStartBone(spine):仅 spine 及其子骨骼参与,其他骨骼该动画不写入(保留前层结果)。boneName 为空 / 找不到时回退到 root(恢复全身)
  • SetExcludedBone(rightArm):在当前作用范围中减去 rightArm 子树。必须在 SetStartBone 之后调用(SetStartBone 会重建作用范围,覆盖之前的 Excluded)
  • 复合遮罩:SetStartBone(spine) → SetExcludedBone(rightArm) = 上半身但不含右臂
csharp
#if CLIENT
// 下半身走路(layer 0,全身)
ctrl.Play("Animations/Walk.ani", layer: 0, looped: true);

// 上半身挥剑(layer 1,遮罩到脊椎子树)
var attack = ctrl.Play("Animations/Attack.ani", layer: 1, looped: false)!.Value;
ctrl.SetStartBone(attack, "Bip001 Spine"u8);
ctrl.SetExcludedBone(attack, "Bip001 R UpperArm"u8);  // 可选:排除右臂
#endif

维度 4:Suppression —— 性能优化的遮蔽跳过 ​

当一个 layer 的动画完全覆盖了下面所有 layer 时,引擎会标记下层动画为 SuppressedNow,跳过该帧的 Apply 计算。这是纯渲染性能优化,触发条件严苛:

触发"我会遮蔽下层"需同时满足原因
weight == 1.0否则下层姿态会通过 Lerp 透出
startBone == rootBone(即未被 mask 限定)否则下层会在 mask 之外的骨骼上显示
IsSuppressible() == true(默认 true)标 false 可手动禁用

⚠️ SetExcludedBone 会自动 SetSuppressible(false):因为带 ExcludedBone 的动画即使 startBone 还在 root,实际也不覆盖所有骨骼,不应让下层被错误抑制。

⚠️ 被遮蔽的动画 time 默认仍然推进:SetPausedWhenSuppressed(true) 可让被遮蔽时时间也停。

完整组合矩阵 —— 几个典型场景 ​

场景layerweightmask表现
单一全身动画01.0无走路
同层淡入淡出过渡0→00→1 / 1→0无walk 淡出,run 淡入
上半身覆盖(替换)高 layer1.0上半身下半身走路 + 上半身挥剑
上半身半透(混合)高 layer0.5上半身下半身走路 + 上半身走路 50% + 挥剑 50%
Additive 瞄准偏移高 layer1.0无(或上半身)走路姿态上叠加瞄准偏移(需 BlendMode=Additive)
复合遮罩高 layer1.0上半身 ∖ 右臂下半身走路 + 上半身挥剑 + 右臂保持走路

与 Actor 体系的关系 ​

这套动画混合机制由 GameGraph 和 Actor 共享。Actor 在此之上加了一层调度逻辑,GameGraph 没有:

概念GameGraph 暴露Actor 额外做
Layer(apply 顺序)byte layer 直接把 logicLayer << 5 | priority 编码进同一个 byte
WeightSetWeight / Fade同
Bone MaskSetStartBone / SetExcludedBoneSetUpperBodyRootBone + BodyPart 三值枚举驱动同一 API
Suppression(性能)底层动画运行时自动处理同
Play 拒绝("高优先级抑制低优先级")无有 —— Actor 层会在播放前比较当前高优先级动画与待播动画,必要时直接拒绝新播放请求

→ Actor 的“高优先级抑制低优先级”是播放时的调度行为(拒绝播放新动画),不是已播放动画为节省更新而进入的 suppression 状态。两者名字相似,但语义无关。

纯 GameGraph 用户如需类似的"拒绝低优先级覆盖"语义,需自己在 Play 前查 IsPlayingOnLayer(higherLayer) 决定是否播放。GameGraph 层故意不内建这套策略,把决策权留给上层。

Actor 与 GameGraph 操作同一个底层动画状态:通过 actor.GetAnimationController() 下钻后,GameGraph 的修改会立即影响该 Actor。Actor 的 SetUpperBodyRootBone + BodyPart 与 GameGraph 的 SetStartBone 控制同一项骨骼范围状态,后写生效,没有冲突仲裁。混用时调过 GameGraph SetStartBone 后,不要继续假设 Actor 的 BodyPart 配置仍保持原语义。

Blend Mode ​

值说明
Lerp线性混合(默认)。多动画按权重在最终姿态上插值
Additive加法混合。基于绑定姿态的差值叠加,用于在基础动画上叠瞄准 / 呼吸等增量动画

AnimationController 完整 API 表 ​

类别方法
播放Play(animation, layer, looped, fadeInTime, fadeOutTime) PlayExclusive(...) Stop(handle/animation, fadeOutTime) StopLayer(layer, fadeOutTime) StopLayerRange(begin, end, fadeOutTime) StopAll(fadeOutTime)
淡入淡出Fade(handle/animation, targetWeight, fadeTime) FadeOthers(handle/animation, targetWeight, fadeTime)
单动画 setterSetLayer SetTime SetWeight SetLooped SetInterpolate SetSpeed SetAutoFade SetRemoveOnCompletion
骨骼遮罩SetStartBone(handle, boneName) SetExcludedBone(handle, boneName) —— per-animation track filter
查询IsPlaying IsPlayingOnLayer(layer) IsFadingIn IsFadingOut IsAtEnd GetLayer GetTime GetWeight IsLooped GetBlendMode GetLength GetSpeed GetFadeTarget GetFadeTime GetAutoFade GetRemoveOnCompletion
取 StateGetAnimationState(handle/animation) → AnimationState?
整体控制SetPause(bool) IsStopping AddSilentTimeStep(float) CleanAndResetPose()

每个非整体方法都有 AnimationHandle 和 GameCore.ResourceType.Animation 两个重载。

⚠️ 无原生动画事件 ​

当前公共 AnimationController 没有“动画完成 / 关键帧触发”事件。判断动画是否播完需要轮询 IsAtEnd 或 IsPlaying:

csharp
#if CLIENT
public class CharacterController : ScriptComponent
{
    private AnimationController? ctrl;
    private bool attackFinished;

    public override void OnStart()
    {
        ctrl = Node.GetComponent<AnimationController>();
        ctrl?.Play("Animations/Attack.ani", layer: 0, looped: false);
    }

    public override void OnUpdate(float dt)
    {
        if (ctrl != null && !attackFinished && ctrl.IsAtEnd("Animations/Attack.ani"))
        {
            attackFinished = true;
            OnAttackHit();
        }
    }
}
#endif

如需事件驱动的动画系统(OnComplete / 关键帧触发等),使用 Actor 层 的 AnimationSequence(基于 SequenceEvent),见 ModelAnimationSystem.md。


AnimationState — 单条动画状态 ​

从 AnimationController.GetAnimationState(handle) 取,用于对单一动画做精细控制,不影响同节点上的其他动画。

csharp
#if CLIENT
var run = ctrl.Play("Animations/Run.ani", layer: 0, looped: true)!.Value;
var state = ctrl.GetAnimationState(run);
if (state != null)
{
    state.Weight = 0.6f;      // 单条权重
    state.Time = 1.2f;        // 跳到 1.2 秒
    state.IsInterpolated = false;  // 关闭帧间插值
    state.PausedWhenSuppressed = true;  // 被遮蔽时暂停
}
#endif

AnimationState API 表 ​

属性 / 方法类型说明
Weightfloat混合权重 (0~1)
AddWeight(delta)void累加权重
Timefloat当前时间(秒)
AddTime(delta)void累加时间
Layerbyte混合层
IsLoopedbool是否循环
IsLoopedThisFramebool本帧是否完成一次循环(边沿检测)
IsInterpolatedbool是否做帧间插值(关闭后离散采样)
BlendModeAnimationBlendMode混合模式
Lengthfloat动画总长(秒)
IsEnabledbool权重 > 0 即视为启用
IsSuppressedbool当前是否被高权重动画遮蔽
IsSuppressiblebool是否会遮蔽其他动画
PausedWhenSuppressedbool被遮蔽时是否暂停时间推进
MaxFrameuint (set)设动画最大帧率限制

AnimationResource — 动画资源 ​

.ani 文件的预加载和资源级查询。多数场景不需要显式 Load —— AnimationController.Play(path, ...) 内部会按需通过 ResourceCache 加载。仅在以下场景需要:

  • 预加载:UI 显示前确保动画已经在内存
  • 查询动画长度:用于 UI 进度条、连招判定窗口等
csharp
#if CLIENT
var animation = AnimationResource.Load("Animations/Idle.ani");
if (animation != null)
{
    float length = animation.Length;
    string name = animation.AnimationName;
    Game.Logger.LogInformation("Loaded {Name}, length={Length}s", name, length);
}
#endif

烘焙动画路径解析 ​

WasiCore 编辑器对 humanoid 模型可以做动画烘焙 —— 把共享 humanoid 动画针对当前模型骨骼做重定向,并按约定输出到 anim/{modelName}/{animName} 路径下。烘焙前后动画名不变。

Actor 层对此完全透明:用户调 actor.PlayAnimation("run.ani"),Actor 体系会自动先尝试烘焙路径,存在就用,不存在就 fallback 原路径。

GameGraph 层不做自动解析 —— AnimationController.Play(path) 直传路径给底层。如果用户知道动画名但不知道烘焙路径,用 ComputeBakedAnimationPath helper 显式拼接:

csharp
#if CLIENT
// 知道动画名,希望优先用烘焙版(如果有)
var bakedPath = animatedModel.ComputeBakedAnimationPath("run.ani");
// → "anim/warrior/run.ani"(如果模型是 characters/Hero/Warrior/model.mdl)

// 用 AnimationResource.Load 检查烘焙资源是否存在
var pathToPlay = AnimationResource.Load(bakedPath) != null ? bakedPath : "run.ani";
ctrl.Play(pathToPlay);
#endif

设计取舍 ​

  • ComputeBakedAnimationPath 只做路径拼接,不做存在性检查 —— 与 Actor 层"先检查再回退"的隐式行为不同
  • 让用户显式决定何时用烘焙版,保持 GameGraph 层"直传路径"的简单语义
  • 与 Actor 层的自动烘焙解析走的是同一约定路径,但不依赖 Actor 体系

何时使用 ​

  • 纯 GameGraph 模式用 humanoid 角色:用户知道动画路径来源,根据需要决定取烘焙版
  • 装饰物 / 过场 prop:通常用模型自带动画或明确路径的共享动画,不需要此 helper
  • 已经走 Actor 体系的角色:不需要 —— Actor 层已经自动处理

Actor 层到 GameGraph 层的下钻 ​

Unit、Actor、Scene 的完整对象关系、双端节点语义和生命周期见 GameCore 与 GameGraph 桥接。本节聚焦动画和骨骼。

Actor 层调用 PlayAnimation 等接口,背后操作的就是 GameGraph 层 wrapper 上的 AnimationController —— 两边对同一个对象生效。可以双向控制等价。

Entity、Unit 和 ActorModel 都实现了 IActorAnimationPlayable,可以直接使用下列扩展方法。Unit 不是 ActorModel,也没有 Unit.GetActor() 方法。以下以已有的 unit 为例;如果持有的是 ActorModel,直接在该对象上调用同样的方法即可。

csharp
#if CLIENT
using GameCore.ActorSystem;
using GameGraph.NodeSystem;

// Unit 自身支持动画,可直接下钻到 GameGraph 层
var ctrl = unit.GetAnimationController();
var model = unit.GetAnimatedModel();

// 直接用 GameGraph API 控制单位的动画
ctrl?.SetSpeed("Animations/Run.ani", 1.5f);

// 高层 PlayAnimation 拿到 Animation,下钻取 AnimationState 做精细控制
var anim = unit.PlayAnimation(myAnimationLink);
var state = anim?.GetAnimationState();
if (state != null)
{
    state.Weight = 0.5f;
}

// 按挂点名挂武器,保留相对骨骼的完整变换
if (model?.GetSocket("socket_weapon_r") is { } socket)
{
    var swordNode = socket.BoneNode.CreateChild("Sword");
    swordNode.LocalPosition = socket.LocalPosition;
    swordNode.LocalRotation = socket.LocalRotation;
    swordNode.LocalScale = socket.LocalScale;
}
#endif

也可以从 unit.GetNode() 取得节点,再查询其组件:

csharp
#if CLIENT
using GameGraph.NodeSystem;
using GameGraph.NodeSystem.Component.Graphics;

var node = unit.GetNode();
var ctrl = node?.GetComponent<AnimationController>();
var model = node?.GetComponent<AnimatedModelComponent>();
#endif

客户端 GetNode() 优先返回单位的表现根节点,缺失时会回退到默认 UnitNodeBinding 的节点;上述动画桥接方法则只访问表现节点。节点或对应组件不存在时会返回 null。

桥接扩展方法清单 ​

定义在 GameGraph/NodeSystem/ExtensionMethod.cs:

扩展方法返回说明
IActorAnimationPlayable.GetAnimatedModel()AnimatedModelComponent?取底层 AnimatedModel
IActorAnimationPlayable.GetAnimationController()AnimationController?取底层 AnimationController
GameCore.ModelAnimation.Animation.GetAnimationState()AnimationState?取底层单条动画状态

> 注意:Actor 的 ModelAnimation 体系还会做优先级压制等额外逻辑,绕过它直接操作底层 controller 可能与 Actor 层的状态机产生冲突。建议只在已经脱离 Actor 体系(纯 GameGraph 模式)或确实需要精细访问时使用。


完整示例:纯 GameGraph 3D 角色 ​

csharp
#if CLIENT
using GameCore;
using GameCore.BaseType;
using GameCore.Interface;
using GameGraph.NodeSystem;
using GameGraph.NodeSystem.Component.Graphics;
using GameGraph.ResourceSystem;

public class SimpleCharacterDemo : DisposableObject, IGameClass, IThinker
{
    private SceneGraph? sceneGraph;
    private Node? characterNode;
    private AnimationController? ctrl;
    private AnimationHandle? idleHandle;
    private AnimationHandle? runHandle;

    public bool DoesThink => !IsDisposed;

    public static void OnRegisterGameClass()
    {
        Game.OnGameTriggerInitialization += RegisterAll;
    }

    private static void RegisterAll()
    {
        if (Game.GameModeLink != ScopeData.GameMode.MyMode) return;

        SimpleCharacterDemo? demo = null;
        Game.OnGameStart += () =>
        {
            demo = new SimpleCharacterDemo();
            demo.Initialize();
        };
        Game.OnGameEnd += () =>
        {
            demo?.Destroy();
            demo = null;
        };
    }

    private void Initialize()
    {
        sceneGraph = new SceneGraph(createOctree: true);
        // 相机和光照略 ...

        // 创建角色节点
        characterNode = sceneGraph.CreateChild("Hero");

        // 骨骼模型
        var model = characterNode.CreateComponent<AnimatedModelComponent>();
        model.Mesh = Mesh.Load("Models/Hero/hero.mdl");
        model.Material = Material.Load("Materials/Hero.xml");
        model.CastShadows = true;

        // 动画控制器
        ctrl = characterNode.CreateComponent<AnimationController>();
        idleHandle = ctrl.Play("Animations/Idle.ani", layer: 0, looped: true);

        // 预先把热点 handle 缓存好
        runHandle = AnimationHandle.For("Animations/Run.ani");

        // 武器挂到右手
        var weaponSocket = model.GetSocket("socket_weapon_r");
        if (weaponSocket.HasValue)
        {
            var swordNode = weaponSocket.Value.BoneNode.CreateChild("Sword");
            swordNode.LocalPosition = weaponSocket.Value.LocalPosition;
            swordNode.LocalRotation = weaponSocket.Value.LocalRotation;
            swordNode.LocalScale    = weaponSocket.Value.LocalScale;
            var sm = swordNode.CreateComponent<StaticMeshComponent>();
            sm.Mesh = Mesh.Load("Models/Weapons/Sword.mdl");
        }

        Game.RegisterThinker(this);
    }

    public void Think(int deltaMs)
    {
        if (ctrl is null || runHandle is null) return;
        if (Game.Input.IsKeyDown(Key.W))
        {
            ctrl.PlayExclusive("Animations/Run.ani",
                layer: 0, looped: true, fadeInTime: 0.15f, fadeOutTime: 0.15f);
        }
        else
        {
            ctrl.PlayExclusive("Animations/Idle.ani",
                layer: 0, looped: true, fadeInTime: 0.15f, fadeOutTime: 0.15f);
        }
    }

    protected override void DisposeManaged()
    {
        sceneGraph?.Dispose();  // 级联销毁
    }
}
#endif

当前限制 ​

下列能力暂未通过 GameGraph 层暴露:

  • 可写 Bone wrapper:运行时修改骨骼局部 transform(捏脸、PostProcess 变换)。当前 Bone 只读
  • 动画通知 / 拖尾事件:从 C# 接收动画关键帧回调。需要的话用 Actor 层的 TimedEventTriggered,或者在 Think 里轮询 IsAtEnd / GetTime
  • IK / 后处理骨骼变换:跑步上下坡足部贴地、注视目标等
  • 非默认蒙皮变体:当前 GameGraph 层只暴露默认蒙皮路径,未来如有需要再扩展

与其他系统的关系 ​

  • 与 Actor 层 ModelAnimation:本文档"Actor 层到 GameGraph 层的下钻"章节。Actor 层完整 API 见 ModelAnimationSystem.md
  • 与 GameGraph 物理:同一节点上 AnimatedModelComponent 可以和 RigidBody / CollisionShape 共存(如布娃娃),物理驱动模型建议设 UpdateInvisible = true。详见 PhysicsSystem.md
  • 与 资源管线:.ani 文件由编辑器生成。资源统计可从常见的“单位模型 → 技能 → GameDataAnimation 素材”静态引用链自动收集共享 Humanoid 动画,也会收集 GameDataModel.HumanoidSourceAnimations 中的显式列表。由于分析结果依赖代码结构,关键动画仍可显式列入该列表作为保险;未烘焙时引擎会 fallback 到原始动画,可能出现体形不匹配。详见 ModelAnimationSystem.md — 动画映射与动画来源