はじめに
この記事は WebXR Advent Calendar 2025 24日の記事です。
WebXR、楽しいですよね。A-Frameなどのライブラリを通して、手軽に楽しむことができます。
でも、自分で直接WebXR APIを触ってみたくないですか? この記事は、そんな冒険者の方のためのものです。
WebGPUでWebXR(VR)の描画を実現するという、これまたマニアックなネタになっています。WebGPUひとつとってもやっている人が少ないのに……。マニアック×マニアックな記事になること請け合いですね。
しかしながら自力でこの辺りを自作したいという方は、かなり役立つ内容だと思いますので、ぜひ参考にされてみてください。
なお、WebGLにおけるWebXR(VR)対応については、以下の記事にて解説をしています。描画周り以外の部分では共通する部分がありますので、ぜひこれも参考にしてください。
参考ドキュメント
基本的な対応方法については、以下のimmersive-webのGithubプロジェクトにドキュメントが公開されています。
本記事もこのドキュメントに沿う形で解説していこうと思います。
手順
XRSessionの取得
まずはXRSessionを取得する必要があります。この際、WebGPUと連携させる場合はnavigator.xr.requestSessionメソッドの第2引数に{requiredFeatures:['webgpu'}を渡す必要があります。渡さないとWebGL連携用に設定されてしまいますので注意しましょう。
const xrSession = await navigator.xr.requestSession('immersive-vr', {requiredFeatures: ['webgpu']});
WebGPU互換のXRSessionは、WebGL互換のセッションと以下の点で異なります。WebXR初学者の方はなんのことかわからないと思うので、一旦忘れてください。
- XRWebGLBindingおよびXRWebGLLayerインスタンスは作成されません
- XRGPUBindingインスタンスは作成できます(下記参照)
- updateRenderState()でbaseLayerを設定できません。代わりにlayersを使用する必要があります
- XRViewsのprojectionMatrix属性は、クリップ空間深度範囲が[-1, 1]ではなく[0, 1]に適した行列を返します
WebGPU binding
WebXRが必要とするすべてのWebGPUリソースはXRGPUBindingインスタンスから得ることができます。
このXRGPUBindingインスタンスは前述のWebGPU互換XRSessionと、WebXR互換のGPUDeviceを使用して、以下のように作成します。
const gpuAdapter = await navigator.gpu.requestAdapter({xrCompatible: true});
const gpuDevice = await gpuAdapter.requestDevice();
const xrGpuBinding = new XRGPUBinding(xrSession, gpuDevice);
XRGPUBinding インスタンスが作成されたら、そこから様々な XRCompositorLayer を作成できます。
const gpuAdapter = await navigator.gpu.requestAdapter({xrCompatible: true});
const gpuDevice = await gpuAdapter.requestDevice();
const xrGpuBinding = new XRGPUBinding(xrSession, gpuDevice);
const projectionLayer = xrGpuBinding.createProjectionLayer();
XRCompositorLayerとは、WebXRの仕様の一部であるWebXR Layer APIが提供するレイヤーオブジェクトです。
WebXR Layer APIは、WebXRの描画にレイヤー(層)の概念を提供します。
WebXRのレイヤーについては、以下の公式ドキュメントを参照してください。
WebGLにおけるWebXRでは、WebXR Layer APIを使わない場合の標準レイヤーとしてXRWebGLLayerがありましたが、WebGPUでは同様のものはなく、WebXR Layer APIが提供する各種レイヤーを使うことになります。
もっとも、現時点では全ての環境でWebXR Layer APIが提供する全ての種類のレイヤーが提供されているわけではありません。
2025年12月時点で、私がWebGPU&WebXR(VR)の動作を確認することができたのはMeta Quest 3 のAirLinkで接続したWindows版Chrome Canaryのみなのですが、この環境ではWebXR Layer APIが提供するレイヤーのうち、もっとも標準的なProjection Layerしか利用することができませんでした。
さて、先ほどのコードによって作られたレイヤーは、カラーアタッチメントとして使用するGPUTextureを提供してくれます。その際、レイヤーのカラーフォーマットを指定する必要があります。
深度/ステンシルが必要な場合は、適切な深度/ステンシルフォーマットを指定することで得ることができます。
XRSessionの推奨カラーフォーマットは、XRGPUBinding.getPreferredColorFormat()メソッドで取得できます。
const gpuAdapter = await navigator.gpu.requestAdapter({xrCompatible: true});
const gpuDevice = await gpuAdapter.requestDevice();
const xrGpuBinding = new XRGPUBinding(xrSession, gpuDevice);
const projectionLayer = xrGpuBinding.createProjectionLayer({
colorFormat: xrGpuBinding.getPreferredColorFormat(),
depthStencilFormat: 'depth24plus',
});
まだ現時点ではサポートしている環境はないと思いますが、WebXR Layer APIはProjection Layer以外のレイヤーも定義しています。以下の表でレイヤーの種類を説明します。
| レイヤーの種類 | 説明 |
|---|---|
| Projection Layer | コントローラーやメニューシステム、その他のUI要素など、フレームごとに再描画する必要があるインタラクティブな要素や常に変化する要素をレンダリングするために使用されます。WebXR Layer APIをサポートしている環境では、このProjection Layerは必ずサポートすることが保証されています。 |
| Quad Layer | テキスト、画像、動画情報を含む小さなパネルをユーザーの目の前に配置するために使用できます。 |
| Cylinder Layer | ユーザーの周囲を囲む大きなパネルを実装し、コンテンツの没入感や読みやすさを向上させるために使用できます。 |
| Cube Layer | 主に、一度だけ描画する、またはほとんど更新する必要がない静的な背景を描画するために使用されます。 |
| Equirect Layer | キューブレイヤーのような静的な背景のレンダリングに使用できます。 |
例えば、QuadLayerであれば以下のように作成します。
const quadLayer = xrGpuBinding.createQuadLayer({
space: xrReferenceSpace,
viewPixelWidth: 1024,
viewPixelHeight: 768,
layout: 'stereo'
});
レイヤーは用途に合わせて複数作成することができ、作成したレイヤーはXRSessionのupdateRenderStateメソッドを使って設定します。
xrSession.updateRenderState({ layers: [projectionLayer, quadLayer] });
レンダリング
XRFrame の処理中、各レイヤーは新しい描画内容で更新することができます。
XRFrame から取得したビューで getViewSubImage() を呼び出すとXRGPUSubImage が返されます。このXRGPUSubImageは、XRView に関連付けられた物理ディスプレイに表示されるテクスチャを保持しています。
Projection Layerを使う場合
xrGpuBinding.getViewSubImageメソッドに対して、Projection LayerのオブジェクトとXRViewを渡すことで、GPUSubImageを得ることができます。このGPUSubImageはcolorTextureとdepthStencilTextureを保持しており、これらをWebGPUのcommandEncoder.beginRenderPassメソッドのdescriptionにビューをアタッチすれば、WebXRのデバイスディスプレイに対して描画を行うことできます。
また、GPUSubImageからビューポート情報を取得し、それをrenderPassEncoderに設定することで、テクスチャの期待される部分(VR HMDのディスプレイ領域)に適切に描画が行われます。
他は、普通のWebGPUの描画処理と同じです。WebGPUとWebXRの組み合わせによるVR体験を楽しんでください。
// Render Loop for a projection layer with a WebGPU texture source.
const xrGpuBinding = new XRGPUBinding(xrSession, gpuDevice);
const layer = xrGpuBinding.createProjectionLayer({
colorFormat: xrGpuBinding.getPreferredColorFormat(),
depthStencilFormat: 'depth24plus',
});
xrSession.updateRenderState({ layers: [layer] });
xrSession.requestAnimationFrame(onXRFrame);
function onXRFrame(time, xrFrame) {
xrSession.requestAnimationFrame(onXRFrame);
const commandEncoder = device.createCommandEncoder();
for (const view in xrViewerPose.views) {
const subImage = xrGpuBinding.getViewSubImage(layer, view);
// Render to the subImage's color and depth textures
const passEncoder = commandEncoder.beginRenderPass({
colorAttachments: [{
view: subImage.colorTexture.createView(subImage.getViewDescriptor()),
loadOp: 'clear',
clearValue: [0,0,0,1],
}],
depthStencilAttachment: {
view: subImage.depthStencilTexture.createView(subImage.getViewDescriptor()),
depthLoadOp: 'clear',
depthClearValue: 1.0,
depthStoreOp: 'store',
}
});
let vp = subImage.viewport;
passEncoder.setViewport(vp.x, vp.y, vp.width, vp.height, 0.0, 1.0);
// Render from the viewpoint of xrView
passEncoder.end();
}
device.queue.submit([commandEncoder.finish()]);
}
Quad Layerの場合
(通常の描画についてはProjection Layerを使って行うものなので、とりあえず画面に何か表示したいという人は、このQuad Layerの説明については理解が追いつくまで当面無視して大丈夫です)
XRQuadLayerのような非投影レイヤーは、「モノ」レイヤーでは1つ、「ステレオ」レイヤーでは2つのサブイメージしか持てず、デバイスが報告するXRViewの数と必ずしも一致しない場合があります。
このような場合、同じビューを複数回レンダリングすることを避けるために、非投影レイヤーはXRGPUBindingのgetSubImage()メソッドを使用して、レンダリング対象のXRSubImageを取得する必要があります。
モノラルテクスチャの場合、レイヤーとXRFrameだけでXRSubImageを取得できます。
// Render Loop for a projection layer with a WebGPU texture source.
const xrGpuBinding = new XRGPUBinding(xrSession, gpuDevice);
const quadLayer = xrGpuBinding.createQuadLayer({
colorFormat: xrGpuBinding.getPreferredColorFormat(),
space: xrReferenceSpace,
viewPixelWidth: 512,
viewPixelHeight: 512,
layout: 'mono'
});
// Position 2 meters away from the origin with a width and height of 1.5 meters
quadLayer.transform = new XRRigidTransform({z: -2});
quadLayer.width = 1.5;
quadLayer.height = 1.5;
xrSession.updateRenderState({ layers: [quadLayer] });
xrSession.requestAnimationFrame(onXRFrame);
function onXRFrame(time, xrFrame) {
xrSession.requestAnimationFrame(onXRFrame);
const commandEncoder = device.createCommandEncoder();
const subImage = xrGpuBinding.getSubImage(quadLayer, xrFrame);
// Render to the subImage's color texture.
const passEncoder = commandEncoder.beginRenderPass({
colorAttachments: [{
view: subImage.colorTexture.createView(subImage.getViewDescriptor()),
loadOp: 'clear',
clearValue: [0,0,0,0],
}]
// Many times simple quad layers won't require a depth attachment, as they're often just
// displaying a pre-rendered 2D image.
});
let vp = subImage.viewport;
passEncoder.setViewport(vp.x, vp.y, vp.width, vp.height, 0.0, 1.0);
// Render the mono content.
passEncoder.end();
device.queue.submit([commandEncoder.finish()]);
}
ステレオテクスチャの場合、getSubImage() にはターゲットとなる XREye を指定する必要があります。
// Render Loop for a projection layer with a WebGPU texture source.
const xrGpuBinding = new XRGPUBinding(xrSession, gpuDevice);
const quadLayer = xrGpuBinding.createQuadLayer({
colorFormat: xrGpuBinding.getPreferredColorFormat(),
space: xrReferenceSpace,
viewPixelWidth: 512,
viewPixelHeight: 512,
layout: 'stereo'
});
// Position 2 meters away from the origin with a width and height of 1.5 meters
quadLayer.transform = new XRRigidTransform({z: -2});
quadLayer.width = 1.5;
quadLayer.height = 1.5;
xrSession.updateRenderState({ layers: [quadLayer] });
xrSession.requestAnimationFrame(onXRFrame);
function onXRFrame(time, xrFrame) {
xrSession.requestAnimationFrame(onXRFrame);
const commandEncoder = device.createCommandEncoder();
for (const eye of ['left', 'right']) {
const subImage = xrGpuBinding.getSubImage(quadLayer, xrFrame, eye);
// Render to the subImage's color texture.
const passEncoder = commandEncoder.beginRenderPass({
colorAttachments: [{
view: subImage.colorTexture.createView(subImage.getViewDescriptor()),
loadOp: 'clear',
clearValue: [0,0,0,0],
}]
// Many times simple quad layers won't require a depth attachment, as they're often just
// displaying a pre-rendered 2D image.
});
let vp = subImage.viewport;
passEncoder.setViewport(vp.x, vp.y, vp.width, vp.height, 0.0, 1.0);
// Render content for the given eye.
passEncoder.end();
}
device.queue.submit([commandEncoder.finish()]);
}
詳しくは、以下のWebXR Layer API説明書か、
WebGLのサンプルになってしまいますが、WebXRレイヤーのサンプルデモページを参考にしてください。
完動デモ
前述のドキュメントだけでは心許ないという方は、以下に完全に動作するデモがありますので、これで動作を確認しながらコードを参考にすると良いと思います。現時点では、MetaQuest 3でWindowsマシンにAirLinkを行い、Windows上のChrome Canaryブラウザで動作を確認しました。以下のデモではProjection Layerしか使っていません。
自作ライブラリRhodoniteでの対応
RhodoniteでもProjection Layerしか使っていません。現行で動作が確認できるMeta Quest 3 (AirLink) & Windows版Chrome Canaryでそれ以外のLayerの取得メソッドがundefinedになっているので、他を使いようがないんですよね。
ソースコードは以下にあります。ちょっと汚いコードなんですが、WebGL&WebGPU両対応なので、そういう意味でも参考にはなるかと。
実際の動作は、以下のRhodoniteEditorから試すことができます。
WebGLとの対応アプローチの違い
WebGLでのWebXR(VR)対応では、XRWebGLLayerが左右の目の領域を合体させた横長のレンダーバッファで、左目・右目のシーンをレンダーバッファ中央を境にそれぞれ左側・右側にviewportを設定して描画するやり方をとっていました。
今回のWebGPUでの対応アプローチは、左目と右目でそれぞれレンダーバッファが分かれており、それぞれのバッファに描画をしていくイメージです。これの問題点は、左目と右目を両方同時に描画できないことです。
あらかじめ自前に横長のレンダーバッファを用意し、WebGLと同じアプローチで左目・右目用にシーンをインスタンスレンダリングで同時に描画し、後でレンダーバッファの左側をHMDの左目バッファにblit、右側をHMDの右目バッファにblitすればもしかしたら行けるかもしれません。
今後、試してみようかな、と思います。
最後に
今回の対応では、左目・右目それぞれで2回シーンを描画しているため、描画速度はあまり速くありません。
左目・右目を同時にレンダリングする、いわゆるマルチビュー対応が今後の課題です。
あと、2025年12月現在では、MetaQuest 3がWebGPU&WebXR(VR)に対応していないんですよね(そもそもWebGPUすら対応していない)。
(@wakufactory さんによると、非VRでのWebGPUには対応しているそうです)
この辺り、来年になれば対応が改善するかもしれません。楽しみに待ちましょう。
あと、Apple Vision ProがWebGPU & WebXR対応したらしいですね。Rhodoniteで試してみましたが、エラーになってしまいました。実機があればデバッグして直せるんですが、Vision Proはいかんせんお値段が高い!
というわけで、低価格版の登場を待ちます……。
皆様も良いWebXRライフを。
