第 3 課 - シーングラフと変換
前の課では円を描きました。この課では、次のことを学びます。
- 変換。図形が平行移動、拡大縮小、回転、スキューの変換に対応するようにします。
- シーングラフ。
最後に、以上の機能を使って、簡単な太陽系のモデルを作ります。
(async () => {
const { Canvas, Circle, Group } = Lesson3;
const canvas = await Utils.createCanvas(Canvas, 400, 400);
const solarSystem = new Group();
const earthOrbit = new Group();
const moonOrbit = new Group();
const sun = new Circle({
cx: 0,
cy: 0,
r: 100,
fill: 'red',
});
const earth = new Circle({
cx: 0,
cy: 0,
r: 50,
fill: 'blue',
});
const moon = new Circle({
cx: 0,
cy: 0,
r: 25,
fill: 'yellow',
});
solarSystem.appendChild(sun);
solarSystem.appendChild(earthOrbit);
earthOrbit.appendChild(earth);
earthOrbit.appendChild(moonOrbit);
moonOrbit.appendChild(moon);
solarSystem.position.x = 200;
solarSystem.position.y = 200;
earthOrbit.position.x = 100;
moonOrbit.position.x = 100;
canvas.appendChild(solarSystem);
let id;
const animate = () => {
solarSystem.rotation += 0.01;
earthOrbit.rotation += 0.02;
canvas.render();
id = requestAnimationFrame(animate);
};
animate();
unsubscribe(() => {
cancelAnimationFrame(id);
canvas.destroy();
});
return canvas.getDOM();
})();変換
CSS Transform は、translate、scale、rotate、skew という変換を提供します。 これらの変換の背後にある行列は Transformations - LearnOpenGL にあります。シーンに含まれるのは 2D グラフィックスだけなので、必要なのは 3x3 の行列だけです。最後の行 [0, 0, 1] は固定なので、実際に保存する必要があるのは行列の 6 要素だけです。
| a | c | tx|
| b | d | ty|
| 0 | 0 | 1 |図形の基底クラスには、@pixi/math をそのまま使って transform 属性を追加します。もちろん gl-matrix を選ぶこともできます。
import { Transform } from '@pixi/math';
export abstract class Shape {
transform = new Transform();
}メソッドを増やす前に、重要な概念を紹介しましょう。
ローカル座標とワールド座標
座標系は、シーン内のオブジェクトの位置、回転、拡大縮小を表すために使えます。最もよく知られている座標系はユークリッド座標系です。グラフィックスでは重心座標系も使います。ユークリッド空間は最大で N 次元まで含めますが、ここでは 2 次元だけを使います。
「月は地球の周りを回る」と言うとき、実際には地球以外のオブジェクトを無視しています。月のローカル座標系では、月は単にある点の周りを回転しているだけです。太陽系全体のワールド座標系では、地球はなお太陽の周りを回転しており、月は最終的に、上で述べた複雑な軌道をたどります。
ローカル座標系とワールド座標系の概念は、2D と 3D の世界のどちらでも使えます。下の図は playcanvas からのもので、左側がワールド座標系です。軸が常に同じであることが分かります。右側は立方体のローカル座標系で、軸はオブジェクトの変換(この場合は回転)に合わせて変わります。そのため、この回転したオブジェクトがこの時点で X 軸(赤)の正方向へ平行移動すると、地面の下に潜り込むことがあります。
ワールド座標系はシーングラフ内のすべてのノードで共有されるため、原点は (0, 0) に固定され、XYZ 軸(2D シーンでは XY 軸)の向きも固定です。シーン内の箱自身が回転しても、その箱にとってワールド座標系は変わりません。一方、自身のローカル座標系では、原点はもはや (0, 0) ではなくオブジェクト自身の位置になり、軸も当然変わります。名前のとおり、オブジェクト自身に結びついています。
箱を「X 軸(赤)に沿って 10 単位動かす」ことを想像してください。座標系が違えば、意味はまったく異なります。したがって、オブジェクトを変換するときは、まずそれがどの座標系にあるのかを指定しなければなりません。
加えて、ローカル座標系はモデル座標系とも呼ばれ、モデル自身の変換を記述するときに便利です。各兵士に頭を向けさせたいなら、ローカル座標系で行うほうが明らかに簡単です。「向ける」という変換は、各モデルの頭を基準にしているからです。
図形の基底クラスに、ローカル座標系とワールド座標系での変換メソッドを追加します。これらは @pixi/math で提供されています。
export abstract class Shape {
get localTransform(): Matrix {
return this.transform.localTransform;
}
get worldTransform(): Matrix {
return this.transform.worldTransform;
}
}下の図は Fundamentals of Computer Graphics 4th Edition からのものです。ローカル(オブジェクト)座標系がモデル変換によってワールド座標系へ移り、次にカメラ変換によってカメラ座標系へ、さらに投影変換によってクリップ座標系(clip space/NDC)へ、最後にビューポート変換によってスクリーン座標系(screen/pixel space)へ移る様子を示しています。カメラは後で紹介します。今のところ、気にする必要があるのはモデル変換だけです。

