跳到主要内容

层级与变换

每个实体都有变换:位置、旋转和缩放。这些值相对于父实体,因此父实体移动、转动或变大时,子实体也会随之变化。这正是层级结构的作用:无论汽车开到哪里,车轮都留在车上;剑留在持有者手中;移动菜单面板时,整个菜单一起移动。

层级如何工作​

下面的引擎应用通过五个实体构建了一个小型太阳系:

sun
earthOrbit 位于中心,缓慢自转
└── earth 沿父级 X 轴向外 6 个单位
└── moonOrbit 位于地球中心,较快旋转
└── moon 沿父级 X 轴向外 1.5 个单位

代码没有直接移动地球或月球。每帧只让两个轨道实体绕各自的 Y 轴旋转。地球相对于 earthOrbit 的位置固定,因此轨道旋转时会带着地球绕太阳运动。月球在层级中更深两层,既会被 moonOrbit 带着绕地球运动,也会被 earthOrbit 带着绕太阳运动。

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

// create the application with the component systems 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
];

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.1, 0.1, 0.12);

const camera = new pc.Entity('camera');
camera.addComponent('camera', {
clearColor: new pc.Color(0.03, 0.04, 0.07)
});
camera.setPosition(0, 7, 12);
camera.lookAt(0, 0, 0);
app.root.addChild(camera);

// a sphere with a material of the given color, which glows if emissive is true
const createSphere = (name, color, emissive = false) => {
const material = new pc.StandardMaterial();
if (emissive) {
material.diffuse = new pc.Color(0, 0, 0);
material.emissive = color;
} else {
material.diffuse = color;
}
material.update();

const entity = new pc.Entity(name);
entity.addComponent('render', { type: 'sphere', material: material });
return entity;
};

// the sun glows, and lights the planets with an omni light
const sun = createSphere('sun', new pc.Color(1, 0.75, 0.3), true);
sun.addComponent('light', { type: 'omni', range: 30, intensity: 2 });
sun.setLocalScale(2, 2, 2);
app.root.addChild(sun);

// the earth is a child of an orbit entity at the center
const earthOrbit = new pc.Entity('earthOrbit');
app.root.addChild(earthOrbit);

const earth = createSphere('earth', new pc.Color(0.2, 0.45, 1));
earth.setLocalPosition(6, 0, 0);
earthOrbit.addChild(earth);

// the moon is set up the same way, one level further down
const moonOrbit = new pc.Entity('moonOrbit');
earth.addChild(moonOrbit);

const moon = createSphere('moon', new pc.Color(0.75, 0.75, 0.75));
moon.setLocalPosition(1.5, 0, 0);
moon.setLocalScale(0.35, 0.35, 0.35);
moonOrbit.addChild(moon);

// every frame, turn the orbits around their own Y axes, in degrees per second
app.on('update', (dt) => {
earthOrbit.rotateLocal(0, 20 * dt, 0);
moonOrbit.rotateLocal(0, 90 * dt, 0);
});

可以尝试:

  • 增大 90,让月球运动更快。
  • 在创建 moonOrbit 的代码后添加 moonOrbit.setLocalEulerAngles(30, 0, 0);,让月球轨道倾斜。轨道仍绕自身 Y 轴旋转,只是该轴现在倾斜了。
  • 使用 earth.setLocalScale(2, 2, 2); 放大地球。月球位于地球的下级,因此也会放大,轨道尺寸也扩大到两倍。
  • 添加自己的行星:复制创建 earthOrbit 和 earth 的代码并改名,将新行星放在离太阳更远的位置,并在 update 处理函数中降低轨道旋转速度。

坐标系​

PlayCanvas 使用右手坐标系,+Y 为上方。在默认正面视图中,+X 向右,+Z 朝向观察者,即从屏幕向外。实体自身的前方为 -Z,相机朝这个方向观察,lookAt() 也将这个方向指向目标,因此代码中的向前移动就是沿 -Z 移动。

坐标系的三个轴:红色 X 向右、绿色 Y 向上、蓝色 Z 朝向观察者,实体前方沿 -Z

  • 单位:按约定,一个单位对应一米。物理引擎以米为单位,基本立方体宽一个单位。
  • 角度:欧拉角以度为单位,按 X、Y、Z 顺序应用。
  • 方向:forward、right 和 up 返回实体的 -Z、+X 和 +Y 轴在世界空间中的方向,不受实体朝向变化的影响。

局部空间与世界空间​

实体变换存储在相对于父实体的局部空间中。应用全部祖先变换后,得到实体在场景中的世界空间变换。对于直接位于 app.root 下的实体,两者相同。

