レベルエンター山本大のブログ

面白いプログラミング教育を若い人たちに

BLOCKVROCKリファレンス目次はこちら

a-frame raycasterを使って高低差のある地形に沿って移動する

目的

AframeのカメラはデフォルトでWASDキーや矢印キーによる操作ができますが、高低差のある地形に沿って移動はしてくれません。
また、階段のようなモデルも登っていきたいところですね。
Aframeで地面に指定したモデルの地形に添って移動できるようにします。

下記の動画では、WASDでカメラを動かしてクレーターを登っていき、最終的に高い位置から見下ろします。

f:id:iad_otomamay:20210503123232g:plain

これを実現できるようにすることがこの記事の目的です。

シーンの設定

とりあえず、モデルや空や、目印になる赤いボックスを置きます。
モデルはMozilla の Spoke https://hubs.mozilla.com/spoke からダウンロードしたクレーターです。

<a-scene>
  <a-assets>
    <a-asset-item id="envGlb" src="./Crater.glb"></a-asset-item>
  </a-assets>

  <a-gltf-model position="0 0 0" src="#envGlb" class="ground" ></a-gltf-model>

  <a-box color="red" position="0 1 -4" scale="2 2 2"></a-box>

  <a-sky color="skyblue"></a-sky>
</a-scene>

カメラの設定

カメラにraycasterを設定。raycasterは「objects:」属性にCSSクエリセレクタを設定することで、交差イベントを発火するオブジェクトを決められます。
このサンプルではCraterモデルのclassに「ground」を設定したので、Craterの3Dモデルに交差するとイベントを発火します。
またraycasterのdirectionプロパティの中の yの値を -1 にすることで下方向のraycasterとなります。
directionは、Vector3型となっていて"0 0 0"のようにスペース区切りの3つの値を設定することで x y zを指定できます。

<a-camera stand-on="raycaster:#stand-on-ray">
  <a-entity id="stand-on-ray" raycaster="objects: .ground; direction:0 -1 0"></a-entity>
</a-camera>

aframeのカスタムコンポーネントを作成

次にAframeのコンポーネントを作成します。「stand-on」と名付けました。schemaでは、このコンポーネントに対する外部入力プロパティを設定することができます。raycaster という名前は任意でありraycasterコンポーネントとは無関係なので別の名前にした方がわかりやすいかもしれません。selecter型にしておくことで、CSSクエリセレクタを入力として受付て、内部的にはDOMの参照として利用することができます。

カメラに設定するraycasterは複数つくることがあり得るので、このコンポーネントが使うraycasterは、どれなのかを指定できるようにしておきます。(今後記事で壁との衝突判定も記載しようと思います)

AFRAME.registerComponent('stand-on', {
  schema: {
    raycaster: { type: 'selector' },
  },
  dependency: ['raycaster'],
}

init の処理

Aframeコンポーネントの初期化(init関数)時に、raycasterが交差したことを表すイベント(raycaster-intersection)をリッスンします。
カメラに複数のraycaster(例えば壁との衝突判定など)を設定していた場合、raycaster-intersectionは全てのraycasterで反応します。
そのため今発生しているイベントがstand-onに対応した下向きのraycasterの交差であることを確認します。

schemaで指定されたraycasterで発生した時だけ、raycasterと交差先のオブジェクトを保存しておきます。*1

同じく交差イベントがクリアされたタイミング(raycasterの交差が外れたタイミング)で、保存していたオブジェクトを削除します。

init: function () {
  this.el.addEventListener('raycaster-intersection', (evt) => {
    if (evt.target !== this.data.raycaster) return; // 対応するレイキャスターのみ反応
    this.raycaster = evt.target.components.raycaster;
    this.target_el = evt.detail.els[0];
  });

  this.el.addEventListener('raycaster-intersection-cleared', (evt) => {
    if (evt.target !== this.data.raycaster) return;
    this.raycaster = null;
    this.target_el = null;
  });
},

raycaster-intersectionのEvent

raycaster-intersectionのEventの主なプロパティ

event
	.detail
		.intersections[n]	交差を表すオブジェクト配列
				.face 交差面
				.distance 距離
				.object	交差先Treejsオブジェクト
				.point	交差した点のVector3座標
				.uv	交差した点のVector2座標
		.els	交差先オブジェクトのDOM
	.target	反応したレイキャスタDOM

tickの処理

tickでは、raycasterと交差先オブジェクトが保存されている(交差イベントが発生して交差が外れるまで)の間ずっと、getIntersection()を使って最新の交差位置を取得します。これでカメラの配下にある地形を常に読み取ることになります。
最新の交差位置のy座標をカメラの高さに合わせます。1.7を地面からの視点の高さとしました。この1.7もschemaで読み込んでもいいですね。

tick: function () {
  if (!this.raycaster || !this.target_el) return;
  const item = this.raycaster.getIntersection(this.target_el);
  this.el.object3D.position.y = item.point.y + 1.7;
},


最終的なコードは以下です。

<html lang="ja">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <script src="https://aframe.io/releases/1.2.0/aframe.min.js"></script>
    <title>Aframe example Stand on Ground</title>
  </head>

  <script type="text/javascript">
    /**
     * 地面に立つためのコンポーネント
     */
    AFRAME.registerComponent('stand-on', {
      schema: {
        raycaster: { type: 'selector' },
      },
      dependency: ['raycaster'],
      init: function () {
        this.el.addEventListener('raycaster-intersection', (evt) => {
          if (evt.target !== this.data.raycaster) return; // 対応するレイキャスターのみ反応
          this.raycaster = evt.target.components.raycaster;
          this.target_el = evt.detail.els[0];
        });

        this.el.addEventListener('raycaster-intersection-cleared', (evt) => {
          if (evt.target !== this.data.raycaster) return;
          this.raycaster = null;
          this.target_el = null;
        });
      },

      tick: function () {
        if (!this.raycaster || !this.target_el) return;
        const item = this.raycaster.getIntersection(this.target_el);
        this.el.object3D.position.y = item.point.y + 1.7;
      },
    });
  </script>
  <body>
    <a-scene>
      <a-assets>
        <a-asset-item id="envGlb" src="./Crater.glb"></a-asset-item>
      </a-assets>

      <a-camera stand-on="raycaster:#stand-on-ray">
        <a-entity id="stand-on-ray" raycaster="objects: .ground; direction:0 -1 0"></a-entity>
      </a-camera>

      <a-gltf-model position="0 0 0" src="#envGlb" class="ground" ></a-gltf-model>

      <a-box color="red" class="collidable" position="0 1 -4" scale="2 2 2"></a-box>

      <a-sky color="skyblue"></a-sky>
    </a-scene>
  </body>
</html>

*1:Aframe の1.2.0の時点では、交差イベント(raycaster-intersection)は、交差が開始したタイミングでしか発行されません。状態のリフレッシュはtickで実装します。tickで使うためにraycasterと交差先オブジェクトを保存しておきます。