A-Frameのエンティティコンポーネントフレームワーク のコンポーネントは、 エンティティ上で混合、組み合わせ、合成することができるJavaScriptモジュールです。これを用いて外観、動作、機能を構築することができます。 コンポーネントをJavaScriptで登録し、DOMから宣言的に利用することができます。 このコンポーネントは、細かな設定ができ再利用可能で共有可能です。 A-Frameアプリケーションのコードのほとんどは、コンポーネントの中にあるべきです。

vehicleimage Image by Ruben Mueller from vrjump.de

このガイドでは、かなりゆっくりと進めます。 このガイドをよりよく理解するために、こちらを読む前に、コンポーネント APIドキュメントにざっと目を通すことをお勧めします。 注 コンポーネントは、 の前に定義する必要があることに注意してください。 例えば

<html>
  <head>
    <script2 src="foo-component.js"></script>
  </head>
  <body>
    <script>
      // Or inline before the <a-scene>.
      AFRAME.registerComponent('bar', {
        // ...
      });
    </script>

    <a-scene>
    </a-scene>
  </body>
</html>

コンポーネントの書き方について、例を挙げて説明します。 このサンプルは小さなトリビアばかりですが、データの流れ、API、使い方を説明しています。 これ以外のコンポーネントのサンプルは、エコシステムのコンポーネントを学ぶ.を通して学ぶを参照してください。

# Example: hello-world Component

まずは、一般的な考え方を知るために、最も基本的なコンポーネントから見ていきましょう。 このコンポーネントは、コンポーネントの実体が .init() ハンドラを使ってアタッチされたときに、簡単なメッセージを一度だけ記録します。

# AFRAME.registerComponentでコンポーネントを登録する

コンポーネントの登録は、AFRAME.registerComponent()で行います。 コンポーネントの名前を渡しますが、これはDOMの中ででコンポーネントを表現する際のHTML属性名として使用されます。 次に、メソッドとプロパティを含むJavaScriptオブジェクトであるコンポーネントの定義を渡します。 この定義の中で、ライフサイクルハンドラのメソッドを定義することができます。 そのうちのひとつが .init() で、これはコンポーネントがそのエンティティに最初に接続された時にときに一度だけ呼ばれる。

以下の例では、シンプルに.init()ハンドラに簡単なメッセージを記録させています。

AFRAME.registerComponent('hello-world', {
  init: function () {
    console.log('Hello, World!');
  }
});

# HTMLからコンポーネントを使用する

次に、hello-world コンポーネントをHTML の属性として宣言的に使用することができます。

<a-scene>
  <a-entity hello-world></a-entity>
</a-scene>

これで、エンティティがアタッチされ初期化され、hello-world コンポーネントが初期化されます。 コンポーネントの素晴らしいところは、エンティティの準備が整った後に呼び出されることです。 シーンやエンティティのセットアップを待つことを心配する必要はありません、これで動きます。 コンソールを確認すれば、シーンが実行され、エンティティがアタッチされた後、Hello, World!が記録されます。

# JSからコンポーネントを使用する

コンポーネントを設定するもう一つの方法は、静的なHTML経由ではなく、.setAttribute()でプログラム的に設定することです。 scene 要素はコンポーネントを受け取ることができるので、 プログラムによって hello-world コンポーネントを シーンに設定してみましょう。

document.querySelector('a-scene').setAttribute('hello-world', '');

# 例: log コンポーネント

Similar to the hello-world component, let's make a log component. It'll still only just do console.log, but we'll make it able to console.log more than just Hello, World!. Our log component will log whatever string its passed in. We'll find out how to pass data to components by defining configurable properties via the schema. hello-worldコンポーネントに似せて、logコンポーネントを作ってみましょう。 これはまだ console.log しかできませんが、Hello World以上のことを console.log でできるようにしましょう。 この log コンポーネントは、引き渡されたどんな文字列でもログに記録します。 が渡されます。 ここでは、コンポーネントにデータを渡す方法について、スキーマで設定可能なプロパティの定義によってデータをどのようにコンポーネントに渡すかを見てみましょう。

# スキーマによるプロパティの定義

