混合树
混合树是同时播放多个片段、按参数加权混合的状态图状态。它将少量片段变成连续运动:行走与奔跑之间的任意速度,或八方向行走之间的任意方向。
每个子项是位于参数点上的片段,点可以是一个值或平面位置。参数变化时,根据距离计算权重,总和为 1。与过渡不同,混合树没有混合结束时刻,只要状态活动,混合就持续跟随参数。
编辑器图编辑器不创建混合树,需要在代码的状态图数据中构建。
混合树类型
type 决定参数数量和点的比较方式。
| 类型 | 参数 | 点 | 用途 |
|---|---|---|---|
pc.ANIM_BLEND_1D | 一个,使用 parameter | 数字 | 行走速度等一维值。在两点间按比例混合两侧片段。参数应保持在最小和最大点之间,超出范围则没有子项获得权重。 |
pc.ANIM_BLEND_2D_CARTESIAN | 两个,使用 parameters | [x, y] | 两个独立值,例如角色愉悦程度和活力。 |
pc.ANIM_BLEND_2D_DIRECTIONAL | 两个,使用 parameters | [x, y] | 移动向量等方向。以原点为参照比较角度和长度,可正确混合同方向行走与奔跑;待机放在 [0, 0]。 |
pc.ANIM_BLEND_DIRECT | 每个子项一个,使用 parameters | 无 | 手动混合。各权重为对应参数除以总和,负值按 0 处理。 |
子项只与同一树中的其他子项混合,不支持子项本身再是混合树。
创建混合树
混合树保存在状态的 blendTree,子项位于 children。每项包含 name、point,可选 speed 用于缩放片段速度。下面的 1D 树按 speed 混合待机、行走和奔跑:
const stateGraph = {
layers: [{
name: 'Base',
states: [
{ name: 'START' },
{
name: 'Movement',
loop: true,
blendTree: {
type: pc.ANIM_BLEND_1D,
parameter: 'speed',
children: [
{ name: 'Idle', point: 0 },
{ name: 'Walk', point: 1 },
{ name: 'Run', point: 3 }
]
}
}
],
transitions: [{ from: 'START', to: 'Movement' }]
}],
parameters: {
speed: { name: 'speed', type: pc.ANIM_PARAMETER_FLOAT, value: 0 }
}
};
2D 树使用两个参数名,点使用数值对:
blendTree: {
type: pc.ANIM_BLEND_2D_DIRECTIONAL,
parameters: ['moveX', 'moveZ'],
children: [
{ name: 'Idle', point: [0, 0] },
{ name: 'Forward', point: [0, 1] },
{ name: 'Back', point: [0, -1] },
{ name: 'Left', point: [-1, 0] },
{ name: 'Right', point: [1, 0] }
]
}
通过“状态名.子项名”路径分配片段。所有子项都有片段后开始播放,游戏运行时设置参数即可。
- 引擎
- 编辑器
- React
- Web Components
character.addComponent('anim', { activate: true });
character.anim.loadStateGraph(stateGraph);
const layer = character.anim.baseLayer;
layer.assignAnimation('Movement.Idle', idleTrack);
layer.assignAnimation('Movement.Walk', walkTrack);
layer.assignAnimation('Movement.Run', runTrack);
app.on('update', () => {
character.anim.setFloat('speed', velocity.length());
});
在实体脚本加载状态图,动画资源通过脚本属性提供。Anim 的 State Graph 留空:
import { Asset, Script } from 'playcanvas';
export class Movement extends Script {
static scriptName = 'movement';
/**
* @attribute
* @type {Asset}
* @resource animation
*/
idle;
/**
* @attribute
* @type {Asset}
* @resource animation
*/
walk;
/**
* @attribute
* @type {Asset}
* @resource animation
*/
run;
initialize() {
this.entity.anim.loadStateGraph(stateGraph);
const layer = this.entity.anim.baseLayer;
layer.assignAnimation('Movement.Idle', this.idle.resource);
layer.assignAnimation('Movement.Walk', this.walk.resource);
layer.assignAnimation('Movement.Run', this.run.resource);
}
}
import { useEffect } from 'react';
import { useParent } from '@playcanvas/react/hooks';
function Movement({ tracks, speed }) {
const entity = useParent();
useEffect(() => {
entity.anim.loadStateGraph(stateGraph);
entity.anim.baseLayer.assignAnimation('Movement.Idle', tracks.idle);
entity.anim.baseLayer.assignAnimation('Movement.Walk', tracks.walk);
entity.anim.baseLayer.assignAnimation('Movement.Run', tracks.run);
}, [entity, tracks]);
useEffect(() => {
entity.anim.setFloat('speed', speed);
}, [entity, speed]);
return null;
}
放在模型 <Entity> 内、<Anim playing /> 后。
与其他状态图一样,不添加 <pc-anim>,从代码向模型实体添加 anim:
const model = await whenReady('pc-model[name="character"]');
const container = document.getElementById('character').asset.resource;
const tracks = Object.fromEntries(container.animations.map(animation => [animation.resource.name, animation.resource]));
model.entity.addComponent('anim', { activate: true });
model.entity.anim.loadStateGraph(stateGraph);
model.entity.anim.baseLayer.assignAnimation('Movement.Idle', tracks.idle);
model.entity.anim.baseLayer.assignAnimation('Movement.Walk', tracks.walk);
model.entity.anim.baseLayer.assignAnimation('Movement.Run', tracks.run);
保持片段同步
混合片段时长往往不同,例如奔跑周期短于行走。各自按原速播放会步伐错位、脚底滑动,因此默认会调整各子项速度,让所有片段完成一轮所需时间相同,时长为按权重计算的平均值。例如一秒与两秒片段各占一半时,都用 1.5 秒播放完。
如果片段不是同类周期动作,将树的 syncAnimations 设为 false,各自按本身速度播放。
示例
下面通过 1D 树混合机械臂待机与检查动作。指针从画布左向右移动,使 look 从 0 变为 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);
// one state, whose 1D blend tree blends idle and inspect by the value of the look parameter
arm.anim.loadStateGraph({
layers: [{
name: 'Base',
states: [
{ name: 'START' },
{
name: 'Blend',
loop: true,
blendTree: {
type: pc.ANIM_BLEND_1D,
parameter: 'look',
children: [
{ name: 'Idle', point: 0 },
{ name: 'Inspect', point: 1 }
]
}
}
],
transitions: [{ from: 'START', to: 'Blend' }]
}],
parameters: {
look: { name: 'look', type: pc.ANIM_PARAMETER_FLOAT, value: 0 }
}
});
// link each child of the tree to a GLB animation, by the path State.Child
const tracks = Object.fromEntries(asset.resource.animations.map(animation => [animation.resource.name, animation.resource]));
arm.anim.baseLayer.assignAnimation('Blend.Idle', tracks.idle);
arm.anim.baseLayer.assignAnimation('Blend.Inspect', tracks.inspect);
// move the pointer across the canvas to set look from 0 at the left to range at the right
const range = 1;
canvas.addEventListener('pointermove', (event) => {
arm.anim.setFloat('look', range * pc.math.clamp(event.offsetX / canvas.clientWidth, 0, 1));
});
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 = `look: ${arm.anim.getFloat('look').toFixed(2)}. Move the pointer from left to right to blend.`;
});
可以尝试:
- 向子项添加
{ name: 'Pick', point: 2 },通过arm.anim.baseLayer.assignAnimation('Blend.Pick', tracks.pick);分配片段,将range改为2。 - 添加
syncAnimations: false,观察混合期间片段逐渐错开。 - 类型改为
pc.ANIM_BLEND_DIRECT,将parameter: 'look'改为parameters: ['still', 'look'],添加值为 1 的still参数,并移除各点。
下面的引擎示例使用 2D 树混合角色动画。在控制面板的操作区拖动改变混合点,各片段点周围圆圈随权重增大:
另请参阅
- 状态图:混合树所属的状态、过渡与参数。
- 图层与遮罩:混合多个完整状态图。
- AnimComponent 和 AnimComponentLayer:引擎 API。