跳到主要内容

状态图

状态图是模型动画的状态机。状态表示待机、行走、跳跃等动作;过渡定义何时、以多长交叉淡化、在什么条件下进入其他状态。条件检查代码设置的参数,例如速度。代码设置参数,状态图选择片段。

START 进入 Idle,speed 超过或降到 0.5 时切换 Idle 和 Walk,jump 触发 ANY 到 Jump,退出时间 1 后进入 END 并返回 START

状态图不包含片段,只是模板。各 anim 为状态绑定自己的片段,因此逻辑相同的角色可共享状态图、使用不同动画。至少包含一个图层,各层独立运行状态机,见图层与遮罩。

状态​

同一图层每次只有一个活动状态,共四类。

动画状态​

播放一个片段,或通过混合树混合多个。包含层内唯一名称、片段速度倍数和循环设置。非循环状态保持末帧,直到过渡退出。

START​

各层从 START 直接进入默认状态,不允许其他进出 START 的过渡。

END​

进入 END 会退出当前图,然后立即回到 START 和默认状态,适合从任意序列结束处返回,无需另画过渡。

ANY​

ANY 的过渡可从任意当前状态触发,适合跳跃、受击等全局动作,避免逐个状态连接。不能创建进入 ANY 的过渡。

过渡​

过渡连接两个状态,设置包括:

设置说明
Duration(time)交叉淡化秒数,0 立即切换。
Exit time(exitTime)相对源片段时长的比例。小于 1 时每轮都可触发,0.75 为每轮四分之三处;1 为首次末尾,2.5 为第三轮中点。没有退出时间则随时可触发。
Offset(transitionOffset)目标片段起始位置,为时长的 0 到 1 比例。
Interruption source(interruptionSource)淡化中允许哪些过渡打断:无、源状态、目标状态,或按指定顺序考虑两者。
Conditions(conditions)参数条件,全部通过才能过渡。

没有退出时间也没有条件时立即过渡;不会过渡到已活动状态。多个过渡同时满足时,priority 最小者优先,活动状态过渡先于 ANY。

参数与条件​

参数是状态图输入,具有名称、类型和默认值:

  • Float、Integer 和 Boolean 保留最后设置的值。
  • Trigger 是被过渡使用后重置为 false 的布尔值,适合跳跃等单次事件。默认保持直到被使用;setTrigger() 第二个参数传 true,则无论是否被使用都在帧末重置。

在 anim 上按名称设置:

entity.anim.setFloat('speed', 1.2);
entity.anim.setInteger('weapon', 2);
entity.anim.setBoolean('crouching', true);
entity.anim.setTrigger('jump');

条件通过 ==、!=、<、<=、>、>= 比较参数与数值,数据常量对应 pc.ANIM_EQUAL_TO、pc.ANIM_NOT_EQUAL_TO、pc.ANIM_LESS_THAN、pc.ANIM_LESS_THAN_EQUAL_TO、pc.ANIM_GREATER_THAN 和 pc.ANIM_GREATER_THAN_EQUAL_TO。

创建状态图​

下面是上图对应的引擎数据,编辑器生成相同结构。

const stateGraph = {
layers: [{
name: 'Base',
states: [
{ name: 'START' },
{ name: 'Idle', loop: true, defaultState: true },
{ name: 'Walk', loop: true },
{ name: 'Jump', loop: false },
{ name: 'ANY' },
{ name: 'END' }
],
transitions: [
{ from: 'START', to: 'Idle' },
{ from: 'Idle', to: 'Walk', time: 0.2, conditions: [{ parameterName: 'speed', predicate: pc.ANIM_GREATER_THAN, value: 0.5 }] },
{ from: 'Walk', to: 'Idle', time: 0.2, conditions: [{ parameterName: 'speed', predicate: pc.ANIM_LESS_THAN_EQUAL_TO, value: 0.5 }] },
{ from: 'ANY', to: 'Jump', time: 0.1, conditions: [{ parameterName: 'jump', predicate: pc.ANIM_EQUAL_TO, value: true }] },
{ from: 'Jump', to: 'END', time: 0.2, exitTime: 1 }
]
}],
parameters: {
speed: { name: 'speed', type: pc.ANIM_PARAMETER_FLOAT, value: 0 },
jump: { name: 'jump', type: pc.ANIM_PARAMETER_TRIGGER, value: false }
}
};

加载到组件,再通过图层给各状态分配片段:

character.addComponent('anim', { activate: true });
character.anim.loadStateGraph(stateGraph);

const layer = character.anim.baseLayer;
layer.assignAnimation('Idle', idleTrack);
layer.assignAnimation('Walk', walkTrack);
layer.assignAnimation('Jump', jumpTrack);

所有状态都有片段后开始播放。

警告

图层 assignAnimation() 保留图中定义的速度和循环。组件 entity.anim.assignAnimation() 则以参数覆盖,默认速度 1、循环,因此上面的 Jump 也会循环。应显式传入所需值,或通过图层分配。

驱动状态图​

图加载后,根据输入、物理或玩法逻辑设置参数。速度等连续值每帧更新,触发器在事件发生时设置。

app.on('update', () => {
character.anim.setFloat('speed', velocity.length());
});
app.keyboard.on('keydown', (event) => {
if (event.key === pc.KEY_SPACE) character.anim.setTrigger('jump');
});

