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

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

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

a-frame raycasterを使って壁との衝突判定を実装する

a-frame raycasterを使って高低差のある地形に沿って移動する - レベルエンター山本大のブログの続き  

目的

a-frameではWASDキーおよび矢印キーでカメラの位置を移動させることができるものの、壁や物体への衝突判定は自分で作り込む必要があります。

以下の動画のように前進、右移動、左移動、後退のどの方向でも障害物に当たったら跳ね返されるようにすることがこの記事の目的です。

f:id:iad_otomamay:20210504014123g:plain

障害物に当たったら、その場にストップするという仕様も考えられますが、そうすると壁にめり込んだりというトラブルが発生しがちです。 めりこみを避けるためにぶつかったら、ちょっと跳ね返るという処理にしておきます。

準備

前回投稿した以下のプロジェクトを土台として、そこに付け足す形で実装します。 a-frame raycasterを使って高低差のある地形に沿って移動する - レベルエンター山本大のブログ

カメラに四方のレイキャスターを追加

前回、a-cameraにはstand-onというカスタムコンポーネントを設定し、下方向のレイキャスターを設定しました。

同じ仕組みで前方・後方・右側・左側のレイキャスターを設定します。

今回のカスタムコンポーネントは、wall-colliderという名前です。

a-cameraにwall-collider属性を複数設定しているため、__以下に識別子をつけた名前にしています。

この multiple instance の仕組みはComponent – A-Frameを参照してください。

raycasterは、directionによって前後左右に向くように設定します。

<a-camera
    wall-collider__f="raycaster:#raycaster-f"
    wall-collider__b="raycaster:#raycaster-b"
    wall-collider__r="raycaster:#raycaster-r"
    wall-collider__l="raycaster:#raycaster-l"
    stand-on="raycaster:#stand-on-ray"
>
    <a-entity id="stand-on-ray" raycaster="objects: .ground; direction:0 -1 0"></a-entity>

    <!-- 四方のレイキャスターを定義 -->
    <a-entity id="raycaster-f" raycaster="objects: .collidable; far: 1; direction:0 0 -1"></a-entity>
    <a-entity id="raycaster-b" raycaster="objects: .collidable; far: 1; direction:0 0 1"></a-entity>
    <a-entity id="raycaster-l" raycaster="objects: .collidable; far: 1; direction:1 0 0"></a-entity>
    <a-entity id="raycaster-r" raycaster="objects: .collidable; far: 1; direction:-1 0 0"></a-entity>
</a-camera>

カスタムコンポーネント

カメラに設定したwall-colliderコンポーネントを定義します。

a-frameのコンポーネントは、a-sceneより先に読み込まれている必要があります。

multiple:trueという属性を指定することで wall-collider__XXXのように、1要素に対して__区切りで複数のインスタンスを設定することができるようになります。

schemaやdependencyは前の記事を参照してください。

AFRAME.registerComponent('wall-collider', {
      schema: {
          raycaster: { type: 'selector' },
      },
      multiple: true, // 複数指定可能
      dependency: ['raycaster'],
}

初期化処理 initの実装

コンポーネントの初期化処理も、前回記事の地面に立つコンポーネントとよく似ています。

  1. カメラに設定したraycasterのいずれかが対象に交差したときにraycaster-intersectionイベントが発生します。

  2. そのタイミングで反応するべきraycasterであることを調べます。

  3. 交差先オブジェクトとの接面を保存しておきます。(次のtickのタイミングで使うため)

  4. 交差が外れたら保存したオブジェクトをクリアしておきます。

evt.detail.intersections[0].face.normalの記述部分だけが、前回と異なります。 交差オブジェクトとの接面(face)を取得して、nomalizeして保存しておくというコードです。

nomalizeすることで、ベクトルから距離の情報を取り除き、向きを表す単位ベクトルに変換することができます。 つまり、"0 1 0" や"0 0 -1"のようにxyzを0,1,-1だけで表すベクトルに変換されます。

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

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

tickの実装

tickでは、faceが保存されていれば対象オブジェクトと衝突しているので、跳ね返すのですがthree.jsのメソッドを使えば、大変簡単な記述で実現できます。以下の処理だけ。

tick: function () {
    if (!this.face) return;
    this.el.object3D.position.add(this.face.multiplyScalar(0.5));
},

一つ一つ説明します。 this.faceが登録されていなければ、衝突していないので処理しないとします。

次にthis.faceが登録されている場合です。 カメラの要素に設定したコンポーネント内であるため、this.elはカメラのaframe要素となります。そのカメラ要素からobject3Dプロパティでthreejsのオブジェクトを取り出します。

three.jsのadd()メソッドは、ベクトル同士の和(足し算)を計算してくれるものです。

this.faceに保存された接面のベクトルは、衝突方向と反転したベクトルを持っているため、カメラの位置(position)に反転ベクトルを足し算します。add メソッドは呼び出し元のベクトルに計算結果を反映させるため、跳ね返してくれるというわけです。

mulitplyScalarはベクトルに対して単一の値を掛け算できるメソッドで、これで跳ね返りの距離を調整します。

(あまり小さい数にすると、衝突後もオブジェクトをすり抜けてしまうので注意が必要です)

three.jsのおかげでとっても短いコードで実現できましたね。

全てのコード

 

<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;
      },
    });

    /**
     * 前後左右のヒット(今回記事の範囲)
     */
    AFRAME.registerComponent('wall-collider', {
      schema: {
        raycaster: { type: 'selector' },
      },
      multiple: true,
      dependency: ['raycaster'],
      init: function () {
        this.el.addEventListener('raycaster-intersection', (evt) => {
          console.log(evt.target);
          if (evt.target !== this.data.raycaster) return; // 対応するレイキャスターのみ反応
          this.face = evt.detail.intersections[0].face.normal;
        });

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

      tick: function () {
        if (!this.face) return;
        this.el.object3D.position.add(this.face.multiplyScalar(0.5));
      },
    });
  </script>
  <body>
    <a-scene>
      <a-assets>
        <a-asset-item id="envGlb" src="./Crater.glb"></a-asset-item>
      </a-assets>

      <a-camera
        wall-collider__f="raycaster:#raycaster-f"
        wall-collider__b="raycaster:#raycaster-b"
        wall-collider__r="raycaster:#raycaster-r"
        wall-collider__l="raycaster:#raycaster-l"
        stand-on="raycaster:#stand-on-ray"
      >
        <a-entity id="stand-on-ray" raycaster="objects: .ground; direction:0 -1 0"></a-entity>

        <!-- 四方のレイキャスターを定義 -->
        <a-entity id="raycaster-f" raycaster="objects: .collidable; far: 1; direction:0 0 -1"></a-entity>
        <a-entity id="raycaster-b" raycaster="objects: .collidable; far: 1; direction:0 0 1"></a-entity>
        <a-entity id="raycaster-l" raycaster="objects: .collidable; far: 1; direction:1 0 0"></a-entity>
        <a-entity id="raycaster-r" raycaster="objects: .collidable; far: 1; direction:-1 0 0"></a-entity>
      </a-camera>

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

      <a-sky color="skyblue"></a-sky>
      <a-box color="red" class="collidable" position="0 1 -4" scale="2 2 2"></a-box>
    </a-scene>
  </body>
</html>