OculusブラウザでVR


OculusブラウザでWebVRなどを実装する方法の説明です (2019/7/8 新規作成)。

Oculus / WebVR / コントローラー / トラッキング / 最適化 / ブラウザ機能 / API / 戻る / トップページ


Oculusブラウザ

OculusブラウザはChromiumをベースに、VR周りの対応を進めているブラウザです。 実機で chrome://version を開くことでバージョンを確認できます。 コンテンツのフルスクリーン表示時に自動で現れるVR化ボタンを押したときや、 VR機能を実装したページでVRを楽しむことが出来ます。
 
元となったChromiumのバージョンはやや古めで、 Oculusブラウザのバージョン情報 Release Notes からも知る事ができます。 2019/7時点で最新のOculus Browser 6.0はChromium 74ベースになります。
 
Oculusブラウザ上での開発についての公式のドキュメントは Introduction to Oculus Browser 以下にあります。

WebVR

ライブラリを使う方が多いとは思いますが、ここでは素のAPIを使っていきます。
 
OculusブラウザでVRを実現するには WebVR 1.1 APIを使います。 navigator.getVRDisplays()でVRDisplayオブジェクトを取得して以後の処理に備えます。
	var vrDisplay;
	var frameData=new VRFrameData();
	
	function animate(timestamp)
	{
		vrDisplay.requestAnimationFrame(animate);
		if (vrDisplay.isPresenting)
		{
			vrDisplay.getFrameData(frameData);
			
			render();
		}
		vrDisplay.submitFrame();
	}
	navigator.getVRDisplays().then(function(displays) {
		for (var i=0;i<=displays.length-1;i++)
		{
			vrDisplay=displays[i];
			
			init();
			
			vrDisplay.requestAnimationFrame(animate);
			break;
		}
	});
	
	document.getElementById("vr").addEventListener("click",function(event) {
		vrDisplay.requestPresent([{ source: canvas }]);
	},false);
VRDisplayにrequestAnimationFrame()関数を呼んで、 VR用のアニメーションループを回るようにします。 VRDisplayにrequestPresent()関数を呼ぶとVRモードに入りますが、 ユーザーがボタンを押すなどの行動を取ったタイミングでしか この関数を呼べない点に注意が必要です。
 
そしてWebVR APIは描画周りにWebGL APIの利用が想定されているため、 これも使うことになります。 OculusブラウザはWebGL2、GLSL ES 3.0に対応しています。
	var canvas=document.getElementById("canvas");
	var gl=canvas.getContext("webgl2");
	
	function init()
	{
		canvas.width =Math.max(vrDisplay.getEyeParameters("left").renderWidth ,vrDisplay.getEyeParameters("right").renderWidth )*2;
		canvas.height=Math.max(vrDisplay.getEyeParameters("left").renderHeight,vrDisplay.getEyeParameters("right").renderHeight);
	}
	function render()
	{
		gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
		
		gl.viewport(0,0,canvas.width*0.5,canvas.height);
		// frameData.leftViewMatrix,frameData.leftProjectionMatrixで左目用を描画
		gl.viewport(canvas.width*0.5,0,canvas.width*0.5,canvas.height);
		// frameData.rightViewMatrix,frameData.rightProjectionMatrixで右目用を描画
		
		gl.flush();
	}

コントローラー

コントローラーの情報参照・操作は Gamepads APIを介してアクセス します。 Gamepads APIの情報はVRモードになっている間だけ得られます (通常時のコントローラーはマウス操作のエミュレーションに使われます)。
 
コントローラーをロストした時などに情報が失われるため、 (特定の機種前提でコードを書いたとしても) navigator.getGamepads()の値が動的に変化して 空だったり順番が変わったりする可能性があるため、 常にIDをチェックして処理する必要があります。
 
