跳到主要内容

混合树

混合树是同时播放多个片段、按参数加权混合的状态图状态。它将少量片段变成连续运动:行走与奔跑之间的任意速度,或八方向行走之间的任意方向。

每个子项是位于参数点上的片段,点可以是一个值或平面位置。参数变化时,根据距离计算权重,总和为 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] }
]
}

通过“状态名.子项名”路径分配片段。所有子项都有片段后开始播放,游戏运行时设置参数即可。

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());
});

保持片段同步​

混合片段时长往往不同,例如奔跑周期短于行走。各自按原速播放会步伐错位、脚底滑动,因此默认会调整各子项速度,让所有片段完成一轮所需时间相同,时长为按权重计算的平均值。例如一秒与两秒片段各占一半时,都用 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 树混合角色动画。在控制面板的操作区拖动改变混合点,各片段点周围圆圈随权重增大:

二维笛卡尔混合树
在示例浏览器中打开
二维方向混合树
在示例浏览器中打开

另请参阅​