引擎为两种空间都提供了方法。带 Local 的方法相对于父实体工作,其他方法使用世界空间:

局部空间世界空间
位置getLocalPosition()、setLocalPosition()getPosition()、setPosition()
欧拉角旋转getLocalEulerAngles()、setLocalEulerAngles()getEulerAngles()、setEulerAngles()
四元数旋转getLocalRotation()、setLocalRotation()getRotation()、setRotation()
缩放getLocalScale()、setLocalScale()getScale(),只读

get 方法返回的向量和四元数属于实体,会随实体运动而变化。应视为只读;要保留数值或修改副本,请调用 clone()。世界缩放只能读取,并且只有不存在斜切时才可靠。层级上方的非均匀缩放与旋转结合可能产生斜切,参见缩放。

const car = new pc.Entity('car');
car.setPosition(10, 0, 0);
app.root.addChild(car);

const wheel = new pc.Entity('wheel');
car.addChild(wheel);
wheel.setLocalPosition(1, 0, 2); // relative to the car

console.log(wheel.getPosition().toString()); // [11, 0, 2]

wheel.setPosition(0, 0, 0); // the world origin
console.log(wheel.getLocalPosition().toString()); // [-10, 0, 0]

移动与旋转​

设置位置或旋转会直接将实体置于新的状态。要从当前状态开始移动或转动,可使用:

  • translate() 沿世界轴移动实体,translateLocal() 沿自身轴移动,因此无论面向哪里,translateLocal(0, 0, -1) 都会向前移动一个单位。
  • rotate() 绕世界轴旋转,rotateLocal() 绕自身轴旋转。两者都接受以度为单位的欧拉角。
  • lookAt() 使实体的 -Z 轴指向指定位置。

要让移动速度不依赖帧率,将每帧位移乘以 dt,即距上一帧的时间,单位为秒。

警告

不要读取欧拉角后直接累加。一个旋转可以有多种欧拉角表达,读取值可能与设置值不同:调用 setLocalEulerAngles(0, 120, 0) 后,getLocalEulerAngles() 返回 (180, 60, 180)。应在自己的变量中保存角度并每次设置,或使用 rotate() 和 rotateLocal()。

app.on('update', (dt) => {
ship.translateLocal(0, 0, -5 * dt); // 5 units per second, forward
ship.rotate(0, 45 * dt, 0); // 45 degrees per second, around the world's Y axis
});

动态刚体由物理引擎移动,会覆盖直接修改的变换。请通过力、冲量或传送移动,参见移动与传送。

父子关系​

将实体添加到新父节点时,会从原父节点移除,并保留局部变换,因此它会跳到相对于新父节点的对应位置。若希望世界位置不变,先保存世界位置和旋转,调整父节点后再重新设置。

子实体通常按添加顺序排列,除非在指定索引处插入。对大多数实体没有影响,但用户界面元素会按层级顺序绘制,因此排列靠后的同级元素绘制在上方。

要将车轮移到拖车下并保留局部变换,直接将其添加为拖车的子实体。它会跳到相对于拖车、与原先相对于旧父节点相同的位置:

trailer.addChild(wheel);

若要保留世界变换,使其保持原位,先保存世界位置与旋转,调整后再重新设置:

const position = wheel.getPosition().clone();
const rotation = wheel.getRotation().clone();
trailer.addChild(wheel);
wheel.setPosition(position);
wheel.setRotation(rotation);

要在子列表开头而非末尾插入:

trailer.insertChild(spare, 0);

缩放​

缩放会影响实体下方的所有内容,包括子实体的位置和尺寸。整体放大模型时很有用,但只想改变其中一部分的形状时就可能带来问题。非均匀缩放尤其需要注意:它沿各轴的缩放程度不同,作用于已旋转的子实体时,不再沿子实体自身轴拉伸,因此可能产生斜切。

为控制缩放的影响范围,应将缩放放在负责绘制的叶实体上,再通过未缩放的父实体分组。例如,桌子可以由未缩放的 table 父实体组织,子实体分别为压扁的盒形桌面和四个细长盒形桌腿。

缩放也影响物理设置:基本几何碰撞形状会忽略实体缩放。参见碰撞形状。

另请参阅​

  • 实体:创建和销毁层级中的实体。
  • 查找实体:按名称、路径、标签或组件查找实体。
  • 层级面板和视口:在编辑器中整理实体,并使用操作器调整变换。
  • Hierarchy:展示实体层级运动的引擎示例。
  • GraphNode:变换与层级的引擎 API 参考。