主题
GameGraph 3D 骨骼动画
WasiCore 提供两级 3D 骨骼动画 API。两级共用同一份底层资源,可以从高层下钻到低层做精细控制,反之亦然。
| 层级 | 模型 | 动画控制 | 适用场景 |
|---|---|---|---|
| Actor 层 | ActorModel(实现 IActorAnimationPlayable) | Animation / AnimationSequence / AnimationBracket | 数编驱动,与 Unit / Entity 绑定,优先级压制、序列编排、生死元组 |
| GameGraph 层 | AnimatedModelComponent | AnimationController | 纯代码控制,直接操作 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 拿对应运行时 AnimationState | animation.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 手动装配示例不满足这项条件。
| 属性 / 方法 | 类型 | 说明 |
|---|---|---|
Mesh | Mesh? | 骨骼模型。设置时自动建立骨架 |
Material / Materials | Material? / Material[] | 材质(继承 StaticMeshComponent) |
SetModel(mesh, createBones) | void | 完整 SetModel 接口 |
SetModelWithoutSkeleton(mesh) | void | 不建骨架 |
AttachAdditionModel(prefab, options) | AdditionModelAttachment | 挂载骨骼附加 Prefab,返回可 Detach() / Dispose() 的句柄;仅支持 Prefab 主骨骼宿主 |
AnimationLodBias | float (set) | 动画 LOD 偏置 |
UpdateInvisible | bool | 不可见时是否继续更新(布娃娃等建议 true) |
UpdateBoneBoundingBox() | void | 手动重算骨骼包围盒 |
SimpleSkinning | bool | 普通硬件骨骼蒙皮开关;受设备最大骨骼数限制,开启后优先于其它路径 |
ComputeShaderSkinning | bool | Compute Shader 蒙皮开关;需 GPU 支持,且在 SimpleSkinning 关闭时生效 |
Move(Vector3 delta) | void | 应用根运动位移 |
TrackOptimizationEnabled | bool | 动画轨道优化开关 |
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) | bool | per-bone 动画 enable 开关(跨所有动画) |
GetSocket(name) | Socket? | 取挂点完整信息(含相对偏移) |
NumBones | uint | 骨骼总数 |
RootBoneIndex | uint | 根骨骼索引 |
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; // ComputeSkinningSimpleSkinning 是模型级开关,不是 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");
}
#endifSocket 类型
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;
}
#endifBone 类型
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 的区别:
| 机制 | 作用域 | 控制位置 |
|---|---|---|
SetBoneAnimated | per-bone 全局 | 是否让该骨骼接受任何动画轨道驱动 |
SetStartBone / SetExcludedBone | per-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;
}
#endifAnimationController — 动画控制器
继承自 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) 可让被遮蔽时时间也停。
完整组合矩阵 —— 几个典型场景
| 场景 | layer | weight | mask | 表现 |
|---|---|---|---|---|
| 单一全身动画 | 0 | 1.0 | 无 | 走路 |
| 同层淡入淡出过渡 | 0→0 | 0→1 / 1→0 | 无 | walk 淡出,run 淡入 |
| 上半身覆盖(替换) | 高 layer | 1.0 | 上半身 | 下半身走路 + 上半身挥剑 |
| 上半身半透(混合) | 高 layer | 0.5 | 上半身 | 下半身走路 + 上半身走路 50% + 挥剑 50% |
| Additive 瞄准偏移 | 高 layer | 1.0 | 无(或上半身) | 走路姿态上叠加瞄准偏移(需 BlendMode=Additive) |
| 复合遮罩 | 高 layer | 1.0 | 上半身 ∖ 右臂 | 下半身走路 + 上半身挥剑 + 右臂保持走路 |
与 Actor 体系的关系
这套动画混合机制由 GameGraph 和 Actor 共享。Actor 在此之上加了一层调度逻辑,GameGraph 没有:
| 概念 | GameGraph 暴露 | Actor 额外做 |
|---|---|---|
| Layer(apply 顺序) | byte layer 直接 | 把 logicLayer << 5 | priority 编码进同一个 byte |
| Weight | SetWeight / Fade | 同 |
| Bone Mask | SetStartBone / SetExcludedBone | SetUpperBodyRootBone + 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) |
| 单动画 setter | SetLayer 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 |
| 取 State | GetAnimationState(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; // 被遮蔽时暂停
}
#endifAnimationState API 表
| 属性 / 方法 | 类型 | 说明 |
|---|---|---|
Weight | float | 混合权重 (0~1) |
AddWeight(delta) | void | 累加权重 |
Time | float | 当前时间(秒) |
AddTime(delta) | void | 累加时间 |
Layer | byte | 混合层 |
IsLooped | bool | 是否循环 |
IsLoopedThisFrame | bool | 本帧是否完成一次循环(边沿检测) |
IsInterpolated | bool | 是否做帧间插值(关闭后离散采样) |
BlendMode | AnimationBlendMode | 混合模式 |
Length | float | 动画总长(秒) |
IsEnabled | bool | 权重 > 0 即视为启用 |
IsSuppressed | bool | 当前是否被高权重动画遮蔽 |
IsSuppressible | bool | 是否会遮蔽其他动画 |
PausedWhenSuppressed | bool | 被遮蔽时是否暂停时间推进 |
MaxFrame | uint (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 — 动画映射与动画来源