スキーマは、そのコンポーネントのプロパティを定義します。 例えるなら、コンポーネントを関数と考えると、コンポーネントのプロパティはその関数の引数のようなものです。 プロパティには、名前があり(コンポーネントに複数のプロパティがある場合)、デフォルト値を持ち、プロパティタイプがあります。 プロパティタイプは、データが文字列として渡された際に(つまり、DOMから)、どのようにパースされるかを定義します。

logコンポーネントの場合、スキーマを介してmessageプロパティタイプを定義します。 メッセージプロパティタイプは、文字列のプロパティ・タイプを持ち、Hello, World!のデフォルト値を持ちます。

AFRAME.registerComponent('log', {
  schema: {
    message: {type: 'string', default: 'Hello, World!'}
  },
  // ...
});

# ライフサイクルハンドラからのプロパティデータを利用する

文字列プロパティタイプは、入力されたデータの解析を行わず、そのままライフサイクルメソッドハンドラーに渡します。 では、そのメッセージプロパティタイプをconsole.logしてみましょう。 hello-world コンポーネントと同様に .init() ハンドラを書きますが、 今回はハードコードされた文字列をログに記録しません。 このコンポーネントのプロパティタイプの値は、this.data を通して利用できます。 では、this.data.message をログに記録してみましょう。

AFRAME.registerComponent('log', {
  schema: {
    message: {type: 'string', default: 'Hello, World!'}
  },

  init: function () {
    console.log(this.data.message);
  }
});

そして、HTMLから、コンポーネントをエンティティに付与することができます。 マルチプロパティコンポーネントの場合、構文はインラインのCSSスタイル (opens new window)と同じです(プロパティ名/値のペアは : で区切り、プロパティは ; で区切ります)。

<a-scene>
  <a-entity log="message: Hello, Metaverse!"></a-entity>
</a-scene>

# プロパティの更新を処理する

これまでのところ、コンポーネントのライフサイクルの最初に一度だけ呼び出される .init() ハンドラだけを使用して、その初期プロパティだけを使用してきました。 しかし、コンポーネントは度々そのプロパティを動的に更新します。 .update()ハンドラを使って、プロパティの更新を処理することができます。

methodsimage Lifecycle method handlers. Image by Ruben Mueller from vrjump.de

これを実証するために、ログコンポーネントは、そのエンティティがイベントを発する時だけログを記録するようにします。 最初に、コンポーネントがリッスンするイベントを指定するイベントプロパティタイプを追加します。

// ...
schema: {
  event: {type: 'string', default: ''},
  message: {type: 'string', default: 'Hello, World!'},
},
// ...

次に、すべてを .init() ハンドラから .update() ハンドラに移動させます。 .update()ハンドラも、コンポーネントがアタッチされたときに.init()の直後に呼び出されます。 時に、ロジックのほとんどを .update() ハンドラで実行することで、コードを繰り返すことなく初期化と更新を一度に処理できるようにしています。

ここでやりたいことは、メッセージをログに記録する前にイベントを聞くイベントリスナーを追加することです。 イベントのプロパティタイプが指定されていない場合は、単にメッセージを記録します。

AFRAME.registerComponent('log', {
  schema: {
    event: {type: 'string', default: ''},
    message: {type: 'string', default: 'Hello, World!'}
  },

  update: function () {
    var data = this.data;  // Component property values.
    var el = this.el;  // Reference to the component's entity.

    if (data.event) {
      // This will log the `message` when the entity emits the `event`.
      el.addEventListener(data.event, function () {
        console.log(data.message);
      });
    } else {
      // `event` not specified, just log the message.
      console.log(data.message);
    }
  }
});

イベントリスナープロパティを追加したので、実際のプロパティの更新を処理してみましょう。 イベントのプロパティタイプが変更された場合(例えば、.setAttribute()の結果として)、 以前のイベントリスナーを削除し、新しいイベントリスナーを追加する必要があります。

しかし、イベントリスナーを削除するためには、その関数への参照が必要です。 そこで、まず、イベントリスナーを付与するたびに this.eventHandlerFn に関数を格納しておくことにしましょう。 thisを介してコンポーネントにプロパティを付与すると、他のすべてのライフサイクルハンドラで利用可能になります。

