VR TOURを動かしている大元は、外部読み込みプログラムのA-Frameですが、
細かな設定を行なうために独自のJavaScriptファイルのvr_tour.jsも読み込ませています。
ここでは全文を表示した後、独自改良が行なえるように詳細を説明します。
// VR modeの制御用
let vr_mode = 'none';
// 移動用サークル作成
AFRAME.registerComponent('warp_circle', {
schema: {
link: {type: 'asset', default: ''}
},
init: function () { // コンポーネントが作成されると実行される
const data = this.data;
const el = this.el;
el.setAttribute('class', 'clickable');
el.addEventListener('click', function () {
// 画面の回転角をqueryで追加して伝える。
const sky = document.querySelector('a-sky');
let sendRotationY = Math.round(sky.getAttribute('rotation').y);
if (vr_mode != 'on') {
const camera = document.querySelector('a-camera');
sendRotationY -= Math.round(camera.getAttribute('rotation').y);
}
sendRotationY = sendRotationY % 360;
if (sendRotationY<0) {sendRotationY += 360;}
location.href = data.link + '?vr=' + vr_mode + '&orient=' + String(sendRotationY);
});
// 形状の指定(円と大きさ、透明度)
el.setAttribute('geometry', 'primitive: circle; radius: 4.0;');
el.setAttribute('rotation', '-90 0 0');
el.setAttribute('material', {
side: 'double',
color: '#FFF',
depthTest: false,
transparent: true,
opacity: 0.5
});
// アニメーションの指定(マウスイン・アウトの色変化)
el.setAttribute('animation__in', {
property: 'components.material.material.color',
type: 'color',
dur: 100,
to: '#00F',
startEvents: 'mouseenter'
});
el.setAttribute('animation__out', {
property: 'components.material.material.color',
type: 'color',
dur: 100,
to: '#FFF',
startEvents: 'mouseleave'
});
}
});
// Oculusコントローラーの設定
AFRAME.registerComponent('vr-controller', {
init: function() {
const el = this.el;
el.triggerState = false; // レーザービームの発射
el.stickState = false; // スティックコントローラー制御用
el.stickX = 0.0;
el.setAttribute('raycaster', {
far: 2000,
lineColor: '#ff8',
showLine: false
});
el.addEventListener('triggerdown', function() {
el.triggerState = true;
});
el.addEventListener('triggerup', function() {
el.triggerState = false;
});
el.addEventListener('thumbstickmoved', function(event) {
el.stickX = event.detail.x;
});
},
tick: function() {
let el = this.el;
// レーザービームのON・OFF
el.setAttribute('raycaster', 'showLine', el.triggerState);
// スティックコントローラーの処理
if (el.stickX < -0.95) {
if (!(el.stickState)) {
let sky = document.querySelector('a-sky');
let skyRotationY = sky.getAttribute('rotation').y;
sky.setAttribute('rotation', {y: skyRotationY - 45});
el.stickState = true;
}
} else if (el.stickX > 0.95) {
if (!(el.stickState)) {
let sky = document.querySelector('a-sky');
let skyRotationY = sky.getAttribute('rotation').y;
sky.setAttribute('rotation', {y: skyRotationY + 45});
el.stickState = true;
}
} else {
el.stickState = false;
}
}
});
// ホームボタンに戻る処理
AFRAME.registerComponent('home_btn', {
init: function() {
const el = this.el;
el.setAttribute('class', 'clickable');
el.setAttribute('position', {x: -1.0, y: -3.5, z: -5.0});
el.setAttribute('geometry', {height: 0.75, width: 0.75});
el.addEventListener('click', function () {
location.href = '../index.html';
});
}
});
// インフォメーションボードの処理
let title_data = ''; //ボードのタイトル格納
let content_data = ''; //ボードの中身格納
function createInfo () { //ボード作成
const infoBtn = document.getElementById('info_btn');
const camera = document.querySelector('a-camera');
const infoBox = document.createElement('a-plane');
camera.appendChild(infoBox);
infoBox.setAttribute('id', 'infoBox');
infoBox.setAttribute('position', '0 0 -5.01');
infoBox.setAttribute('geometry', {height: 4.6, width: 4.6});
infoBox.setAttribute('color', '#242');
const infoWindow = document.createElement('a-plane');
camera.appendChild(infoWindow);
infoWindow.setAttribute('class', 'clickable');
infoWindow.setAttribute('id', 'infoWindow');
infoWindow.setAttribute('position', '0 -0.25 -5');
infoWindow.setAttribute('geometry', {height: 4.0, width: 4.5});
infoWindow.setAttribute('color', 'white');
const title = document.createElement('a-text');
infoBox.appendChild(title);
title.setAttribute('position', '0.0 2.05 0.0');
title.setAttribute('width', 4.2);
title.setAttribute('scale', '1.4 1.4 1');
title.setAttribute('align', 'center');
title.setAttribute('font', '../assets/NotoSansJP-Regular.json');
title.setAttribute('font-image', '../assets/NotoSansJP-Regular.png');
title.setAttribute('color', 'white');
title.setAttribute('value', title_data);
const content = document.createElement('a-text');
infoWindow.appendChild(content);
content.setAttribute('width', 4.2);
content.setAttribute('position', '-2.1 1.8 0.0');
content.setAttribute('scale', '1.0 1.0 1');
content.setAttribute('baseline', 'top');
content.setAttribute('font', '../assets/NotoSansJP-Regular.json');
content.setAttribute('font-image', '../assets/NotoSansJP-Regular.png');
content.setAttribute('color', 'black');
content.setAttribute('wrap-count', 32);
content.setAttribute('lineHeight', '80');
content.setAttribute('value', content_data);
infoWindow.addEventListener('click', removeInfo);
infoBtn.removeEventListener('click', createInfo);
infoBtn.addEventListener('click', removeInfo);
}
function removeInfo () { //ボード削除
const infoBtn = document.getElementById('info_btn');
const camera = document.querySelector('a-camera');
const infoWindow = document.getElementById('infoWindow');
const infoBox = document.getElementById('infoBox');
camera.removeChild(infoWindow);
camera.removeChild(infoBox);
infoBtn.removeEventListener('click', removeInfo);
infoBtn.addEventListener('click', createInfo);
}
// インフォメーションボードの初期設定
AFRAME.registerComponent('info_window', {
schema: {
title: {type: 'string', default: ''},
content: {type: 'string', default: ''}
},
init: function() {
const data = this.data;
title_data = data.title;
content_data = data.content;
const infoBtn = document.getElementById('info_btn');
infoBtn.setAttribute('position', {x: 0.0, y: -3.5, z: -5.0});
infoBtn.setAttribute('geometry', {height: 0.75, width: 0.75});
infoBtn.setAttribute('class', 'clickable');
infoBtn.addEventListener('click', createInfo);
}
});
// 音声案内の処理
function playVoice () { //音声スタート
const audioBtn = document.getElementById('audio_btn');
const voicePlay = document.querySelector('a-sound').components.sound;
voicePlay.playSound();
audioBtn.setAttribute('src', '#audio_off_img');
audioBtn.removeEventListener('click', playVoice);
audioBtn.addEventListener('click', stopVoice);
}
function stopVoice () { //音声ストップ
const audioBtn = document.getElementById('audio_btn');
const voicePlay = document.querySelector('a-sound').components.sound;
voicePlay.stopSound();
audioBtn.setAttribute('src', '#audio_on_img');
audioBtn.removeEventListener('click', stopVoice);
audioBtn.addEventListener('click', playVoice);
}
// 音声案内の初期設定
AFRAME.registerComponent('voice', {
init: function() {
const audioBtn = document.getElementById('audio_btn');
audioBtn.setAttribute('position', {x: 1.0, y: -3.5, z: -5.0});
audioBtn.setAttribute('geometry', {height: 0.75, width: 0.75});
audioBtn.setAttribute('class', 'clickable');
audioBtn.addEventListener('click', playVoice);
}
});
// a-scene全体の初期化処理
function initScene () {
// quetyを元に表示させる方向を設定する。
const recieveRotationY = parseFloat(AFRAME.utils.getUrlParameter('orient'));
if (recieveRotationY>0) {
const sky = document.querySelector('a-sky');
sky.setAttribute('rotation', {y: recieveRotationY});
}
const sceneEl = document.querySelector('a-scene');
if (AFRAME.utils.getUrlParameter('vr')=='on') {
vr_mode = 'on';
sceneEl.enterVR();
} else {
//sceneEl.exitVR();
if (AFRAME.utils.device.checkHeadsetConnected()) {
vr_mode = 'off';
}
if (AFRAME.utils.device.isMobile()) {
vr_mode = 'none';
}
}
// Oculus Quest 2の場合は、VRボタンの有効化
const vrBtn = document.getElementById('vr_btn');
vrBtn.setAttribute('class', 'clickable');
vrBtn.setAttribute('position', {x: 2.0, y: -3.5, z: -5.0});
vrBtn.setAttribute('geometry', {height: 0.75, width: 0.75});
if (vr_mode == 'off') {
vrBtn.addEventListener('click', function () {
vrBtn.setAttribute('visible', 'false');
vr_mode = 'on';
document.querySelector('a-scene').enterVR();
});
} else {
vrBtn.setAttribute('visible', 'false');
}
};
// a-sceceの読み込み・初期化がすべて終わってからinitSceneを実行
document.addEventListener('DOMContentLoaded', function () {
const sceneEl = document.querySelector('a-scene');
sceneEl.addEventListener('renderstart', initScene);
});
2行目で定義している変数 vr_mode ですが、値としてはnone, off, onの3通りをとります。 PCやスマホからのアクセスではnoneの値をとり、Oculus Quest 2などのヘッドセットの場合 offかonになります。Oculus BrowserでPCと同様にブラウザ内でVR TOURを行なっているときは offの値で、360度の没入状態ではonの値にします。場所を移動したときの別ページに移った際に この値を元に強制的に没入状態を指定しています。コードの作成過程で加えた変数なので、 A-Frameのバージョン1.4.1では不要になっている可能性も高いです。
VRゴーグルの中にスマホをセットした人に没入感を味わえるようにスマホ画面を右目用と左目用に 2分割した画面を表示させることも可能なはずですが、大多数の人は、スマホ単体でアクセス するだろうと、スマホではPCと同じnoneモードでの運用としています。
4行目から52行目が、移動用サークルの初期設定です。
AFRAME.registerComponent('warp_circle', {
registerComponentはHTMLファイルで指定したClassが作成されると呼び出される関数です。 schema:の部分でdefault値の設定、init:の部分にインスタンスが作成されたときのコードを 書いています。HTMLファイルの中で設定しても構わないのですが、共通設定はここで書いた方が コードがスッキリするだろうと初期値はこちらに書いてあります。ここでは、'warp_circle' が作成されたときに移動用サークルの大きさ、形、リンクなどを設定していきます。
13行目の
el.setAttribute('class', 'clickable');
でサークルをクリック可能とし、14行目のel.addEventListener('click', function () {
以降でクリックしたときの処理を記載しています。let sendRotationY = Math.round(sky.getAttribute('rotation').y);
変数 sendRotationY は、A-Frameから表示している画面の向きを取得します。少し厄介なのは、 このVR TOURではカメラの向きを変更して表示する向きを指定するだけでなく、VR空間そのものも 回転させています。PCやスマホでアクセスした場合は、cameraの向きだけを考えればよいのですが Oculusではヘッドセットの向きを変えてcameraを動かしても、コントローラーの向きは変らないので VR空間そのものもa-skyで回転させています。それらを複合して移動先に送る回転角の情報を決めていきます。
location.href = data.link + '?vr=' + vr_mode + '&orient=' + String(sendRotationY);
移動先のURLはdata.linkでHTMLファイルから取得し、そこに見ている向きの情報などをqueryにのせて 次のページに送っています。
el.setAttribute('geometry', 'primitive: circle; radius: 4.0;');
ここからはサークルの形状などの設定です。radiusの後の数値は半径なので、ここでサークルの大きさを変えられます。 transparentは透明化のON(True)とOFF(False)、opacityは透明レベルを設定しています。
el.setAttribute('animation__in', {
これ以降ではサークル内にマウスが入ったときと出て行ったときのアニメーション処理をしています。 基本的にマウスがサークル内に入ったら青色に、出て行ったら白色に戻す設定です。
54行目から101行目は、Oculus Quest 2用のコントローラーの設定です。HTML内に vr-controllerを右手用と左手用のそれぞれで記載しているので、どちらのコントローラーでも 動くように作ってあります。
el.setAttribute('raycaster', {far: 2000,
lineColor: '#ff8',showLine: false});
init: 内のこの部分でコントローラーから発信するレーザービームの設定をしています。lineColor の後ろを修正するとレーザービームの色を変えることができます。
変数 el.triggerState はトリガーの状態を格納しており、トリガーイベントに紐づけています。
tick: 以下の部分は、フレーム毎に実行されるコードであり、 el.triggerState によって レーザービームの表示・非表示を切り替えています。
変数 el.stickX にはスティックコントローラーの左右方向の情報を格納しており、スティックが 倒されているとVR空間そのもの(a-sky)を45度左右にそれぞれ回転させます。ただ、左右回転の 切り替えを倒した回数だけ実施させるため、el.stickState にフラグを立てて、一度、スティックを 戻さないと次の回転をしないようにコードを書いてあります。
103行目から114行目は、ホームボタンの処理です。'home_btn'のキーワードで作成した ボタンに対して、その位置やサイズを指定して、最後の
location.href = '../index.html';
この部分で、ボタンをクリックした際のリンク先のURLを指定しています。
116行目から195行目では、インフォメーションボードの設定を行なっています。
インフォメーションボードでは、ボードを作成する関数
function createInfo ()
と、ボードを削除する関数function removeInfo ()
を宣言しています。変数 title_data にはボードのタイトルを、変数 content_data には本文をHTMLファイルから読み込んでいおいて
AFRAME.registerComponent('info_window', {
の部分からの
記述でインフォーメーションボタンの初期設定を行ないます。ボタンの位置とサイズを指定した後、クリックすると
createInfo関数を呼び出すようにしています。ボードそのものを透明にして見えなくする方法も
考えたのですが、透明にしてもクリックイベントは有効で、ボードを表示させる度に a-plane (A-Frameの要素) を
作成し、ボードを消すには、a-plane そのものを削除しています。ボードを作成するときは、a-cameraの要素の子要素としてa-planeを追加しています。cameraが親要素 とすることで、常に視点の中央にインフォーメーションボードが来るようになります。
197行目から225行目は音声案内の設定です。
スマホ対応させるには、ユーザーからのアクションが無い限り音を出すことは許可されません。 そのため音声案内を開始するボタンを設置しました。(自動で音声案内を始めることができなかった。) ここでも、音声を開始する関数
function playVoice ()
と
音声を止める関数function stopVoice ()
を宣言しています。
これらの関数の中で、音声案内の制御だけでなく、ボタンの画像の変更も次の箇所などで実施しています。audioBtn.setAttribute('src', '#audio_off_img');
227行目から271行目は、ページの初期設定です。
268行目の
document.addEventListener('DOMContentLoaded', function ()
でHTMLのページ読み込み終了を待ちます。さらに、270行目のsceneEl.addEventListener('renderstart', initScene);
でページ表示のリフレッシュが始まるのを待ちます。すべての準備が整ったところで、228行目からの初期化スクリプトを実行します。230行目では
const recieveRotationY = parseFloat(AFRAME.utils.getUrlParameter('orient'));
queryで指定された前のページでの回転角度の情報を recieveRotationY に格納します。
Oculus Quest 2 などのVRヘッドセットを使用しているか、どうかを、
if (AFRAME.utils.getUrlParameter('vr')=='on') {
で評価しており、さらにスマホかどうかを次で評価しています。
if (AFRAME.utils.device.isMobile()) {
250行目から263行目において、Oculus Quest 2の場合の、360度表示への没入開始ボタンも作成しています。