メインコンテンツまでスキップ

階層とトランスフォーム

すべてのエンティティは、位置、回転、スケールからなるトランスフォームを持っています。これらはエンティティの親に対する相対値なので、親が動いたり、回ったり、大きくなったりすると、子も一緒に動き、回り、大きくなります。これが階層の便利なところです。車がどこを走っても車輪は車についたままで、剣は持ち主の手に収まったままで、パネルを動かせばメニュー全体が一緒に動きます。

階層の仕組み​

このEngineアプリは、5つのエンティティでできた小さな太陽系です。

sun
earthOrbit 中心にあり、ゆっくり回転する
└── earth 親のX軸に沿って6ユニット離れた位置
└── moonOrbit 地球の中心にあり、より速く回転する
└── moon 親のX軸に沿って1.5ユニット離れた位置

地球や月を動かすコードはどこにもありません。アプリは毎フレーム、2つの軌道エンティティをそれぞれのY軸の周りに回すだけです。地球はearthOrbitに対して決まった位置にあるので、earthOrbitが回ると地球は太陽の周りを運ばれていきます。月はさらに2階層下にあるので、moonOrbitによって地球の周りを運ばれ、同時にearthOrbitによって太陽の周りも運ばれます。

ライブサンプル
import * as pc from 'playcanvas';

// 使用するコンポーネントシステムを指定してアプリケーションを作成します
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);

// 指定した色のマテリアルを持つ球。emissiveが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;
};

// 太陽は発光し、オムニライトで惑星を照らします
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);

// 地球は、中心にある軌道エンティティの子です
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);

// 月も同じように、1階層下に設定します
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);

// 毎フレーム、各軌道をそれ自身のY軸の周りに回します(単位は度/秒)
app.on('update', (dt) => {
earthOrbit.rotateLocal(0, 20 * dt, 0);
moonOrbit.rotateLocal(0, 90 * dt, 0);
});

次のような変更を試してみてください。

  • 90を大きくして、月を速くします。
  • moonOrbitを作成する行の後にmoonOrbit.setLocalEulerAngles(30, 0, 0);を追加して、月の軌道を傾けます。軌道は自身のY軸の周りを回り続けますが、そのY軸が傾いた状態になります。
  • earth.setLocalScale(2, 2, 2);で地球を大きくします。月は階層で地球の下にあるので、月も大きくなり、軌道の大きさも2倍になります。
  • 自分の惑星を追加します。earthOrbitとearthを作成する行を新しい名前でコピーし、新しい惑星を太陽からもっと離れた位置に置いて、updateハンドラーでその軌道をもっとゆっくり回します。

座標系​

PlayCanvasは、+Yが上を向く右手座標系を使います。デフォルトのビューで正面から見ると、+Xは右を、+Zは画面の外のこちら側を向きます。エンティティ自身の前方向は、その-Z軸です。これはカメラが見る方向であり、lookAt()が向ける方向でもあります。コードで前に進むことが-Z方向への移動を意味するのはこのためです。

座標系の3つの軸。Xは赤で右を、Yは緑で上を、Zは青でこちら側を向き、エンティティの前方向は-Zに沿っています

  • 単位 - 慣例として、1ユニットは1メートルです。物理エンジンはメートル単位で動作し、プリミティブのボックスの幅は1ユニットです。
  • 角度 - オイラー角は度単位で、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); // carに対する相対位置

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

wheel.setPosition(0, 0, 0); // ワールドの原点
console.log(wheel.getLocalPosition().toString()); // [-10, 0, 0]

移動と回転​

位置や回転を設定すると、エンティティはそのまま新しい場所に置かれます。今いる場所から動かしたり回したりするには、次のメソッドを使います。

  • translate()はワールドの軸に沿ってエンティティを動かし、translateLocal()はエンティティ自身の軸に沿って動かします。そのため、translateLocal(0, 0, -1)は、エンティティがどちらを向いていても1ユニット前に進めます。
  • rotate()はワールドの軸の周りにエンティティを回し、rotateLocal()はエンティティ自身の軸の周りに回します。どちらも度単位のオイラー角を受け取ります。
  • lookAt()は、エンティティの-Z軸がある位置を向くように回します。

フレームレートにかかわらず同じ速さで動かすには、各フレームの移動量に、前のフレームからの経過秒数であるdtを掛けます。

注意

オイラー角を読み戻して値を足すのはやめてください。1つの回転には複数のオイラー角表現があり、読み取った値が設定した値と異なることがあります。setLocalEulerAngles(0, 120, 0)の後にgetLocalEulerAngles()を呼ぶと(180, 60, 180)が返ります。角度は自分の変数に保持して毎回それを設定するか、rotate()とrotateLocal()を使ってください。

app.on('update', (dt) => {
ship.translateLocal(0, 0, -5 * dt); // 前方へ毎秒5ユニット
ship.rotate(0, 45 * dt, 0); // ワールドのY軸の周りに毎秒45度
});

DynamicなRigidBodyは物理エンジンが動かし、そのトランスフォームへの変更は上書きされます。力やインパルスで動かすか、テレポートさせてください。移動とテレポートを参照してください。

親子関係​

エンティティを新しい親に追加すると、古い親からは取り除かれます。ローカルトランスフォームは保たれるので、エンティティは新しい親に対して同じ相対位置へ飛びます。ワールド内の今の場所に留めるには、先にワールドでの位置と回転を保存しておき、追加した後でそれを設定し直します。

エンティティの子の順序は、特定のインデックスに子を挿入しない限り、追加した順になります。ほとんどのエンティティでは違いはありませんが、ユーザーインターフェースの要素は階層の順に描画されるので、後の兄弟ほど手前に描画されます。

wheelをtrailerに移してローカルトランスフォームを保つには、trailerに追加します。wheelは、古い親に対して持っていたのと同じ相対位置へ、trailerに対して飛びます。

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エンティティの子として、天板用の平たいボックスと、脚用の4つの細いボックスを置きます。

スケールは物理にも関係します。プリミティブから作られたコリジョン形状は、スケールを無視します。コリジョン形状を参照してください。

関連情報​