AFRAME.registerComponent('log', {
  schema: {
    event: {type: 'string', default: ''},
    message: {type: 'string', default: 'Hello, World!'}
  },

  init: function () {
    // Closure to access fresh `this.data` from event handler context.
    var self = this;

    // .init() is a good place to set up initial state and variables.
    // Store a reference to the handler so we can later remove it.
    this.eventHandlerFn = function () { console.log(self.data.message); };
  },

  update: function () {
    var data = this.data;
    var el = this.el;

    if (data.event) {
      el.addEventListener(data.event, this.eventHandlerFn);
    } else {
      console.log(data.message);
    }
  }
});

これで、イベントハンドラ関数が格納されました。 eventプロパティタイプが変わるたびに、イベントリスナーを削除することができます。 イベントプロパティタイプが変更されたときだけ、イベントリスナーを更新するようにしたいです。 そのためには、.update() ハンドラによって提供されるoldData 引数とthis.data を照合します。

AFRAME.registerComponent('log', {
  schema: {
    event: {type: 'string', default: ''},
    message: {type: 'string', default: 'Hello, World!'}
  },

  init: function () {
    var self = this;
    this.eventHandlerFn = function () { console.log(self.data.message); };
  },

  update: function (oldData) {
    var data = this.data;
    var el = this.el;

    // `event` updated. Remove the previous event listener if it exists.
    if (oldData.event && data.event !== oldData.event) {
      el.removeEventListener(oldData.event, this.eventHandlerFn);
    }

    if (data.event) {
      el.addEventListener(data.event, this.eventHandlerFn);
    } else {
      console.log(data.message);
    }
  }
});

それでは、更新イベントリスナーをコンポーネントでテストしてみましょう。以下は、このシーンです。

<a-scene>
  <a-entity log="event: anEvent; message: Hello, Metaverse!"></a-entity>
</a-scene>

試しにエンティティにイベントを発行させてみます。

var el = document.querySelector('a-entity');
el.emit('anEvent');
// >> "Hello, Metaverse!"

そして、このイベントに.update()ハンドラーを試してみます。

var el = document.querySelector('a-entity');
el.setAttribute('log', {event: 'anotherEvent', message: 'Hello, new event!'});
el.emit('anotherEvent');
// >> "Hello, new event!"

# コンポーネントの削除を操作する

コンポーネントをエンティティから外すケース(つまり .removeAttribute('log') )に対処してみましょう。 コンポーネントが削除されたときに呼び出される .remove() ハンドラを実装することができます。 このログコンポーネントの場合、コンポーネントがエンティティにアタッチしたイベントリスナーをすべて削除します。

AFRAME.registerComponent('log', {
  schema: {
    event: {type: 'string', default: ''},
    message: {type: 'string', default: 'Hello, World!'}
  },

  init: function () {
    var self = this;
    this.eventHandlerFn = function () { console.log(self.data.message); };
  },

  update: function (oldData) {
    var data = this.data;
    var el = this.el;

    if (oldData.event && data.event !== oldData.event) {
      el.removeEventListener(oldData.event, this.eventHandlerFn);
    }

    if (data.event) {
      el.addEventListener(data.event, this.eventHandlerFn);
    } else {
      console.log(data.message);
    }
  },

  /**
   * Handle component removal.
   */
  remove: function () {
    var data = this.data;
    var el = this.el;

    // Remove event listener.
    if (data.event) {
      el.removeEventListener(data.event, this.eventHandlerFn);
    }
  }
});

では、削除ハンドラをテストしてみましょう。コンポーネントを削除し、イベントを発行しても何も起こらないことを確認しましょう。

<a-scene>
  <a-entity log="event: anEvent; message: Hello, Metaverse!"></a-entity>
</a-scene>
var el = document.querySelector('a-entity');
el.removeAttribute('log');
el.emit('anEvent');
// >> Nothing should be logged...

# コンポーネントの複数インスタンスを許可する

同じエンティティに複数のログコンポーネントをアタッチすることを許可してみましょう。 そのためには、.multipleフラグで複数のインスタンスを有効にします。これをtrueに設定します。

AFRAME.registerComponent('log', {
  schema: {
    event: {type: 'string', default: ''},
    message: {type: 'string', default: 'Hello, World!'}
  },

  multiple: true,

  // ...
});

