🌕

【8th wall】World Tracking Portalのサンプルを理解してみる

2023/03/19に公開

https://www.8thwall.com/8thwall/portal-aframe
このサンプルコードの解説です.

現実世界に,異世界への扉「ポータル」に入ると異世界へ行ける

こちらをタップして体験↓
https://8thwall.8thwall.app/portal-aframe/

裏側の仕組みの全体観

現実にいる時,実は外側には異世界のskyboxや異世界に置いてあるオブジェクトがありますが,自分の周りに壁があるので異世界は見えません.
現実世界にいるとき
ポータルの外(現実に見える)にいるとき
カメラの位置がポータルを潜ると,自分を囲っていた壁が消え,異世界が見えます.それと同時に,ポータルの円の部分にだけ,視界を隠す壁ができ,現実世界が見えます.
異世界にいるとき
ポータルの中にいるとき

壁に色をつけてみるとわかりやすいです

コード読む

body.html

まず全体像から

portal用の部分だけ取り出すと↓
<!-- Hider walls -->: 重なっている片方の世界を隠すための壁
<!-- Portal Contents -->: Portalの中の世界
<!-- Portal -->: Portal(入り口の輪っかの部分と,入り口のモヤモヤアニメーションと影)

<!-- Hider walls -->

      <a-box scale="100 1 100" position="0 -1 49" xrextras-hider-material></a-box>
      <a-box scale="100 100 1" position="0 50 75" xrextras-hider-material></a-box>
      <a-box scale="100 1 100" position="0 100 49" xrextras-hider-material></a-box>
      <a-box scale="1 100 100" position="-30 50 50" xrextras-hider-material></a-box>
      <a-box scale="1 100 100" position="30 50 50" xrextras-hider-material></a-box>
      <a-ring id="portalHiderRing" radius-inner="0.001" radius-outer="100" position="0 7.5 -0.2" xrextras-hider-material></a-ring>

自分の周りを囲む板を5枚と,ドーナツ状の平面でプレイヤーを取り囲む壁を作って,Portalの中の景色を隠している

<!-- Portal Contents -->

異世界の中身です.中の風景を書き換えたい時はここを書き換えればOK.

  <!-- Portal Contents -->
  <a-entity id="portal-contents">
    <a-entity
      gltf-model="#moon-model"
      ...>
    </a-entity>

    <a-entity
      gltf-model="#platform-model"
      ...>
    </a-entity>
    
    <a-plane
      material="src: #satellite-img; transparent: true; roughness: 0.8; metalness: 0"
      ...>
     </a-plane>
    
     <a-entity
      gltf-model="#flag-model"
      ...>
     </a-entity>
    
     <a-entity
      gltf-model="#rocks-model"
      ...>
     </a-entity>
     <a-sky src="#skybox-img" rotation="0 7 0" transparent="true"></a-sky>
  </a-entity>

jsでは,"portal-contents"idで参照していじっているので,中身を書き換える分には挙動に影響ありません.

<!-- Portal -->

ポータル自体のモデルとか装飾

  <!-- Portal -->
   <a-entity
    id="portalRim"
    gltf-model="#portal-rim-model"
    ...>
  </a-entity>

  <a-entity
    id="portalVideo"
    auto-play-video="video: #portal-video"
    ...>
  </a-entity>

  <a-circle
    id="portalShadow"
    radius="0.5"
    ...>
  </a-circle>

このauto-play-videoて何??調べても出て来ないんだけど!と思った結果

ここにありました

app.js
AFRAME.registerComponent('auto-play-video', {
  schema: {
    video: {type: 'string'},
  },
  init() {
    const v = document.querySelector(this.data.video)
    v.play()
  },
})

やってることとしては,
<a-entity>でplaneに,video要素をmaterialとして貼ることで表示
auto-play-videoでソースのvideo要素自体を再生する
という仕組み.

portal-contents.js

  • portalCameraComponent
    カメラの移動に応じて,異世界を隠す/隠さないを制御
  • tapToPlacePortalComponent
    画面をタップしたらポータルを表示
  • promptFlowComponent
    "Tap to Place Moon Portal"を出したり消したり(説明しない)
  • spinComponent
    ポータルの輪っかを回す(説明しない)

portalCameraComponent

portal-components.js
const portalCameraComponent = {
  schema: {
    width: {default: 10},
    height: {default: 10},
  },
  init() {
    this.camera = this.el
    this.contents = document.getElementById('portal-contents')
    this.walls = document.getElementById('hider-walls')
    this.portalWall = document.getElementById('portal-wall')
    this.portalVideo = document.getElementById('portalVideo')
    this.isInPortalSpace = false
    this.wasOutside = true
  },

  // UnityでいうUpdate。描画のたびに呼ばれる
  tick() {
    const {position} = this.camera.object3D
    const isOutside = position.z > 0
    // ポータルに入ったかどうかを判別
    const withinPortalBounds =
      position.y < this.data.height && Math.abs(position.x) < this.data.width / 2
    if (this.wasOutside !== isOutside && withinPortalBounds) {
      this.isInPortalSpace = !isOutside
    }
    // isOutsideに応じて各オブジェクトの挙動を変更
    this.contents.object3D.visible = this.isInPortalSpace || isOutside
    this.walls.object3D.visible = !this.isInPortalSpace && isOutside
    this.portalWall.object3D.visible = this.isInPortalSpace && !isOutside
    this.portalVideo.object3D.visible = isOutside
    this.wasOutside = isOutside
  },
}