Gamepads APIの値
属性名 データ内容
Oculus Quest
Oculus Touchコントローラー (右手側)
Oculus Quest
Oculus Touchコントローラー (左手側)
Oculus Go
コントローラー
Gear VR
id "Oculus Touch (Right)" "Oculus Touch (Left)" "Oculus Go Controller" "Gear VR Touchpad"
※外部コントローラーの時は"Gear VR Controller"
hand "right" "left" "right" "right" ?
axes[0] 右アナログスティックの倒し量
(左が-1.0、右が1.0)
左アナログスティックの倒し量
(左が-1.0、右が1.0)
Touchサーフェス
(左スワイプが-1.0、右スワイプが1.0)
Touchサーフェス
(左スワイプが-1.0、右スワイプが1.0)
axes[1] 右アナログスティックの倒し量
(上が-1.0、下が1.0)
左アナログスティックの倒し量
(上が-1.0、下が1.0)
Touchサーフェス
(上スワイプが-1.0、下スワイプが1.0)
Touchサーフェス
(上スワイプが-1.0、下スワイプが1.0)
buttons[0] 右アナログスティック押し込み 左アナログスティック押し込み Touchサーフェスのタッチ タッチ
buttons[1] 右手側人差し指トリガー 左手側人差し指トリガー トリガーボタン
buttons[2] 右手側中指トリガー 左手側中指トリガー
buttons[3] Aボタン Xボタン
buttons[4] Bボタン Yボタン
buttons[5] Oculusボタン ? メニューボタン ?
pose.position 右手の位置 左手の位置 手の位置 手の位置
pose.orientation 右手の向き 左手の向き 手の向き 手の向き
pose.linearVelocity 右手の速度 左手の速度 手の速度 手の速度
pose.angularVelocity 右手の角速度 左手の角速度 手の角速度 手の角速度
pose.linearAcceleration 右手の加速度 左手の加速度 手の加速度 手の加速度
pose.angularAcceleration 右手の角加速度 左手の角加速度 手の角加速度 手の角加速度
hapticActuators[] (関数) 右手側振動制御 左手側振動制御
 
Oculus Touchコントローラーでは、 buttons[]は .pressed でボタンを押したかどうかが取れます。 .touched でボタンに指を触れているかどうかが取れますが、中指トリガー (buttons[2]) だけは取れません (トリガーを押して初めて true になる)。 トリガーボタン (buttons[1], buttons[2]) は .value で押し込み量が 0.0〜1.0 の範囲で取れます。
 
Goコントローラーでは、 buttons[]は .pressed でボタンを押したかどうかが取れます。 Touchサーフェス (buttons[0]) は .touched でサーフェスに指を触れているかどうかが取れます。 トリガーボタン (buttons[1]) は .value で押し込み量が 0.0〜1.0 の範囲で取れます。
 
メニューボタン (戻るボタン)、Oculusボタンはブラウザの機能(VRモード終了)が 割り当てられているため、実質的には使えません。

トラッキング

自分の頭や手の位置は複数のAPIの値を元に算出します。 VR関連の全ての情報はヘッドセットの初期位置を原点としているためです。 VRStageParametersが足元から頭までの高さ情報を持っているので、 各pose値をこれでずらすことで得られます。
 
頭の位置はVRFrameDataのpose値から算出します。
	var head=poseToMatrix(frameData.pose);
	// VRPoseを4×4行列に変換するposeToMatrix()はこちらを参照
	var sittingToStanding=vrDisplay.stageParameters.sittingToStandingTransform;
	// sittingToStanding×headが頭の位置を表すモデル行列
手の位置はGamepadのpose値から算出します。
	var gamepads=navigator.getGamepads();
	for (var i=0;i<=gamepads.length-1;i++)
	{
		if (gamepads[i]!=null && gamepads[i].id.match(/^Oculus Touch \([^\)]+\)$/))
		{
			var hand=poseToMatrix(gamepads[i].pose);
			var sittingToStanding=vrDisplay.stageParameters.sittingToStandingTransform;
			
			if (gamepads[i].hand=="right")
			{
				// sittingToStanding×handが右手の位置を表すモデル行列
			}
			if (gamepads[i].hand=="left")
			{
				// sittingToStanding×handが左手の位置を表すモデル行列
			}
		}
	}
自分が実際に立って/座っているヘッドセットの位置を基準に描画して欲しいなら、 ビュー行列も同様にずらすのが良いです。
	var sittingToStanding=vrDisplay.stageParameters.sittingToStandingTransform;
	var view=frameData.leftViewMatrix;
	// view×sittingToStanding-1を左目描画時のビュー行列に
	var view=frameData.rightViewMatrix;
	// view×sittingToStanding-1を右目描画時のビュー行列に

最適化

リフレッシュレート
 
OculusブラウザのVRモードはデフォルトで60Hzで描画しますが、requestPresent()を呼ぶ際にattributes引数に highRefreshRate:true を指定する ことで72Hzの描画に引き上げることができます。
	vrDisplay.requestPresent([{ source: canvas, attributes: { highRefreshRate: true } }]);
FFR
 