The syntax for an attribute name for a multiple-instanced component has the form of <COMPONENTNAME>__<ID>, a double-underscore with an ID suffix. The ID can be whatever we choose. For example, in HTML:

マルチインスタンスコンポーネントに対する属性名の構文は、<COMPONENTNAME>__<ID>という形式をとります。 2重のアンダースコアにIDの接尾辞が付きます。IDは任意に設定することができる。 例えば、HTMLでは

<a-scene>
  <a-entity log__helloworld="message: Hello, World!"
            log__metaverse="message: Hello, Metaverse!"></a-entity>
</a-scene>

Java Scriptでは

var el = document.querySelector('a-entity');
el.setAttribute('log__helloworld', {message: 'Hello, World!'});
el.setAttribute('log__metaverse', {message: 'Hello, Metaverse!'});

このコンポーネント内では、必要であれば、this.idthis.attrNameを使って、 異なるインスタンスを区別することができます。 log__helloworldの場合、this.idhelloworldとなり、this.attrNamelog__helloworldとなります。

これで基本的なログコンポーネントが出来上がりました。

# 例: boxコンポーネント

ここでは、three.jsを用いて3Dオブジェクトを追加したり、3Dオブジェクトがシーングラフに影響を与える方法について説明します。 これを理解するために、 boxコンポーネントを作成し、 することで、そのため ここでは、ジオメトリとマテリアルにボックスメッシュを持った基本的なboxコンポーネントを使ってみます。

boximage Image by Ruben Mueller from vrjump.de

注:これは、Hello, World!コンポーネントを書くのと同じ3Dに過ぎません。 A-Frameでボックスを作りたい場合にはジオメトリマテリアルコンポーネントを使えます

# スキーマとAPI

まずはスキーマからはじめましょう。スキーマはコンポーネントのAPIを定義します。 幅、高さ、奥行き、色をプロパティで設定できるようにします。 幅、高さ、奥行きは数値型(つまりfloat)にし、デフォルトは1mにします。色の種類はカラータイプ(つまり文字列)とし、デフォルトはグレーとします。

AFRAME.registerComponent('box', {
  schema: {
    width: {type: 'number', default: 1},
    height: {type: 'number', default: 1},
    depth: {type: 'number', default: 1},
    color: {type: 'color', default: '#AAA'}
  }
});

後でこのコンポーネントをHTML経由で使用する場合、構文は次のようになります。

<a-scene>
  <a-entity box="width: 0.5; height: 0.25; depth: 1; color: orange"
            position="0 0 -5"></a-entity>
</a-scene>

# Boxメッシュを作成する

.init()でthree.jsのボックスメッシュを作成し、後で.update()ハンドラを使ってすべてのプロパティの更新を処理するようにしましょう。 three.jsでボックスを作成するには、THREE.BoxBufferGeometry (opens new window)THREE.MeshStandardMaterial (opens new window)を作成し、最後に THREE.Mesh (opens new window)を作成します。 次に、エンティティにメッシュを設定し、.setObject3D(name, object)を使用して、three.jsのシーングラフにメッシュを追加していきます。

AFRAME.registerComponent('box', {
  schema: {
    width: {type: 'number', default: 1},
    height: {type: 'number', default: 1},
    depth: {type: 'number', default: 1},
    color: {type: 'color', default: '#AAA'}
  },

  /**
   * Initial creation and setting of the mesh.
   */
  init: function () {
    var data = this.data;
    var el = this.el;

    // Create geometry.
    this.geometry = new THREE.BoxBufferGeometry(data.width, data.height, data.depth);

    // Create material.
    this.material = new THREE.MeshStandardMaterial({color: data.color});

    // Create mesh.
    this.mesh = new THREE.Mesh(this.geometry, this.material);

    // Set mesh on entity.
    el.setObject3D('mesh', this.mesh);
  }
});

では、更新をしてみましょう。ジオメトリ関連のプロパティ(幅、高さ、奥行きなど)が更新されると、ジオメトリが再作成されます。 マテリアル関連のプロパティ(色など)が更新された場合は、その場でマテリアルを更新するだけです。メッシュにアクセスして更新するには、.getObject3D('mesh')を使用します。