图层 activeState 返回活动状态名,另有 previousState 和 transitioning。参数通过 getFloat()、getInteger()、getBoolean()、getTrigger() 读取。

AI

复杂状态图可让 AI 助手通过**编辑器 MCP 服务器**追踪失败过渡的条件、参数、脚本和 Anim 映射,再在 Launcher 复现。

示例​

机械臂先待机,设置 work 后抓取、放置,再返回。各动作是不循环状态,以退出时间 1 离开。点击画布设置触发器。

实时示例
import * as pc from 'playcanvas';

// create the application with the component systems and resource handlers it uses
const canvas = document.getElementById('application');
const device = await pc.createGraphicsDevice(canvas, {
deviceTypes: [pc.DEVICETYPE_WEBGPU]
});
device.maxPixelRatio = Math.min(window.devicePixelRatio, 2);

const options = new pc.AppOptions();
options.graphicsDevice = device;
options.componentSystems = [
pc.RenderComponentSystem,
pc.CameraComponentSystem,
pc.LightComponentSystem,
pc.AnimComponentSystem
];
options.resourceHandlers = [pc.ContainerHandler, pc.TextureHandler];

const app = new pc.AppBase(canvas);
app.init(options);
app.setCanvasFillMode(pc.FILLMODE_FILL_WINDOW);
app.setCanvasResolution(pc.RESOLUTION_AUTO);
window.addEventListener('resize', () => app.resizeCanvas());
app.start();

app.scene.ambientLight = new pc.Color(0.35, 0.35, 0.4);

// load the robot arm, a GLB with five animations
const asset = new pc.Asset('robot-arm', 'container', {
url: 'https://developer.playcanvas.com/assets/robot-arm.glb'
});
await new Promise((resolve) => {
asset.ready(resolve);
app.assets.add(asset);
app.assets.load(asset);
});

const camera = new pc.Entity('camera');
camera.addComponent('camera', {
clearColor: new pc.Color(0.11, 0.12, 0.17)
});
camera.setPosition(1.75, 1.4, 2.15);
camera.lookAt(0, 0.75, 0);
app.root.addChild(camera);

const light = new pc.Entity('light');
light.addComponent('light', {
castShadows: true,
shadowBias: 0.2,
normalOffsetBias: 0.05
});
light.setEulerAngles(48, 38, 0);
app.root.addChild(light);

const floor = new pc.Entity('floor');
const floorMaterial = new pc.StandardMaterial();
floorMaterial.diffuse = new pc.Color(0.24, 0.26, 0.31);
floorMaterial.update();
floor.addComponent('render', { type: 'plane', material: floorMaterial });
floor.setLocalScale(20, 1, 20);
app.root.addChild(floor);

// create the arm's entities, and give its root an anim component
const arm = asset.resource.instantiateRenderEntity({ castShadows: true });
arm.addComponent('anim', { activate: true });
app.root.addChild(arm);

// the arm idles until it is told to work, then picks, places and goes back to idling
arm.anim.loadStateGraph({
layers: [{
name: 'Base',
states: [
{ name: 'START' },
{ name: 'Idle', loop: true },
{ name: 'Pick', loop: false },
{ name: 'Place', loop: false }
],
transitions: [
{ from: 'START', to: 'Idle' },
// the work trigger starts a cycle, and is reset when this transition uses it
{ from: 'Idle', to: 'Pick', time: 0.4, conditions: [{ parameterName: 'work', predicate: pc.ANIM_EQUAL_TO, value: true }] },
// once a clip has played through, cross-fade to the next one
{ from: 'Pick', to: 'Place', time: 0.4, exitTime: 1 },
{ from: 'Place', to: 'Idle', time: 0.4, exitTime: 1 }
]
}],
parameters: {
work: { name: 'work', type: pc.ANIM_PARAMETER_TRIGGER, value: false }
}
});

// link each state to a GLB animation, keeping the speed and loop the graph gave it
const tracks = Object.fromEntries(asset.resource.animations.map(animation => [animation.resource.name, animation.resource]));
arm.anim.baseLayer.assignAnimation('Idle', tracks.idle);
arm.anim.baseLayer.assignAnimation('Pick', tracks.pick);
arm.anim.baseLayer.assignAnimation('Place', tracks.place);

// click to set the trigger
canvas.addEventListener('click', () => {
arm.anim.setTrigger('work');
});

const label = document.createElement('div');
label.style.cssText = 'position: absolute; left: 12px; bottom: 10px; color: #ccc; font: 14px sans-serif;';
document.body.appendChild(label);
app.on('update', () => {
label.textContent = `State: ${arm.anim.baseLayer.activeState}. Click to start a work cycle.`;
});

可以尝试:

  • 运动中再次点击。触发器保留到 Idle,随后立即开始下一轮。
  • 将 Pick 退出时间改为 0.5,抓取尚未完成便开始放置。
  • 以 inspect 片段添加同名状态,让 Place 转到它而非 Idle。

Locomotion 用 speed 整数和从 ANY 出发的 jump 驱动角色。示例浏览器中点击地面行走,启用 Run 改为跑步,可随时点击 Jump:

另请参阅​