requestPresent()を呼ぶ際にattributes引数に foveationLevelを指定する ことで、FFR (Fixed Foveated Rendering)を有効化できます。 FFRは人の目が視界の端をきちんと見えていないことを利用して、 画面の端の解像度を落とすことで速度を稼ぐ機能です。
	vrDisplay.requestPresent([{ source: canvas, attributes: { foveationLevel: 3 } }]);
Multiview Rendering (バージョン6.0以降)
 
VRでは左目用と右目用の2つの画像を毎フレーム描画するため、 処理速度の足かせとなります。 左右の目用の描画は、視点位置以外の頂点座標やテクスチャ情報は同じなので、 一度の描画命令で両目を同時に処理することで、無駄を省く機能 です。 これにより、CPU負荷が25%〜50%軽くなると言われています。
 
最新版ではOCULUS_multiview拡張を使います。 Multiviewを実現するために始めに提案された OVR_multiview拡張は改良版のOVR_multiview2拡張に変わっていますが、 OVR_multiview2拡張は標準化提案の草案状態なので、 さらに上位互換のアンチエイリアシング機能を足された OCULUS_multiview拡張のみがデフォルトで利用可能になっているためです。 なお、WebGL 2.0+GLSL ES 3.0の利用が必須です。
	gl.getExtension("OCULUS_multiview");											// Multiview機能を有効化
	var samples=2;				// 2以上でアンチエイリアシング (最大値:gl.getParameter(gl.MAX_SAMPLES))
	
	var defaultFramebuffer=gl.getParameter(gl.FRAMEBUFFER_BINDING);
	
	var multiviewFramebuffer=gl.createFramebuffer();
	gl.bindFramebuffer(gl.DRAW_FRAMEBUFFER,multiviewFramebuffer);
	
	var multiviewColorTexture=gl.createTexture();
	gl.bindTexture(gl.TEXTURE_2D_ARRAY,multiviewColorTexture);
	gl.texStorage3D(gl.TEXTURE_2D_ARRAY,1,gl.RGBA8,canvas.width/2,canvas.height,2);
	if (samples>1)
	{
		multiview.framebufferTextureMultisampleMultiviewOVR(gl.DRAW_FRAMEBUFFER,gl.COLOR_ATTACHMENT0,multiviewColorTexture,0,samples,0,2);
	}
	else
	{
		multiview.framebufferTextureMultiviewOVR(gl.DRAW_FRAMEBUFFER,gl.COLOR_ATTACHMENT0,multiviewColorTexture,0,0,2);
	}
	
	var depthStencilTexture=gl.createTexture();
	gl.bindTexture(gl.TEXTURE_2D_ARRAY,depthStencilTexture);
	gl.texStorage3D(gl.TEXTURE_2D_ARRAY,1,gl.DEPTH32F_STENCIL8,canvas.width/2,canvas.height,2);
	if (samples>1)
	{
		multiview.framebufferTextureMultisampleMultiviewOVR(gl.DRAW_FRAMEBUFFER,gl.DEPTH_STENCIL_ATTACHMENT,depthStencilTexture,0,samples,0,2);
	}
	else
	{
		multiview.framebufferTextureMultiviewOVR(gl.DRAW_FRAMEBUFFER,gl.DEPTH_STENCIL_ATTACHMENT,depthStencilTexture,0,0,2);
	}
	
	function render()
	{
		gl.bindFramebuffer(gl.DRAW_FRAMEBUFFER,multiviewFramebuffer);
		gl.viewport(0,0,canvas.width/2,canvas.height);
		gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
		
		gl.uniformMatrix4fv(gl.getUniformLocation(shaderProgram,"vpMatrixLeft"),false,vpMatrixLeft);	// frameData.leftProjectionMatrix×frameData.leftViewMatrix
		gl.uniformMatrix4fv(gl.getUniformLocation(shaderProgram,"vpMatrixRight"),false,vpMatrixRight);	// frameData.rightProjectionMatrix×frameData.rightViewMatrix
		// ここで描画
		
		gl.invalidateFramebuffer(gl.DRAW_FRAMEBUFFER,[ gl.DEPTH_STENCIL_ATTACHMENT ]);
		gl.bindFramebuffer(gl.DRAW_FRAMEBUFFER,defaultFramebuffer);
		
		// multiviewColorTextureを画面全体に書き写し (こちらのblit()を参照)
		
		gl.flush();
	}
