みかづきブログ・カスタム

基本的にはちょちょいのほいです。

Three.jsでOculus Quest向けのWebVRコンテンツを作成し、Oculus Touchコントローラーの入力をスマートに取得する(トリガー、グリップ、スティック、A・B・X・Yボタン) 🎮

f:id:kimizuka:20220226145013g:plain

blog.kimizuka.org

かつて、

controllerModel.motionController.data

にアクセスすることで強引に取得していた、トリガー、A、B、X、Yボタンの状態ですが、VRボタンを、Three.jsのVRButton.jsからImmersive Webのwebxr-button.jsへ切り替えたところ、スマートに取得できるようになったので、その方法をまとめます。

Immersive Webのwebxr-button.jsの設置方法は、こちらの記事を参考にしてみてください。

blog.kimizuka.org

一言で言えば、renderer.xr.setSessionにsessionを渡すだけです。


コントローラーの状態の取得

ざっくりした手順としては、

❶ sessionのinputsourceschangeイベントにコールバック関数を設定

function onSessionStarted(session) {
  renderer.xr.setSession(session);
  session.addEventListener('inputsourceschange', handleInputSourceChange);
}

❷ コールバック関数内でgamepadを取得

function handleInputSourceChange({ added, session }) {
  session.requestAnimationFrame(tick);

  function tick() {
    const sources = added.map((source) => {
      const buttons = source.gamepad.buttons.map(({ pressed, touched, value }) => {
        return {
          pressed,
          touched,
          value
        };
      });

      return {
        gamepad: {
          axes: source.gamepad.axes,
          buttons
        },
        handedness: source.handedness
      };
    });

    console.log(sources); // => コントローラーの状態がログに表示される
    session.requestAnimationFrame(tick);
  }
}

という流れです。
実際はログに表示するだけでなく、コントローラーの状態をどうにかして外部に伝える必要があります。


ソースコード

WebXrButtonWithGamepadEvent.jsx

import styled from 'styled-components';
import { useEffect, useRef, useState } from 'react';
import { WebXRButton } from '~/scripts/immersive/webxr-button';

const Wrapper = styled.span`
  position: relative;

  &[data-renderer='false'] {
    .webvr-ui-title {
      display: none !important;
    }

    .webvr-ui-svg {
      display: none;
    }
  }

  &[data-renderer='true'] {
    .webvr-ui-svg-error {
      display: none;
    }
  }

  button {
    display: flex;
    flex-direction: row-reverse;
    align-items: center;
    justify-content: center;
    width: 176px; height: 56px;
    background: #FAFAFA;
    cursor: pointer;
  }

  .webvr-ui-title {
    margin-left: 8px;
  }
`;

export default function WebXrButtonWithGamepadEvent({
  renderer,
  onChangeControllerState = (evt) => {}
}) {
  const wrapperRef = useRef(null);
  const [ xrButton, setXrButton ] = useState(null);
  const [ supportedXr, setSupportedXr ] = useState(false);
  const [ controllerState, setControllerState ] = useState('{ "sources": [] }');

  useEffect(() => {
    if (!!renderer) {
      setXrButton(new WebXRButton({
        color: '#212121',
        injectCSS: false,
        onRequestSession: onRequestSession,
        onEndSession: onEndSession
      }));

      if (navigator.xr) {
        (async () => {
          const supported = await navigator.xr.isSessionSupported('immersive-vr');

          setSupportedXr(supported);
        })();
      }
    }
  }, [renderer]);

  useEffect(() => {
    if (!xrButton) {
      return;
    }

    xrButton.enabled = supportedXr;

    if (xrButton.enabled && wrapperRef.current) {
      wrapperRef.current.innerHTML = '';
      wrapperRef.current.appendChild(xrButton.domElement);
    }
  }, [xrButton, supportedXr, wrapperRef]);
  
  useEffect(() => {
    const { sources } = JSON.parse(controllerState);

    onChangeControllerState(sources);
  }, [controllerState]);

  function onRequestSession() {
    if (!renderer) {
      return;
    }

    const sessionInit = { optionalFeatures: [ 'local-floor', 'bounded-floor', 'hand-tracking' ] };

    navigator.xr.requestSession('immersive-vr', sessionInit).then(onSessionStarted);
  }

  function onSessionStarted(session) {
    renderer.xr.setSession(session);
    session.addEventListener('inputsourceschange', handleInputSourceChange);
  }

  function onEndSession(session) {
    session.end();
  }

  function handleInputSourceChange({ added, session }) {
    session.requestAnimationFrame(tick);

    function tick() {
      const sources = added.map((source) => {
        const buttons = source.gamepad.buttons.map(({ pressed, touched, value }) => {
          return {
            pressed,
            touched,
            value
          };
        });

        return {
          gamepad: {
            axes: source.gamepad.axes,
            buttons
          },
          handedness: source.handedness
        };
      });

      setControllerState(JSON.stringify({ sources }));
      session.requestAnimationFrame(tick);
    }
  }

  return (
    <Wrapper
      ref={ wrapperRef }
      data-renderer={ !!renderer }
      className="web-xr-button"
    ></Wrapper>
  );
}

親から授かったonChangeControllerStateをコントローラーの状態を引数にして実装します。
useStateを使っているので、実行されるのはコントローラーの状態に変更があった時のみです。

使い方

<WebXrButtonWithGamepadEvent
  renderer={ renderer }
  onChangeControllerState={ handleChangeControllerState }
/>

という感じで、Three.jsのrendererとコントローラーの状態に変更があった際のコールバックを渡して使う想定です。


DEMO

r136

kimizuka.org

f:id:kimizuka:20220226145013g:plain

VRモードに切り替えて、コントローラを操作すると、状態が更新されます。
右手のコントローラーの状態は右に、左手のコントローラーの状態は左に表示されます。
ほぼほぼ生のデータを使っているので、不要な情報も取得してしまっていますが、一旦これで満足してます。