WebXRがどのように描画されるのか、生のWebGLで理解したい

WebXRがどのように描画されるのか、単純なトライアングルでのサンプルを基に、ChatGPT o1 pro に聞きながら理解を深めたい。
🫀一番知りたかったキモの部分
WebXR でのVRセッションを開始し、左右の目用の射影行列・ビュー行列に合わせて WebGL で描画している。
// 各ビュー(左右の目)に対して描画を行う
for (let view of pose.views) {
// 各ビューに対応するビューポートを取得
const viewport = session.renderState.baseLayer.getViewport(view);
gl.viewport(viewport.x, viewport.y, viewport.width, viewport.height);
// 各ビューの射影行列とビュー行列(カメラの位置・向きの逆行列)を uniform に設定
gl.uniformMatrix4fv(projLoc, false, view.projectionMatrix);
// view.transform.matrix はカメラのワールド行列なので、その逆行列を使います
gl.uniformMatrix4fv(viewLoc, false, view.transform.inverse.matrix);
// シンプルに白色で三角形を描画
gl.uniform4fv(colorLoc, [1.0, 1.0, 1.0, 1.0]);
gl.drawArrays(gl.TRIANGLES, 0, vertexCount);
}
単純なサンプル。
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>シンプルな WebXR サンプル (Raw WebGL)</title>
<style>
body { margin: 0; overflow: hidden; }
/* VRセッション開始ボタンのスタイル */
#enter-vr {
position: absolute;
top: 20px;
left: 20px;
z-index: 100;
padding: 10px 20px;
font-size: 16px;
}
canvas {
display: block;
width: 100%;
height: 100%;
}
</style>
</head>
<body>
<button id="enter-vr">Enter VR</button>
<canvas id="xr-canvas"></canvas>
<script>
let xrSession = null;
let gl = null;
let xrRefSpace = null;
let shaderProgram = null;
let vertexBuffer = null;
const vertexCount = 3; // 三角形なので3頂点
// ------------- シェーダーのソースコード -------------
// 頂点シェーダー:各頂点の位置を変換する
const vsSource = `
attribute vec3 aPosition;
uniform mat4 uProjectionMatrix;
uniform mat4 uViewMatrix;
void main(void) {
// 射影行列とビュー行列をかけることで、各頂点の最終的な位置を計算
gl_Position = uProjectionMatrix * uViewMatrix * vec4(aPosition, 1.0);
}
`;
// フラグメントシェーダー:描画する色を指定する
const fsSource = `
precision mediump float;
uniform vec4 uColor;
void main(void) {
gl_FragColor = uColor;
}
`;
// ------------- WebGL コンテキストの初期化 -------------
function initWebGLContext(canvas) {
// xrCompatible:true を指定することで、XR 用に最適化されたコンテキストを取得
const context = canvas.getContext('webgl', { xrCompatible: true });
if (!context) {
console.error("WebGL コンテキストの初期化に失敗しました。");
}
return context;
}
// ------------- シェーダーのコンパイル -------------
function compileShader(gl, source, type) {
const shader = gl.createShader(type);
gl.shaderSource(shader, source);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
console.error("シェーダーのコンパイルエラー: " + gl.getShaderInfoLog(shader));
gl.deleteShader(shader);
return null;
}
return shader;
}
// ------------- シェーダープログラムの生成 -------------
function initShaderProgram(gl, vsSource, fsSource) {
const vertexShader = compileShader(gl, vsSource, gl.VERTEX_SHADER);
const fragmentShader = compileShader(gl, fsSource, gl.FRAGMENT_SHADER);
const program = gl.createProgram();
gl.attachShader(program, vertexShader);
gl.attachShader(program, fragmentShader);
gl.linkProgram(program);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
console.error("シェーダープログラムの初期化に失敗しました: " + gl.getProgramInfoLog(program));
return null;
}
return program;
}
// ------------- 頂点バッファの初期化 -------------
function initBuffers(gl) {
// 画面中央付近に表示されるよう z=-1.0 に配置
const vertices = new Float32Array([
0.0, 0.5, -1.0, // 上頂点
-0.5, -0.5, -1.0, // 左下
0.5, -0.5, -1.0 // 右下
]);
const buffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW);
return buffer;
}
// ------------- XRセッション開始時の処理 -------------
async function onXRSessionStarted(session) {
xrSession = session;
const canvas = document.getElementById('xr-canvas');
gl = initWebGLContext(canvas);
// WebGL コンテキストを XR 用に互換性を持たせる
await gl.makeXRCompatible();
// XRWebGLLayer を作成し、XR セッションのレンダリングターゲットとして設定
session.updateRenderState({ baseLayer: new XRWebGLLayer(session, gl) });
// 'local' リファレンススペースを取得
xrRefSpace = await session.requestReferenceSpace('local');
// シェーダープログラムと頂点バッファを初期化
shaderProgram = initShaderProgram(gl, vsSource, fsSource);
vertexBuffer = initBuffers(gl);
// レンダリングループの開始
session.requestAnimationFrame(onXRFrame);
}
// ------------- フレーム毎のレンダリング -------------
function onXRFrame(time, frame) {
const session = frame.session;
session.requestAnimationFrame(onXRFrame);
// 現在のユーザーの位置・向きを取得
const pose = frame.getViewerPose(xrRefSpace);
if (!pose) return;
// XR のレンダリングターゲットであるフレームバッファをバインド
gl.bindFramebuffer(gl.FRAMEBUFFER, session.renderState.baseLayer.framebuffer);
// 画面をクリア(背景は黒)
gl.clearColor(0.0, 0.0, 0.0, 1.0);
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
// シェーダープログラムを利用開始
gl.useProgram(shaderProgram);
// attribute と uniform の位置を取得
const posLoc = gl.getAttribLocation(shaderProgram, 'aPosition');
const projLoc = gl.getUniformLocation(shaderProgram, 'uProjectionMatrix');
const viewLoc = gl.getUniformLocation(shaderProgram, 'uViewMatrix');
const colorLoc = gl.getUniformLocation(shaderProgram, 'uColor');
// 頂点バッファをバインドし、attribute を有効化
gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer);
gl.enableVertexAttribArray(posLoc);
gl.vertexAttribPointer(posLoc, 3, gl.FLOAT, false, 0, 0);
// 各ビュー(左右の目)に対して描画を行う
for (let view of pose.views) {
// 各ビューに対応するビューポートを取得
const viewport = session.renderState.baseLayer.getViewport(view);
gl.viewport(viewport.x, viewport.y, viewport.width, viewport.height);
// 各ビューの射影行列とビュー行列(カメラの位置・向きの逆行列)を uniform に設定
gl.uniformMatrix4fv(projLoc, false, view.projectionMatrix);
// view.transform.matrix はカメラのワールド行列なので、その逆行列を使います
gl.uniformMatrix4fv(viewLoc, false, view.transform.inverse.matrix);
// シンプルに白色で三角形を描画
gl.uniform4fv(colorLoc, [1.0, 1.0, 1.0, 1.0]);
gl.drawArrays(gl.TRIANGLES, 0, vertexCount);
}
}
// ------------- XRセッション開始関数 -------------
async function startXR() {
if (!navigator.xr) {
alert("WebXRはこのブラウザでサポートされていません。");
return;
}
try {
// immersive-vr セッションをリクエスト(ARの場合は immersive-ar)
const session = await navigator.xr.requestSession('immersive-vr', { requiredFeatures: ['local'] });
onXRSessionStarted(session);
} catch (e) {
console.error("XRセッションの開始に失敗しました:", e);
}
}
// ------------- イベントリスナーの設定 -------------
document.getElementById('enter-vr').addEventListener('click', () => {
startXR();
});
</script>
</body>
</html>