AFRAME.registerComponent('box', {
  schema: {
    width: {type: 'number', default: 1},
    height: {type: 'number', default: 1},
    depth: {type: 'number', default: 1},
    color: {type: 'color', default: '#AAA'}
  },

  init: function () {
    var data = this.data;
    var el = this.el;
    this.geometry = new THREE.BoxBufferGeometry(data.width, data.height, data.depth);
    this.material = new THREE.MeshStandardMaterial({color: data.color});
    this.mesh = new THREE.Mesh(this.geometry, this.material);
    el.setObject3D('mesh', this.mesh);
  },

  /**
   * Update the mesh in response to property updates.
   */
  update: function (oldData) {
    var data = this.data;
    var el = this.el;

    // If `oldData` is empty, then this means we're in the initialization process.
    // No need to update.
    if (Object.keys(oldData).length === 0) { return; }

    // Geometry-related properties changed. Update the geometry.
    if (data.width !== oldData.width ||
        data.height !== oldData.height ||
        data.depth !== oldData.depth) {
      el.getObject3D('mesh').geometry = new THREE.BoxBufferGeometry(data.width, data.height,
                                                                    data.depth);
    }

    // Material-related properties changed. Update the material.
    if (data.color !== oldData.color) {
      el.getObject3D('mesh').material.color = new THREE.Color(data.color);
    }
  }
});

# Box メッシュを削除する

Lastly, we'll handle when the component or entity is removed. In this case, we'll want to remove the mesh from the scene. We can do so with the .remove() handler and .removeObject3D(name):

最後に、コンポーネントやエンティティが削除されたときの処理をします。今回は、シーンからメッシュを削除したいと思います。 .remove()ハンドラと.removeObject3D(name)を使って行います。

AFRAME.registerComponent('box', {
  // ...

  remove: function () {
    this.el.removeObject3D('mesh');
  }
});

これで基本的なthree.jsのボックスコンポーネントの完成です! 実際には、three.jsのコンポーネントは、もっと役に立ちます。 three.jsで実現できることはすべて、A-Frameコンポーネントでラップして宣言することができます。 というわけで、three.jsの機能とエコシステムをチェックして、どんなコンポーネントが書けるか見てみましょう!

# 例: follow コンポーネント

あるエンティティに他のエンティティをフォローするように指示するfollowコンポーネントを書いてみましょう。 これは、レンダリングループの全てのフレームで実行される継続的な動作をシーンに追加する .tick() ハンドラの使用を実演するものです。 また、エンティティ間の関係についてもの実演します。

# スキーマとAPI

First off, we'll need a target property that specifies which entity to follow. A-Frame has a selector property type to do the trick, allowing us to pass in a query selector and get back an entity element. We'll also add a speed property (in m/s) to tell specify how fast the entity should follow.

まず、どのエンティティをフォローするかを指定するターゲットプロパティが必要になります。 A-Frameには、これを実行するためのselectorプロパティタイプがあり、 クエリセレクタに渡し、エンティティエレメントが返ります。 さらに、Speedプロパティ(単位:m/s)を追加して、エンティティがどの程度の速度で追従するかを指定します。

AFRAME.registerComponent('follow', {
  schema: {
    target: {type: 'selector'},
    speed: {type: 'number'}
  }
});

後で、このコンポーネントをHTML経由で使用する場合、構文は次のようになります。

<a-scene>
  <a-box id="target-box" color="#5E82C5" position="-3 0 -5"></a-box>
  <a-box follow="target: #target-box; speed: 1" color="#FF6B6B" position="3 0 -5"></a-box>
</a-scene>

# ヘルパーベクターの作成

.tick()ハンドラは毎フレーム(例えば1秒間に90回)呼び出されるので、そのパフォーマンスを確認しておきたいところです。 その際、THREE.Vector3オブジェクトのような不要なオブジェクトをtick毎に作成するのは避けたいところです。 私達が避けたいのはTHREE.Vector3 オブジェクトのようなものがtickごとに不必要なオブジェクトを生成することです。 これはガベージコレクションを中断させることに役立ちます。 THREE.Vector3を使ってベクトル演算を行う必要があるので、.init()ハンドラで一度作成し、後で再利用できるようにします。