次に、頂点の位置を変換するため、モデル変換行列をシェーダーへ渡す必要があります。
アライメント
頂点シェーダーでは、モデル変換行列を Uniform 経由で渡し、位置ベクトルに左から掛けます。
layout(std140) uniform ShapeUniforms {
mat3 u_ModelMatrix;
};
vec2 position = (u_ModelMatrix * vec3(a_Position + a_Size * a_FragCoord, 1)).xy;当然、長さ 9(3 * 3 の行列)の Float32Array を直接作ります。
this.#uniformBuffer = device.createBuffer({
viewOrSize: Float32Array.BYTES_PER_ELEMENT * 9, // mat3
usage: BufferUsage.UNIFORM,
hint: BufferFrequencyHint.DYNAMIC,
});しかしその後、コンソールは、作成した Uniform Buffer の長さが足りないというエラーを出します。何が起きているのでしょうか。
[.WebGL-0x10800c78f00] GL_INVALID_OPERATION: It is undefined behaviour to use a uniform buffer that is too small.ここで Memory layout の概念を導入する必要があります。Uniform Block は packed、shared、std140、std430 というレイアウトルールに対応しています。レイアウトルールが違うと、バッファ内のデータを格納する方法と読み出す方法が変わります。std140 を選ぶ利点は、Program 間でレイアウトの違いがないこと(packed と比べて)、OpenGL の実装間でも違いがないこと(shared と比べて)です。欠点は、アライメントを手動で扱う必要があることです。公式サイトのドキュメントはこの点について警告しており、アライメントを避けるために vec3 を使わないよう促しています。
警告: 実装によっては、vec3 コンポーネントの std140 レイアウトを誤ることがあります。構造体や配列には手動でパディングを入れ、vec3 はまったく使わないようにすることを勧めます。
では、アライメントとは何でしょうか。vec3 を例に取ると、長さは 4 * 3 バイトですが、実際に占めるのは 16 バイトです。実際のアライメント規則は非常に複雑で、ここでの mat3 が実際に必要とするストレージは 4 * 12 バイトです。
this.#uniformBuffer = device.createBuffer({
viewOrSize: Float32Array.BYTES_PER_ELEMENT * 12, // mat3
usage: BufferUsage.UNIFORM,
});wgsl-offset-computer は、可視化を通じてアライメント規則を理解するのに役立つ、優れたオンラインツールです。