このMultiview処理では、左目用と右目用の両方の画像を持つ 3Dテクスチャを描画対象にして一括で描画し、 そのテクスチャを本来のフレームバッファへ書き写すことで実現します。
 
一括描画処理は 1回のドローコールで頂点シェーダーがgl_ViewID_OVRの値のみが変化して 2回走ります。 そのため、左右両方の目の情報をシェーダーに渡しておいて、 シェーダー側で振り分けるように書きます。
	<script type="x-shader/x-vertex">
	#version 300 es
	#extension GL_OVR_multiview : require	// Multiview機能用頂点シェーダー
	layout(num_views=2) in;				// Multiview用指定 (2は同時に描画する数)
	in vec3 position;
	in vec4 color;
	in vec3 normal;
	uniform mat4 vpMatrixLeft;
	uniform mat4 vpMatrixRight;
	uniform mat4 mMatrix;
	out vec4 vColor;
	out vec3 vNormal;
	void main()
	{
		mat4 vpMatrix = gl_ViewID_OVR == 0u ? vpMatrixLeft : vpMatrixRight;		// 左右のどちらの描画かによって振り分ける
		gl_Position=vpMatrix*mMatrix*vec4(position,1.0);
		vColor=color;
		vNormal=normalize(normal);
	}
	</script>
アンチエイリアシング (マルチサンプリング) を無効で使うと、 特に直線などが汚くなってしまいます。 有効にするとMultiviewを使わないときと同様に綺麗になりますが、 処理速度が落ちてしまうようで、まだ発展途上の機能かもしれません。
 
Multiview Rendering (バージョン5系)
 
バージョン5系では、OVR_multiview拡張を読み込んで (デフォルトではWEBGL_multiviewではなくOVR_multiviewを使うようになっています)、 requestPresent()を呼ぶ際にMultiviewを要求します。 描画処理ではVRViewからフレームバッファを受け取ってWebGL側に設定します。 こちらの手法では3Dテクスチャの経由は不要で、WebGL 1.0でも実現可能です。
	gl.getExtension("OVR_multiview");											// Multiview機能を有効化
	
	vrDisplay.requestPresent([{ source: canvas, attributes: { multiview: true, depth: true } }]);	// Multiview機能を要求
	
	function render()
	{
		var views=vrDisplay.getViews();		// getViewsはWebVR 2.0の機能で、大半のブラウザでは未定義(バージョン6以降含む)なので注意
		gl.enable(gl.SCISSOR_TEST);
		for (var i=0;i<=views.length-1;i++)
		{
			var viewport=views[i].getViewport();
			gl.bindFramebuffer(gl.FRAMEBUFFER,views[i].framebuffer);
			gl.viewport(viewport.x,viewport.y,viewport.width,viewport.height);
			gl.scissor(viewport.x,viewport.y,viewport.width,viewport.height);
			gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
			// Multiview機能が動いているか views[i].getAttributes().multiview で要確認
			
			gl.uniformMatrix4fv(gl.getUniformLocation(shaderProgram,"vpMatrixLeft"),false,vpMatrixLeft);	// frameData.leftProjectionMatrix×frameData.leftViewMatrix
			gl.uniformMatrix4fv(gl.getUniformLocation(shaderProgram,"vpMatrixRight"),false,vpMatrixRight);	// frameData.rightProjectionMatrix×frameData.rightViewMatrix
			// ここで描画
		}
		gl.disable(gl.SCISSOR_TEST);
		
		gl.flush();
	}
シェーダー側の処理はバージョン6.0以降と全く同じで、 gl_ViewID_OVRの値で左右どちら用か振り分けます。
 
VRDisplay.getViews()はWebVR 2.0の機能が前倒し導入されたものですが、 WebVR 2.0の仕様は放棄されています (WebXR Device APIへ統合するためですが、そのWebXRの仕様はまだ策定中)。 また、OVR_multiview拡張は見ての通りOculusのベンダープリフィックスが付いています。 そのため、Oculusブラウザバージョン5系以外ではこの方法は使えません。

ブラウザ機能

OculusブラウザのVR以外の機能について書きます。
 
PC/Macでリモートデバッグ
 
開発者モードをONにしてPC/MacとUSBケーブルで繋いでいれば、 PC/Mac上のGoogle Chromeで chrome://inspect/#devices を開いて、 Oculusブラウザで開いているページをデバッグできます。 adb connectしていれば、無線接続でもできます。
 
PC/MacからURLを送る
 