AFRAME.registerComponent('follow', {
  schema: {
    target: {type: 'selector'},
    speed: {type: 'number'}
  },

  init: function () {
    this.directionVec3 = new THREE.Vector3();
  }
});

# .tick()ハンドラでビヘイビアを定義する

次に、.tick()ハンドラを書いて、コンポーネントがターゲットに向かってエンティティを任意のスピードで連続的に移動するようにします。 A-Frame は、グローバルなシーンの稼働時間をtimeとして引き渡し、 最後のフレームからの時間を timeDelta として tick() ハンドラーにミリ秒単位で渡します。 timeDelta を使って、与えられたスピードで、そのフレーム内でエンティティがターゲットに向かってどれだけ移動すべきかを計算することができます。

エンティティの向かうべき方向を計算するために、ターゲットエンティティの方向ベクトルからエンティティの位置ベクトルを引きます。 .object3Dを通してエンティティのthree.jsオブジェクトにアクセスし、そこから位置ベクトル.positionにアクセスしています。 方向ベクトルは あらかじめinit() ハンドラで割り当てた this.directionVec3 に格納されます。

次に、移動距離と速度、そして最後のフレームからの経過時間を考慮して、エンティティの位置に追加する適切なベクトルを見つける。 .setAttributeでエンティティを翻訳し、次のフレームで再び.tick()ハンドラが実行されます。

.tick()ハンドラの全容は以下の通りです。 .tick() は、レンダリングループを実際に参照することなく、 レンダリングループに簡単にフックすることができるため、非常に便利です。メソッドを定義するだけです。 以下、コードのコメントに従ってください。

AFRAME.registerComponent('follow', {
  schema: {
    target: {type: 'selector'},
    speed: {type: 'number'}
  },

  init: function () {
    this.directionVec3 = new THREE.Vector3();
  },

  tick: function (time, timeDelta) {
    var directionVec3 = this.directionVec3;

    // Grab position vectors (THREE.Vector3) from the entities' three.js objects.
    var targetPosition = this.data.target.object3D.position;
    var currentPosition = this.el.object3D.position;

    // Subtract the vectors to get the direction the entity should head in.
    directionVec3.copy(targetPosition).sub(currentPosition);

    // Calculate the distance.
    var distance = directionVec3.length();

    // Don't go any closer if a close proximity has been reached.
    if (distance < 1) { return; }

    // Scale the direction vector's magnitude down to match the speed.
    var factor = this.data.speed / distance;
    ['x', 'y', 'z'].forEach(function (axis) {
      directionVec3[axis] *= factor * (timeDelta / 1000);
    });

    // Translate the entity in the direction towards the target.
    this.el.setAttribute('position', {
      x: currentPosition.x + directionVec3.x,
      y: currentPosition.y + directionVec3.y,
      z: currentPosition.z + directionVec3.z
    });
  }
});

# エコシステムのコンポーネントを通して学ぶ

There are a large number of components in the ecosystem, most of them open source on GitHub. One way to learn is to browse the source code of other components to see how they're built and what use cases they provide for. Here are a few places to look:

このエコシステムの中には多くのコンポーネントがあり、そのほとんどがGitHubでオープンソース化されています。 学習方法の一つとして、他のコンポーネントのソースコードを眺めて、どのように構築されているか、 どのようなユースケースを提供しているかを確認することです。 ここでは、いくつかの場所を紹介します。

# コンポーネントを出力する

実際に使用される多くのコンポーネントは、特定のアプリケーションに向けたものや、一点物のコンポーネントになるでしょう。 しかし、もしあなたがコミュニティにとって有用で、他のアプリケーションでも動作するような汎用性のあるコンポーネントを書いたのであれば、ぜひそれを公開すべきです!

コンポーネントテンプレートには、アングル (opens new window)の使用をお勧めします。 angleはA-Frameのコマンドラインインターフェースで、その特徴の一つは、GitHubやnpmに公開するためのコンポーネントテンプレートを設定し、またエコシステムの他のすべてのコンポーネントと整合性を持つことです。 テンプレートをインストールするには

npm install -g angle && angle initcomponent

と実行します。

initcomponent テンプレートをセットアップするためにコンポーネント名のようないくつかの情報を要求します。 コード、サンプル、ドキュメントを書いて、GitHub と npm に公開しましょう!