データを書き込むときは、手動でパディングを追加する必要があります。
| a | c | tx|
| b | d | ty|
| 0 | 0 | 1 |
| padding | padding | padding |const PADDING = 0;
const { a, b, c, d, tx, ty } = this.worldTransform;
this.#uniformBuffer.setSubData(
0,
new Uint8Array(
new Float32Array([
a,
b,
0,
PADDING,
c,
d,
0,
PADDING,
tx,
ty,
1,
PADDING,
]).buffer,
),
);なお、WGSL にも同じ Alignment of Uniform and Storage buffers があり、Rust のエコシステムにはアライメントを自動化する bytemuck があります。次の例は bevy からのものです。
use bytemuck::{Pod, Zeroable};
#[repr(C)]
#[derive(Copy, Clone, Pod, Zeroable)]
pub struct UiMaterialVertex {
pub position: [f32; 3],
pub uv: [f32; 2],
pub border_widths: [f32; 4],
}図形に、平行移動、回転、拡大縮小などの変換 API を追加しましょう。
平行移動
export abstract class Shape {
get position(): ObservablePoint {
return this.transform.position;
}
set position(value: IPointData) {
this.transform.position.copyFrom(value);
}
get x(): number {
return this.position.x;
}
set x(value: number) {
this.transform.position.x = value;
}
get y(): number {
return this.position.y;
}
set y(value: number) {
this.transform.position.y = value;
}
}使い方は PIXI.js と同じです。
circle = call(() => {
const { Circle } = Lesson3;
return new Circle({
cx: 100,
cy: 100,
r: 50,
fill: 'red',
});
});positionX = Inputs.range([0, 100], { label: 'position.x', value: 0, step: 1 });positionY = Inputs.range([0, 100], { label: 'position.y', value: 0, step: 1 });call(() => {
circle.position.x = positionX;
circle.position.y = positionY;
});(async () => {
const { Canvas } = Lesson3;
const canvas = await Utils.createCanvas(Canvas, 200, 200);
canvas.appendChild(circle);
let id;
const animate = () => {
canvas.render();
id = requestAnimationFrame(animate);
};
animate();
unsubscribe(() => {
cancelAnimationFrame(id);
canvas.destroy();
});
return canvas.getDOM();
})();ピボット
回転、拡大縮小、スキューでは、変換の中心を指定する必要があります。Pixi.js ではこれを pivot と呼びます。CSS の transform-origin とは同じではありません。参照: PixiJS Positioning。
pivot は、オブジェクトの位置のオフセットにも影響します。
export abstract class Shape {
get pivot(): ObservablePoint {
return this.transform.pivot;
}
set pivot(value: IPointData) {
this.transform.pivot.copyFrom(value);
}
}回転
export abstract class Shape {
get rotation(): number {
return this.transform.rotation;
}
set rotation(value: number) {
this.transform.rotation = value;
}
}circle2 = call(() => {
const { Circle } = Lesson3;
const circle = new Circle({
cx: 0,
cy: 0,
r: 50,
fill: 'red',
});
circle.position = { x: 100, y: 100 };
return circle;
});pivotX = Inputs.range([0, 100], { label: 'pivot.x', value: 0, step: 1 });pivotY = Inputs.range([0, 100], { label: 'pivot.y', value: 0, step: 1 });call(() => {
circle2.pivot.x = pivotX;
circle2.pivot.y = pivotY;
});(async () => {
const { Canvas } = Lesson3;
const canvas = await Utils.createCanvas(Canvas, 200, 200);
canvas.appendChild(circle2);
let id;
const animate = () => {
circle2.rotation += 0.01;
canvas.render();
id = requestAnimationFrame(animate);
};
animate();
unsubscribe(() => {
cancelAnimationFrame(id);
canvas.destroy();
});
return canvas.getDOM();
})();拡大縮小
export abstract class Shape {
get scale(): ObservablePoint {
return this.transform.scale;
}
set scale(value: IPointData) {
this.transform.scale.copyFrom(value);
}
}circle3 = call(() => {
const { Circle } = Lesson3;
return new Circle({
cx: 0,
cy: 0,
r: 50,
fill: 'red',
});
});pivotX2 = Inputs.range([0, 100], { label: 'pivot.x', value: 0, step: 1 });pivotY2 = Inputs.range([0, 100], { label: 'pivot.y', value: 0, step: 1 });scaleX = Inputs.range([0, 5], { label: 'scale.x', value: 1, step: 0.1 });scaleY = Inputs.range([0, 5], { label: 'scale.y', value: 1, step: 0.1 });call(() => {
circle3.pivot.x = pivotX2;
circle3.pivot.y = pivotY2;
circle3.scale.x = scaleX;
circle3.scale.y = scaleY;
circle3.position.x = 100;
circle3.position.y = 100;
});(async () => {
const { Canvas } = Lesson3;
const canvas = await Utils.createCanvas(Canvas, 200, 200);
canvas.appendChild(circle3);
let id;
const animate = () => {
canvas.render();
id = requestAnimationFrame(animate);
};
animate();
unsubscribe(() => {
cancelAnimationFrame(id);
canvas.destroy();
});
return canvas.getDOM();
})();スキュー
export abstract class Shape {
get skew(): ObservablePoint {
return this.transform.skew;
}
set skew(value: IPointData) {
this.transform.skew.copyFrom(value);
}
}シーングラフ
SceneGraph は、2D/3D の仮想シーンを組織し管理するためのデータ構造であり、有向非巡回グラフです。シーングラフは、大きく 2 つの機能を提供します。
- 親子関係を記述します
- 親子関係に基づいて、一部の複雑なカスケード計算を自動化します
Figma の左パネルには、シーングラフが表示されます。