adbでOculusヘッドセットとやりとり出来るように環境設定出来ていれば、 PC/Macで指定したURLをOculusブラウザで開くことができます。
	adb shell am start 開きたいURL
PC/Macから文字を入力する
 
adbでOculusヘッドセットとやりとり出来るように環境設定出来ていれば、 ブラウザで文字入力画面になっているときに、 PC/Macで指定した文字列を打ち込ませることができます。 日本語は無理ですが (文字ではなくキー入力を送っているため)、 パスワード入力などに便利です。
	adb shell input text 打ち込みたい文字列
日本語入力
 
日本語入力は現状実装されていません。 有志の方が作ったBookmarklet を使うことで、擬似的に日本語を入力することはできます。
 
Bookmarklet
 
「ブックマークを作成/編集」でURLを javascript:で始めることで、 Bookmarkletを保存して使うことが出来ます。 ただし、URLの頭にスペースを入れないと保存できない点に注意が必要です。
 
chrome://アドレス
 
OculusブラウザはChromiumベースなので、chrome://アドレスが使えます (chrome://chrome-urls/ で使えるアドレス一覧を表示できます)。 chrome://flags で実験的な機能を先行利用したり、 ブラウザの調子が悪いときに chrome://restart でブラウザだけ再起動できます。
 
file:///アドレス
 
file:///アドレスは使えないようです。検索語扱いされてしまいます。
 
ファイルのダウンロード・アップロード
 
ダウンロードしたファイルは /Download フォルダ以下に保存されます。 WindowsはADB Drivers、 MacはAndroid File Transferを入れることで、USB接続時にPC/Macからローカルフォルダにアクセスできます。 ファイルのアップロードは出来ないようです。 <input type=file> ボタンは押しても無反応です。

その他のAPI

OculusブラウザでVR以外も含めて、 各APIが使えるようになっているかどうか調べた結果を載せます。
 
機種判別
 
UserAgent中に特定の文字列が入っているか どうかで、機種判別できます。 また、VRDisplay.displayNameの値 でも機種判別できます。
 
機種判別
機種名 UserAgentに含まれる文字列 VRDisplay.displayName
Oculus Quest Quest Build Oculus Quest
Oculus Go Pacific Build Oculus Go
Gear VR SAMSUNG SM-G920F for a Samsung Galaxy S6など ?
 
WebXR
 
WebVRの後継にWebXRがあります。 これはVR/AR/MRを統合して統一的に扱えるようにした新しいAPIですが、 Oculusブラウザではまだデフォルトで使えませんし (chrome://flagsから有効化して一部使えますが)、 WebXRの仕様自体がまだ流動的で固まっていないため、 WebVR APIを使うのが現実的です。
 
WebRTC
 
WebRTCは一通り使えますが、 OculusヘッドセットのMediaDevicesは音声入出力しか取れないため (トラッキング用カメラは参照できるカメラ扱いされていません)、 映像は受信専用になります。 受け取ったMediaStreamをテクスチャに貼ったりも出来ます。
 
Screen Capture API
 
スクリーンキャプチャーはバージョン6.0から getDisplayMedia()が実装されましたが、 navigator.mediaDevices.getDisplayMedia()を呼ぶと "NotAllowedError" 例外が出るため、使えません。
 
Web Audio API
 
Web Audio APIは普通に使えます。 音の再生はもちろん、 MediaDevicesからマイクのMediaStreamを取得して MediaRecorderで録音もできます。
 
Web Speech API (SpeechRecognition)
 
音声認識はwebkitSpeechRecognitionが使えてユーザー許可も出来ますが、 start()を呼んだ直後にerrorイベントが返ってきて必ず失敗します。 Chromeではサーバーで処理することで実現しているため、 サーバー側が用意されていないのではないかと思われます。
 
Clipboard API
 
Oculusブラウザにはコピー&ペーストするUIがありませんが、 Clipboard APIは使えます。
 
Notifications API
 
Notificationクラスが存在しないため、通知は使えません。
 
Bluetooth API
 
navigator.bluetooth.requestDevice()を呼ぶと、 "Bluetooth Low Energy not available." 例外が発生するため、 使えないようになっているようです。
 
OffscreenCanvas
 
バージョン6.0以降ではOffscreenCanvasが使えます。 ですが、WebVRで使おうとするとrequestPresent()に渡したところで "OffscreenCanvas presentation not implemented." エラーになるため、 VRで直接使うことは出来ませんでした。

戻る