コードを説明して?
全体の流れ
-
HTML の構造:
- ページを真っ黒背景で全画面表示するために、
body { margin: 0; overflow: hidden; }
のスタイル指定。 - 「Enter VR」ボタンと
<canvas>
要素を準備。
- ページを真っ黒背景で全画面表示するために、
-
JavaScript の流れ:
- ボタンを押すと
startXR()
関数が呼ばれ、navigator.xr.requestSession('immersive-vr')
でVRセッションを開始。 - セッション開始後、
onXRSessionStarted()
関数で WebGL の初期化やシェーダーの設定、バッファ(頂点データ)の準備などを行う。 - それらが終わると、
session.requestAnimationFrame(onXRFrame)
を使って、継続的にフレーム描画処理(onXRFrame
) が呼ばれるようになる。
- ボタンを押すと
-
onXRFrame()
での描画:- 毎フレーム、ユーザーの視点情報 (
getViewerPose
) を取得し、その情報(左右の目それぞれの投影行列・ビュー行列)を使って WebGL で三角形を描画する。
- 毎フレーム、ユーザーの視点情報 (
主なパートの詳細
1. シェーダー(頂点/フラグメント)の準備
-
頂点シェーダー (vsSource)
各頂点の位置に対して、「射影行列 × ビュー行列 × 頂点座標」を計算して最終的なクリップ座標に変換します。 -
フラグメントシェーダー (fsSource)
描画するピクセルの色を指定します(ここではuniform vec4 uColor
で単色に設定)。
2. WebGL コンテキストの初期化
const context = canvas.getContext('webgl', { xrCompatible: true });
-
{ xrCompatible: true }
オプションによって、WebXR で使うのに適した WebGL コンテキストを取得しています。
3. シェーダープログラムとバッファの作成
-
initShaderProgram()
シェーダーをコンパイルしてリンクし、一つのシェーダープログラムとしてまとめます。 -
initBuffers()
三角形の頂点座標をFloat32Array
として定義し、WebGL のバッファに格納します。ここでは z 座標を -1.0 にして、VR空間の少し手前あたりに三角形が表示されるイメージになっています。
4. VRセッション開始とレンダリングの準備
session.updateRenderState({ baseLayer: new XRWebGLLayer(session, gl) });
xrRefSpace = await session.requestReferenceSpace('local');
- VRセッションでの描画先を
XRWebGLLayer
に設定し、カメラの座標系(referenceSpace
)を "local" で取得しています。
5. 毎フレームの描画 (onXRFrame)
-
frame.getViewerPose(xrRefSpace)
でユーザーのヘッドポーズを取得。 -
session.renderState.baseLayer.framebuffer
をフレームバッファとしてバインドし、画面をクリア。 - シェーダープログラムを有効化し、頂点バッファを設定。
- ユーザーの視点(左右の目)ごとにビューポートを切り替え、射影行列・ビュー行列をセットして三角形を描画。
まとめ
-
やっていることの本質:
- WebXR でのVRセッションを開始し、左右の目用の射影行列・ビュー行列に合わせて WebGL で三角形を描画している。

シェーダーに渡している行列の役割を説明して?
WebGL で 3D オブジェクトを描画する際には、通常「座標変換を行うための複数の行列」を使います。ここではシェーダーに対し、以下のように 2つ の行列を渡しています。
uniform mat4 uProjectionMatrix;
uniform mat4 uViewMatrix;
それぞれの役割を簡単に説明すると、次のとおりです。
1. uProjectionMatrix(射影行列)
-
役割:
「カメラ空間」から「クリップ空間」に変換する行列です。 -
どんな計算をしているか:
- 遠近感(パースペクティブ)を表現するために、遠くの物体は小さく見えるように座標を縮める。
- WebXR では、左右の目で微妙に異なる射影行列を用いることで立体視を実現している。
-
使われ方のイメージ:この掛け算によって、最終的に画面上での「2D投影位置」が確定します。
// 頂点シェーダー内 gl_Position = uProjectionMatrix * ...;
2. uViewMatrix(ビュー行列)
-
役割:
「ワールド空間」にあるオブジェクトを「カメラ空間」に変換する行列です。 -
どんな計算をしているか:
- カメラの位置・向きを逆に適用して、シーン全体を “カメラから見た” 形に変換する。
- WebXR では、ユーザーのヘッドセットの姿勢(位置や向き)に合わせてビュー行列がフレームごとに変化するため、実際に首を動かした方向に合わせた視点が得られるようになっている。
-
使われ方のイメージ:オブジェクトの座標をカメラの基準に合わせるために利用します。
// 頂点シェーダー内 gl_Position = ... * uViewMatrix * vec4(aPosition, 1.0);
シェーダー内での流れ
最終的に頂点シェーダーでは、下記のように行列を乗算しています。
gl_Position = uProjectionMatrix * uViewMatrix * vec4(aPosition, 1.0);
-
uViewMatrix * vec4(aPosition, 1.0)
- ワールド空間にある頂点位置を「カメラ空間」に変換する。
-
uProjectionMatrix * (上記の結果)
- カメラ空間での頂点をクリップ空間に変換し、画面上どこに表示するか(遠近感含む)を決める。
この 2段階の行列変換によって、3D空間の頂点が最終的にスクリーンに投影されるわけです。
まとめ
- uProjectionMatrix: 「カメラ空間 → クリップ空間」の変換。遠近感を計算し、最終的な2D投影座標を決定。
- uViewMatrix: 「ワールド空間 → カメラ空間」の変換。カメラの位置や向きをシーン全体に反映させる。
- WebXR では、この2つの行列をフレームごとに更新することで、ヘッドセットの位置・向きに合わせて正しい視点からの 3D 描画が可能となっています。

uViewMatrix: 「ワールド空間 → カメラ空間」の変換だけで十分な気がしますが…?
おっしゃるように、3D のレンダリングで「カメラ空間への変換」(ビュー変換) だけでも、ワールド座標系から見た相対位置は得られます。ですが最終的には、ディスプレイ上での表示や、いわゆる「遠近感」を正しく処理するために、もう一段の射影変換が必要です。
具体的には、WebGL (OpenGL系 API 全般) では「クリップ空間」という座標系に変換しないと描画が行えません。クリップ空間は、縦横とも (-1)〜(1) に正規化された範囲を意味します。「ビューポートに写せる(=実際に画面に出力できる)」形まで座標を持っていくために、射影行列 でカメラ空間からクリップ空間へと頂点を変換します。
例え話でイメージすると
-
ビュー変換 (uViewMatrix):
- 「カメラ側がどこを向いているか?」を基準として、シーンをカメラの向きに合わせて動かすイメージです。
- 例えるなら「写真を撮るときに自分がどこに立つか、どこを向くか」を決める段階と思ってください。
-
射影変換 (uProjectionMatrix):
- 「レンズの特性」を反映して、遠近感をつけたり、画面の縦横比を調整したりする行列です。
- 例えるなら「望遠レンズで撮るか、広角レンズで撮るか」または「写真の撮影範囲をどう切り取るか」を決める段階になります。
なぜ分けるの?
もちろん「ビュー行列 × 射影行列」を一つの行列にまとめてしまうことは技術的に可能で、結果的には同じ変換をします。しかし分割しておくと、
-
ビュー行列 (カメラ位置・向き) のみ更新
ユーザーが首を動かしたり、位置が変わったりしても、射影行列はほぼ変わらない(画角や表示領域は同じ)。 -
射影行列 (レンズ特性・クリップ空間への投影) のみ更新
VRデバイスによって視野角や左右の目の調整が違う場合、ビューとは別に射影行列を変更できる。
…など、論理的・運用的に分けて扱いやすいという利点があります。
まとめ
- ビュー行列 (ワールド空間 → カメラ空間) は “カメラがどこを見ているか” を決める。
- 射影行列 (カメラ空間 → クリップ空間) は “画面にどう切り取られるか” や “遠近感” を決める。
- 最終的に
gl_Position = uProjectionMatrix * uViewMatrix * …
という順番で掛け合わせる必要があるので、ビュー行列だけでは「画面に描画される位置」を完全には確定できないわけですね。
3D 初心者だとちょっとややこしく感じるかもしれませんが、視点(View)とレンズ(Projection)を分けて考えるのは定番パターンです。慣れていくうちに、「あぁ、2ステップに分かれていたほうが管理しやすいな」ときっと思うようになります。

図解して?
以下は 3D グラフィックスの一般的な座標変換ステップを、文字ベースで図解したものです。図の各行列変換でどの座標空間に移るかを示しています。WebXR でも基本的な考え方は同じで、ビュー行列と射影行列の 2段階を踏んで最終的に画面に描画されています。
各ステップの説明
-
ワールド空間 (World Space)
- 3D シーン上での実際の位置を表す座標系。
- もしオブジェクトごとに「モデル行列」がある場合は、それを掛けてワールド空間に変換します。
-
ビュー行列 (View Matrix)
- 「カメラがどこにあって、どこを向いているか」を反映して、ワールド座標をカメラ基準の「カメラ空間」へ変換する行列です。
- 結果として、ユーザーから見た位置・向きになる座標系になります。
-
カメラ空間 (View/Camera Space)
- カメラを(0, 0, 0) に置き、カメラが奥方向を見るように座標を再配置した空間。
- まだ “画面上の位置” は決まっていません。あくまでカメラ視点でシーンを見たらどこに見えるか、を計算しているイメージです。
-
射影行列 (Projection Matrix)
- 遠近感 (パースペクティブ) や、どの範囲を切り取って描画するか (視野角, 画角) を決めるための行列。
- カメラ空間から「クリップ空間」へ変換します。
-
クリップ空間 (Clip Space)
- グラフィックス API での「頂点を切り出す空間」。ここで視野外にある頂点は“クリップ”される対象になります。
- ただし、まだ座標は ((x, y, z, w)) と「同次座標」の形。
-
透視除算 (Perspective Divide)
- クリップ空間での ((x, y, z, w)) を ((x/w, y/w, z/w)) に変換するステップです。
- これにより、(-1) から (1) の「正規化デバイス空間 (NDC)」に投影されます。
-
正規化デバイス空間 (Normalized Device Coordinates, NDC)
- (-1)~(1) の立方体領域。
- (x=-1) が画面左、(x=1) が画面右。(y=-1) が画面下、(y=1) が画面上。
- ここまで座標変換すると、GPU はさらに内部処理での“ラスタライズ”に進みます。
-
ビューポート変換 (Viewport Transform)
- 実際のウィンドウやディスプレイ解像度 ((width, height)) に合わせて、NDC上の (-1)~(1) をピクセル座標系に写し取ります。
WebXRの場合
- ビュー行列は、VR/ARデバイスの「位置 (トラッキングされた頭の位置)」「向き (ヘッドセットの向き)」に合わせて毎フレーム変化します。
- 射影行列は、各目(左眼と右眼)の視野角や IPD (瞳孔間距離) を踏まえて違うものを使います。
- これらを組み合わせて、左右の目で微妙に異なる映像を描画し、VR空間での立体視を実現しています。
まとめ
- 「ワールド空間」から「カメラ空間 (ビュー変換)」を経由し、さらに「クリップ空間 (射影変換)」へ座標を移して初めて GPU が描画可能な形になる。
- 透視除算やビューポート変換も含めて、合計 4 つ以上のステップを踏んで最終的に画面上のピクセル座標が決まる。
- ビュー行列と射影行列は特に重要で、VR なら「ユーザーの視点の位置」「左右の目のレンズ設定」をここに反映している。
図解を見ながら実際にシェーダーなどで行列を組み立てると、3D空間でどう座標が変化しているかイメージしやすくなると思います。