次のような階層関係を持つ、簡単な太陽系のシーンを作る必要があると想像してください。
solarSystem
| |
| sun
|
earthOrbit
| |
| earth
|
moonOrbit
|
moon次の API で記述します。Group は図形の基底クラスを継承するだけで、レンダリングメソッドをオーバーライドする必要はありません。
const solarSystem = new Group();
const earthOrbit = new Group();
const moonOrbit = new Group();
const sun = new Circle({
cx: 0,
cy: 0,
r: 100,
fill: 'red',
});
const earth = new Circle({
cx: 0,
cy: 0,
r: 50,
fill: 'blue',
});
const moon = new Circle({
cx: 0,
cy: 0,
r: 25,
fill: 'yellow',
});
solarSystem.appendChild(sun);
solarSystem.appendChild(earthOrbit);
earthOrbit.appendChild(earth);
earthOrbit.appendChild(moonOrbit);
moonOrbit.appendChild(moon);親子関係
図形に parent と children プロパティを追加します。
export abstract class Shape {
parent: Shape;
readonly children: Shape[] = [];
}次に、append/removeChild というノードのメソッドを追加します。子ノードにすでに親ノードが付いている場合は、まずそれを外します。ここでの _parentID の設定は @pixi/math の実装に関係しており、最後の小節で説明します。
export abstract class Shape {
appendChild(child: Shape) {
if (child.parent) {
child.parent.removeChild(child);
}
child.parent = this;
child.transform._parentID = -1;
this.children.push(child);
return child;
}
}シーングラフを走査する
階層ができれば、再帰を使ってシーングラフ全体を走査できます。ここにユーティリティメソッドを追加します。
export function traverse(shape: Shape, callback: (shape: Shape) => void) {
callback(shape);
shape.children.forEach((child) => {
traverse(child, callback);
});
}キャンバスのレンダリング中は、上のユーティリティメソッドを使って、各図形を順番にレンダリングします。
export class Canvas {
render() {
const { hooks } = this.#pluginContext;
hooks.beginFrame.call();
this.#shapes.forEach((shape) => {
traverse(shape, (s) => {
hooks.render.call(s);
});
});
hooks.endFrame.call();
}
}変換を更新する
シーングラフでは、ワールド座標系における子ノードの変換行列を、次の方法で計算します。
child's WorldTransform = parent's WorldTransform
* child's LocalTransform各フレームをレンダリングする前に、変換行列の更新を完了します。
hooks.render.tap((shape) => {
shape.transform.updateTransform(
shape.parent ? shape.parent.transform : IDENTITY_TRANSFORM,
);
shape.render(this.#device, this.#renderPass, this.#uniformBuffer);
});図形が変換されていない場合でも、毎フレーム更新する必要があるのか、気になるかもしれません。シーングラフ全体で、すべての図形に対して演算を行う必要があり、オーバーヘッドはかなり大きいからです。このデザインパターンは 第 2 課 - ダーティフラグ で紹介しました。ここでは @pixi/math の実装を分析しましょう。
変換が起きるたびに、例えば平行移動の変換では、バージョン番号 _localID を増やすだけです。
this.position = new ObservablePoint(this.onChange, this, 0, 0);
protected onChange(): void {
this._localID++;
}ローカル変換行列を更新するときは、バージョン番号を使って、前回以降に変換が起きていないかを判断します。起きていなければ実際の行列演算は行いません。完了したらバージョン番号を更新し、_parentID をリセットして、ワールド変換行列の更新を強制します。
updateTransform(parentTransform: Transform): void {
const lt = this.localTransform;
if (this._localID !== this._currentLocalID) {
// Perform actual matrix operations
lt.a = this._cx * this.scale.x;
// ...
this._currentLocalID = this._localID;
this._parentID = -1;
}
if (this._parentID !== parentTransform._worldID) {
this._parentID = parentTransform._worldID;
this._worldID++;
}
}先ほど追加した新しい appendChild メソッドを覚えていますか。親が変わったので、_parentID をリセットする必要があります。こうすると、次の更新が来たときに、ワールド座標系の行列が再計算されます。
appendChild(child: Shape) {
child.transform._parentID = -1;
}このパターンは PIXI.js でも、バウンディングボックスの計算のようにオーバーヘッドの大きい演算に使われています。