AFrameで2D・3Dの画像とビデオを表示する

私たちのARアプリケーションの1つでは、[AFrame](https://aframe.io/)にARマーカー付きの3Dモデルに加えて、さまざまな2Dおよび3Dアセットを表示して、アプリケーションの機能を拡張したいと考えていました。 最初は、作業に時間がかかるように見えましたが...

どうなったでしょう？

[AFrame](https://aframe.io/)で下記の5つのアセットの種類が表示できるようになりました：

 - 3Dのモデル
 - 3Dで2Dの画像（静止画のみ）
 - 3Dで2Dの動画
 - 2Dの画像（静止画とアニメーション画像）
 - 2Dの動画

## 適切なファイル形式
以下は、ブラウザのサポートに応じて、各アセットタイプに使用できるファイル形式の非網羅的なリストです：
 - 3Dのモデル：GLB、GLTF、OBJ、COLLADA (DAE)、PLY、JSON、FBX、three.js等
 - 3Dで2Dの画像：AVIF 、GIF、JPG、PNG、SVG、WebP等
 - 3Dで2Dの動画：_3GP_、_MOV_、MP4、MPEG、MPG、_OGG_、_OGV_、WebM等
 - 2Dの画像：APNG、AVIF、GIF、JPG、PNG、SVG、WebP等
 - 2Dの動画：_3GP_、_MOV_、MP4、MPEG、MPG、_OGG_、_OGV_、WebM等

## Laravelのブレード
Laravelコントローラーからのブレードの引数として渡されるアセットのデータは下記の通りです：

```JSON:$assets
[
  ...
  {
    "id": ◯◯◯◯,
    "key": "asset01",
    "marker_id": □□□□,
    "model_animation_mixer": "loop: repeat;"
    "model_path": "assets\/model-◯◯◯◯.glb",
    "rotation_x": 270,
    "rotation_y": 0,
    "rotation_z": 0,
    "scale": 7,
    "translation_x": 0,
    "translation_y": -0.8,
    "translation_z": 0,
    "type": "3D model",
  },
  {
    "id": ◯◯◯◯,
    "height": 540,
    "key": "asset02",
    "marker_id": □□□□,
    "rotation_x": 270,
    "rotation_y": 0,
    "rotation_z": 0,
    "scale": 0.003,
    "translation_x": 0,
    "translation_y": 0,
    "translation_z": 0,
    "type": "3D video",
    "video_path": "assets\/video-◯◯◯◯.mp4",
    "width": 960,
  },
  {
    "id": ◯◯◯◯,
    "height": 370,
    "image_path": "assets\/image-◯◯◯◯.png",
    "key": "asset03",
    "marker_id": □□□□,
    "scale": 2,
    "translation_x": 0.5,
    "translation_y": 0.5,
    "type": "2D image",
    "width": 278,
  },
  ...
]
```
### 3Dコンテンツ
3Dコンテンツは下記の3つのアセットの種類です：

 - 3Dのモデル
 - 3Dで2Dの画像（静止画のみ）
 - 3Dで2Dの動画

[AFrame](https://aframe.io/)の[**Scene**コンポネント](https://aframe.io/docs/1.3.0/core/scene.html)で直接表示できます：

```PHP:AR.blade.php
    <a-scene
        vr-mode-ui="enabled: false;"
        renderer="logarithmicDepthBuffer: true;"
        arjs="trackingMethod: best; sourceType: webcam; debugUIEnabled: false;"
        device-orientation-permission-ui="enabled: false;"
        embedded
    >
        @foreach ($assets as $assetData)
```
アセットごとに、最初に[AFrame](https://aframe.io/)の[アセット管理システム](https://aframe.io/docs/1.3.0/core/asset-management-system.html)を使用してファイルからデータをキャッシュします：

```PHP:AR.blade.php
            <a-assets>
                @if ($assetData['type']=='3D model')
                    <a-asset-item id="model-{{ $assetData['id'] }}" src="{{ $assetData['model_path'] }}" response-type="arraybuffer"></a-asset-item>
                @elseif ($assetData['type']=='3D image')
                    <img id="image-{{ $assetData['id'] }}" src="{{ $assetData['image_path'] }}">
                @elseif ($assetData['type']=='3D video')
                    <video id="video-{{ $assetData['id'] }}" autoplay loop="true" src="{{ $assetData['video_path'] }}">
                @endif
            </a-assets>
```
使用してる拡張現実（AR）技術は[マーカーベース](https://aframe.io/blog/arjs/)であり、各アセットは、読み取ったときにアセットを表示するマーカーにリンクされています。

Laravelコントローラーからのブレードの引数として渡されるマーカーのデータは下記の通りです：

```JSON:$markers
{
  "◯◯◯◯": {
    "id": ◯◯◯◯,
    "image_path": "default\/marker\/image-□□.png",
    "pattern_path": "default\/markers\/pattern-□□.patt",
    ...
  },
  ...
}
```
[aframe-ar](https://jeromeetienne.github.io/AR.js/aframe/)の[マーカーコンポネント](https://jeromeetienne.github.io/AR.js/aframe/#a-marker)を使用してマーカーを定義します（3Dおよび2Dのすべてのアセットの種類のためにマーカーを定義しますが、マーカーに子として追加する必要があるのは3Dアセットのみです。）：

```PHP:AR.blade.php

            <a-marker
                id="asset-{{ $assetData['id'] }}"
                data-stampid="{{ $assetData['id'] }}"
                type="pattern"
                preset="custom"
                url="{{ $markers[$assetData['marker_id']]->pattern_path }}"
                registerevents
            >
```
次に、アセットを子として[`<a-marker>`](https://jeromeetienne.github.io/AR.js/aframe/#a-marker)タグに追加します：

- [`<a-entity>`](https://aframe.io/docs/1.3.0/core/entity.html)は3Dモデルを表示するために使用されます：

```PHP:AR.blade.php

                @if ($assetData['type']=='3D model')
                    <a-entity
                        gltf-model="#model-{{ $assetData['id'] }}"
                        scale="{{ $assetData['scale'] }} {{ $assetData['scale'] }} {{ $assetData['scale'] }}"
                        rotation="{{ $assetData['rotation_x'] }} {{ $assetData['rotation_y'] }} {{ $assetData['rotation_z'] }}"
                        animation-mixer="{{ $assetData['model_animation_mixer'] }}"
                        position="{{ $assetData['translation_x'] }} {{ $assetData['translation_y'] }} {{ $assetData['translation_z'] + 0.65 }}"
                    ></a-entity>
```
- [`<a-image>`](https://aframe.io/docs/1.3.0/primitives/a-image.html)は2D画像を3Dで表示するために使用されます：

```PHP:AR.blade.php

                @elseif ($assetData['type']=='3D image')
                    <a-image
                        src="#image-{{ $assetData['id'] }}"
                        height="{{$assetData['height']}}"
                        width="{{$assetData['width']}}"
                        position="{{ $assetData['translation_x'] }} {{ $assetData['translation_y'] }} {{ $assetData['translation_z'] + 0.65 }}"
                        rotation="{{ $assetData['rotation_x'] }} {{ $assetData['rotation_y'] }} {{ $assetData['rotation_z'] }}"
                        scale="{{ $assetData['scale'] }} {{ $assetData['scale'] }} {{ $assetData['scale'] }}"
                    ></a-image>
```
- [`<a-video>`](https://aframe.io/docs/1.3.0/primitives/a-video.html)は2D動画を3Dで表示するために使用されます：

```PHP:AR.blade.php

                @elseif ($assetData['type']=='3D video')
                    <a-video
                        src="#video-{{ $assetData['id'] }}"
                        height="{{$assetData['height'] * $assetData['scale']}}"
                        width="{{$assetData['width'] * $assetData['scale']}}"
                        position="{{ $assetData['translation_x'] }} {{ $assetData['translation_y'] }} {{ $assetData['translation_z'] + 0.65 }}"
                        rotation="{{ $assetData['rotation_x'] }} {{ $assetData['rotation_y'] }} {{ $assetData['rotation_z'] }}"
                    ></a-video>
                @endif
            </a-marker>
        @endforeach
```
**※ 注意：** マーカーに子として2Dアセットのエンティティを定義しません！

最後に、カメラをセットアップします：

```PHP:AR.blade.php

        <a-entity camera></a-entity>
    </a-scene>
```
### 2Dコンテンツ
2Dコンテンツは下記の2つのアセットの種類です：

 - 2Dの画像（静止画とアニメーション画像）
 - 2Dの動画

2Dアセットを2Dで表示するためには、`<img>`と`<video>`のHTMLタグを使用します。デフォルトでアッセトを見えないようにします：

```PHP:AR.blade.php

    <div class="2D-ar">
        @foreach ($stamps as $assetData)
            <div class="2D-layer">
                @if ($assetData['type']=='2D image')
                    <img
                        class="2D-layer-asset invisible"
                        id="asset-{{ $assetData['id'] }}-2D"
                        src="{{ $assetData['image_path'] }}"
                        style="left: {{ $assetData['translation_x'] * 100 }}%; top: {{ $assetData['translation_y'] * 100 }}%; "
                        height="{{ $assetData['height'] * $assetData['scale'] }}"
                        width="{{ $assetData['width'] * $assetData['scale'] }}"
                    />
               @elseif ($assetData['type']=='2D video')
                    <video
                        class="2D-layer-asset invisible"
                        id="asset-{{ $assetData['id'] }}-2D"
                        src="{{ $assetData['video_path'] }}"
                        style="left: {{ $assetData['translation_x'] * 100 }}%; top: {{ $assetData['translation_y'] * 100 }}%; "
                        height="{{ $assetData['height'] * $assetData['scale'] }}"
                        width="{{ $assetData['width'] * $assetData['scale'] }}"
                        autoplay
                        loop="true"
                    />
                @endif
            </div>
        @endforeach
    </div>
```
### 2DコンテンツのCSS

```SCSS:AR.scss
  .2D-ar {
  }
  .2D-ar .2D-layer {
    position: absolute;
    top: 0;
    left: 0;
    width: 100%;
    min-width: 100%;
    height: 100%;
    min-height: 100%;
    visibility: show;
  }
  .2D-ar .2D-layer .2D-layer-asset {
    position: relative;
    top: 50%;
    left: 50%;
    max-width: 320px;
    max-height: auto;
    visibility: show;
    transition: 1s;
    transform: translate(-50%, -50%);
    @media (min-width: 429px) {
      max-width: 450px;
    }
  }
  .2D-ar .2D-layer .2D-layer-asset.invisible {
    visibility: hidden;
    opacity: 0;
  }
```

## JavaScript
3DアセットはHTMLタグを使用して簡単に表示できますが、ARマーカーがカメラで検出された場合は、2Dアセットを手動で表示する必要があります。[AFrame](https://aframe.io/)の[マーカー関連イベント](https://aframe.io/docs/1.3.0/introduction/javascript-events-dom-apis.html)（`markerfound`と`markerlost`）をキャッチするために、JavaScriptで[AFrameのコンポーネント](https://aframe.io/docs/1.3.0/core/component.html)を登録し、初期化の時にはイベントのリスナーを追加します。

※ マーカーの前に読み込まないと動きません

```JavaScript:AR.js
const sleep = function (msec) {
  return new Promise(function(resolve) {
    setTimeout(function() { resolve() }, msec);
  });
}

AFRAME.registerComponent('registerevents', {
    init: function () {
        var marker = this.el;
        const arStatus = window.arStatus;
        let markerStatus = arStatus.markerLost;
```
マーカーを検出したイベントのリスナーを追加します：

```JavaScript:AR.js
        marker.addEventListener('markerFound', async function () {
            markerStatus = arStatus.markerFound;
```
誤認識を防ぐため、`sleep`ユーティリティ関数を使用して一定時間処理を待ちます：

```JavaScript:AR.js
            await sleep(arStatus.msecUntilArRecognition);
```
`sleep`完了するまでにマーカーを見失った場合は、処理を止めます：

```JavaScript:AR.js
            if (markerStatus === arStatus.markerLost) {
                return;
            }
```
マーカーを見失ってない場合は2Dアセットを表示します：

```JavaScript:AR.js
            show2DAsset(marker.id)
        });
```
マーカーを見失ったイベントのリスナーを追加します。マーカーを見失ったときには2Dアセットを非表示にします：

```JavaScript:AR.js
        marker.addEventListener('markerLost', async function () {
            markerStatus = arStatus.markerLost;
            hide2DAsset(marker.id)
        });
    }
});
```
2Dアセットを非表示にする関数はアセットのHTML要素を取得して`invisible`のクラスを追加することで要素を非表示にします：

```JavaScript:AR.js
function hide2DAsset(assetId) {
    const asset = document.getElementById(`${assetId}-2D`);
    if(asset) {
        asset.classList.add('invisible');
        
    }
}
```
2Dアセットを表示する関数はアセットのHTML要素を取得して`invisible`のクラスを削除することで要素を表示します：

```JavaScript:AR.js
function show2DAsset(assetId) {
    const asset = document.getElementById(`${assetId}-2D`);
    if(asset) {
        asset.classList.remove('invisible');
```
2D画像がループしないアニメーション画像の場合、`img`タグの`src`プロパティをリセットしてアニメーションを再起動する必要があります：

```JavaScript:AR.js
        if (asset.tagName === 'IMG') {
            asset.src = asset.getAttribute('src');
        }
    }
}
```
※ アニメーション画像を3D画像として使用することはできません。ビデオを使用する必要がありますのでご注意ください

## 結論

[AFrame](https://aframe.io/)を使用すると、ARアプリケーションで3Dモデル以外のさまざまなアセットタイプ（3Dおよび2D）を使用することが、当初の予想よりも簡単でした。