挙動は上の図に示したとおりです.照らし合わせながら見るとわかりますので再掲します.

現実世界にいるとき
ポータルの外(現実に見える)にいるとき
異世界にいるとき
ポータルの中にいるとき

tapToPlacePortalComponent

主な挙動は

  • handleClickEvent
    センターボタンがクリックされたとき
  • firstPlaceEvent
    初めて画面をクリックしたとき,ポータルを出す
    の二つ

handleClickEvent

センターボタンがクリックされたとき,シーンにrecenterイベントをエミット(呼ばれる側がどこにあるか結局よく分からず)
一回のタップでイベントが重複して発火されないようにsetTimeoutで管理

 const handleClickEvent = (e) => {
      if (!e.touches || e.touches.length < 2) {
        recenterBtn.classList.add('pulse-once')
        sceneEl.emit('recenter')
        setTimeout(() => {
          recenterBtn.classList.remove('pulse-once')
        }, 200)
      }
    }

firstPlaceEvent

シーンを1回目にタップされた時にPortalを開く
portalHiderRingのradius-innerが大きくなることによって,Hiderは薄い輪っかのようになり,空間に穴が開く

portal-components.js
    const firstPlaceEvent = (e) => {
      // recenterなにが呼ばれている??
      sceneEl.emit('recenter')
      // "Tap to Place Portal"の文字を消す
      sceneEl.emit('dismissPrompt')
      // hider ringの内側の半径を広げることによって,異世界の目隠しをしていた前方の面に穴が開く
      portalHiderRing.setAttribute('animation__1', {
        ...
      })
	
      // ポータルの縁を出現させる 
      portalRim.setAttribute('animation__2', {
        ...
      })

      // ポータルの縁のモヤモヤアニメーションを出現させる 
      portalVideo.setAttribute('animation__3', {
        ...
      })

      // ポータルのしたの影を出現させる 
      portalShadow.setAttribute('animation__4', {
        ...
      })
      // 画面をクリックしたらfirstPlaceEventを呼ぶためのイベントリスナーを解除
      sceneEl.removeEventListener('click', firstPlaceEvent)
      // recenterボタンにイベントリスナーを付ける
      recenterBtn.addEventListener('click', handleClickEvent, true)
    }

バグ?みっけ

今のサンプルだと,portal-cameraのtick()内で,

portal-components.js
    this.contents.object3D.visible = this.isInPortalSpace || isOutside

となっていることによって,ポータルが開いていなくても実はポータル内の世界に入れてしまう

portalCameraComponentを↓のように修正して,

portal-components.js
const portalCameraComponent = {
  schema: {
    width: {default: 10},
    height: {default: 10},
    active: {default: false},
  },
  init() {
    this.camera = this.el
    this.contents = document.getElementById('portal-contents')
    this.walls = document.getElementById('hider-walls')
    this.portalWall = document.getElementById('portal-wall')
    this.portalVideo = document.getElementById('portalVideo')
    this.isInPortalSpace = false
    this.wasOutside = true
  },

  tick() {
    if (!this.data.active) return
    const {position} = this.camera.object3D
    const isOutside = position.z > 0
    const withinPortalBounds =
      position.y < this.data.height && Math.abs(position.x) < this.data.width / 2
    if (this.wasOutside !== isOutside && withinPortalBounds) {
      this.isInPortalSpace = !isOutside
    }
    this.contents.object3D.visible = this.isInPortalSpace || isOutside
    this.walls.object3D.visible = !this.isInPortalSpace && isOutside
    this.portalWall.object3D.visible = this.isInPortalSpace && !isOutside
    this.portalVideo.object3D.visible = isOutside
    this.wasOutside = isOutside
  },
}

tapToPlacePortalComponentを

portal-components.js
const tapToPlacePortalComponent = {
  init() {
    const {sceneEl} = this.el
    const recenterBtn = document.getElementById('recenterButton')

    this.camera = document.getElementById('camera')
    this.contents = document.getElementById('portal-contents')
    this.contents.object3D.visible = false
    
    ...

    const handleClickEvent = (e) => {...}

    const firstPlaceEvent = (e) => {
      sceneEl.emit('recenter')
      sceneEl.emit('dismissPrompt')
      this.camera.setAttribute('portal-camera', 'active:true;')
      ....
      sceneEl.removeEventListener('click', firstPlaceEvent)
      recenterBtn.addEventListener('click', handleClickEvent, true)
    }

    sceneEl.addEventListener('click', firstPlaceEvent)
  },
}

とすると直る

before
https://8th.io/t/3hay4pjd
(projectページ: https://www.8thwall.com/8thwall/portal-aframe)

after
https://vpsworldtourjapan.8thwall.app/portal-fix

まとめ

https://www.8thwall.com/8thwall/portal-aframe
の中身と全体観をまとめました.読めばわかりますが.

360°画像の表現方法としても面白いなと思いました!

Discussion