各位靓仔靓女,咱们今天来聊聊WebXR,这个听起来高大上,实际上也没那么难的东西。简单来说,WebXR就是让你的网页也能玩VR和AR的秘密武器。
咱们今天的重点是WebXR Device API,这个API是WebXR的核心,它负责跟各种VR/AR设备打交道,比如头显、手柄,甚至是你的手机摄像头。有了它,你才能获取设备的位置、方向、按钮状态等等,才能让你的网页知道你在VR/AR世界里干了啥。
开场白:WebXR是个啥?
想象一下,你戴上VR头显,看到的不再是平面的屏幕,而是身临其境的3D世界。或者你用手机摄像头对着一张桌子,屏幕上立刻出现一只虚拟的小恐龙,在你桌子上蹦蹦跳跳。这就是WebXR能做到的,它让你的网页突破了屏幕的限制,进入了虚拟和增强现实的世界。
第一章:初识 WebXR Device API
WebXR Device API就像一个翻译官,它把各种VR/AR设备的“语言”翻译成JavaScript能听懂的“语言”。它主要负责以下几个方面:
- 请求XR会话 (Requesting an XR Session): 告诉浏览器,你想开启VR/AR模式了。
- 管理XR会话 (Managing an XR Session): 维护VR/AR会话的状态,比如开始、暂停、结束等等。
- 获取XR参考空间 (Acquiring an XR Reference Space): 定义VR/AR世界的坐标系,让你知道物体在哪个位置。
- 提交XR帧 (Submitting an XR Frame): 把渲染好的画面交给设备显示出来。
第二章:请求XR会话 (Requesting an XR Session)
要开始VR/AR之旅,首先要告诉浏览器:“嘿,我要用WebXR了!” 这就要用到navigator.xr.requestSession()
方法。
navigator.xr.isSessionSupported('immersive-vr')
.then((supported) => {
if (supported) {
console.log('VR session is supported!');
} else {
console.log('VR session is not supported.');
}
});
async function startVR() {
try {
// 请求一个 immersive-vr 会话
const session = await navigator.xr.requestSession('immersive-vr');
// 会话创建成功,继续处理
onSessionStarted(session);
} catch (error) {
console.error('Failed to start VR session:', error);
}
}
function onSessionStarted(session) {
// 设置会话
xrSession = session;
// 监听会话结束事件
xrSession.addEventListener('end', onSessionEnded);
// 获取 WebGL 上下文
const canvas = document.createElement('canvas');
gl = canvas.getContext('webgl', { xrCompatible: true });
if (!gl) {
gl = canvas.getContext('webgl2', { xrCompatible: true });
}
if (!gl) {
alert("Unable to initialize WebGL. Your browser or hardware may not support it.");
return;
}
// 将 WebGL 上下文与 XR 会话绑定
xrSession.updateRenderState({ baseLayer: new XRWebGLLayer(xrSession, gl) });
// 请求参考空间
xrSession.requestReferenceSpace('local').then((refSpace) => {
xrRefSpace = refSpace;
// 开始渲染循环
xrSession.requestAnimationFrame(render);
});
}
function onSessionEnded(event) {
xrSession = null;
console.log('VR session ended.');
}
这段代码做了以下几件事:
- 检查支持性: 首先用
navigator.xr.isSessionSupported('immersive-vr')
检查浏览器是否支持VR会话。'immersive-ar'
则代表AR会话。 - 请求会话: 调用
navigator.xr.requestSession('immersive-vr')
请求一个VR会话。'immersive-vr'
告诉浏览器,你想开启沉浸式的VR体验。 还可以是'immersive-ar'
。 - 处理成功: 如果会话创建成功,
onSessionStarted()
函数会被调用,进行后续的处理。 - 处理失败: 如果创建失败,
catch
语句会捕获错误,并打印到控制台。 - 绑定WebGL上下文: 创建一个canvas元素,并获取WebGL上下文,然后通过
xrSession.updateRenderState()
将WebGL上下文与XR会话绑定。 - 请求参考空间: 通过
xrSession.requestReferenceSpace('local')
请求一个参考空间,这里我们请求的是'local'
参考空间,它表示以用户为中心的本地坐标系。 - 开始渲染循环: 通过
xrSession.requestAnimationFrame(render)
开始渲染循环,render
函数会在每一帧被调用,用于渲染VR/AR场景。 - 监听会话结束事件: 监听
'end'
事件,当VR/AR会话结束时,onSessionEnded()
函数会被调用。
注意:
'immersive-vr'
和'immersive-ar'
是两种会话模式。'immersive-vr'
提供完全沉浸式的VR体验,而'immersive-ar'
则将虚拟内容叠加到现实世界中。navigator.xr.requestSession()
是一个异步函数,所以要用async/await
或者Promise
来处理它的结果。
第三章:管理XR会话 (Managing an XR Session)
一旦会话创建成功,你就需要管理它的状态。WebXR提供了一些事件和方法来帮助你:
session.addEventListener('end', callback)
: 监听会话结束事件。当用户退出VR/AR模式时,这个事件会被触发。session.end()
: 手动结束会话。session.updateRenderState(renderStateInit)
: 更新渲染状态,比如设置WebGL上下文、视口等等。session.requestAnimationFrame(callback)
: 请求下一帧的渲染。
// 结束会话
function endVR() {
if (xrSession) {
xrSession.end();
}
}
//监听会话结束事件
function onSessionEnded(event) {
xrSession = null;
console.log('VR session ended.');
}
第四章:获取XR参考空间 (Acquiring an XR Reference Space)
参考空间定义了VR/AR世界的坐标系。有了它,你才能知道物体在哪个位置,才能进行各种计算。WebXR提供了几种参考空间:
'viewer'
: 以用户的眼睛为原点,Z轴指向前方。'local'
: 以用户为中心的本地坐标系。'local-floor'
: 以地面为基准的本地坐标系。'bounded-floor'
: 以用户活动范围为边界的地面坐标系。'unbounded'
: 无边界的坐标系,适用于AR应用。
xrSession.requestReferenceSpace('local').then((refSpace) => {
xrRefSpace = refSpace;
});
这段代码请求了一个'local'
参考空间,并将其保存在xrRefSpace
变量中。
第五章:提交XR帧 (Submitting an XR Frame)
每一帧,你都需要把渲染好的画面交给设备显示出来。这就要用到XRFrame
对象和XRWebGLLayer
对象。
function render(time, frame) {
xrSession.requestAnimationFrame(render);
const pose = frame.getViewerPose(xrRefSpace);
if (pose) {
const glLayer = xrSession.renderState.baseLayer;
gl.bindFramebuffer(gl.FRAMEBUFFER, glLayer.framebuffer);
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
for (const view of pose.views) {
const viewport = glLayer.getViewport(view);
gl.viewport(viewport.x, viewport.y, viewport.width, viewport.height);
// 获取投影矩阵和视图矩阵
const projectionMatrix = view.projectionMatrix;
const viewMatrix = pose.transform.inverse.matrix;
// 渲染场景
renderScene(projectionMatrix, viewMatrix);
}
}
}
这段代码做了以下几件事:
- 请求下一帧: 调用
xrSession.requestAnimationFrame(render)
请求下一帧的渲染。 - 获取姿态: 调用
frame.getViewerPose(xrRefSpace)
获取用户当前的姿态(位置和方向)。 - 绑定帧缓冲: 获取
XRWebGLLayer
对象,并将其帧缓冲绑定到WebGL上下文。 - 清空缓冲: 清空颜色缓冲和深度缓冲。
- 遍历视图: 遍历
pose.views
数组,获取每个视图的视口、投影矩阵和视图矩阵。 - 设置视口: 调用
gl.viewport()
设置WebGL视口。 - 渲染场景: 调用
renderScene()
函数,传入投影矩阵和视图矩阵,渲染VR/AR场景。
第六章:获取设备姿态和输入
除了渲染画面,你还需要获取设备的位置、方向、按钮状态等等,才能让你的应用与用户互动。
- 获取姿态:
XRFrame.getViewerPose(XRReferenceSpace)
可以获取用户头显的姿态。 - 获取输入源:
XRSession.inputSources
可以获取所有输入源(比如手柄)的信息。 - 获取输入状态:
XRFrame.getPose(XRSpatialInputSource, XRReferenceSpace)
可以获取手柄的姿态。XRInputSource.gamepad
可以获取手柄的按钮和摇杆状态。
function render(time, frame) {
xrSession.requestAnimationFrame(render);
const pose = frame.getViewerPose(xrRefSpace);
if (pose) {
// ... (渲染代码) ...
}
// 获取输入源
for (const source of xrSession.inputSources) {
if (source.gamepad) {
// 获取手柄状态
const gamepad = source.gamepad;
// 获取按钮状态
if (gamepad.buttons[0].pressed) {
console.log('Button A is pressed!');
}
// 获取摇杆状态
const xAxis = gamepad.axes[0];
const yAxis = gamepad.axes[1];
console.log('Joystick X:', xAxis, 'Joystick Y:', yAxis);
// 获取手柄姿态
const gripPose = frame.getPose(source.gripSpace, xrRefSpace);
if (gripPose) {
//手柄位置
const position = gripPose.transform.position;
//手柄方向
const orientation = gripPose.transform.orientation;
console.log('Grip Position:', position, 'Grip Orientation:', orientation);
}
}
}
}
这段代码做了以下几件事:
- 遍历输入源: 遍历
xrSession.inputSources
数组,获取所有输入源的信息。 - 检查手柄: 判断输入源是否是手柄。
- 获取手柄状态: 获取手柄的按钮和摇杆状态。
- 获取按钮状态: 检查按钮是否被按下。
- 获取摇杆状态: 获取摇杆的X轴和Y轴的值。
- 获取手柄姿态: 如果存在gripSpace,则获取手柄的姿态(位置和方向)。
第七章:一个简单的例子
咱们来做一个简单的例子,在VR世界里显示一个立方体,并根据手柄的摇杆控制立方体的位置。
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>WebXR Cube</title>
<style>
body { margin: 0; overflow: hidden; }
canvas { width: 100%; height: 100%; display: block; }
</style>
</head>
<body>
<canvas id="webgl-canvas"></canvas>
<script>
// 初始化
let xrSession = null;
let xrRefSpace = null;
let gl = null;
let cubeRotation = 0.0;
// 顶点着色器
const vsSource = `
attribute vec4 aVertexPosition;
attribute vec4 aVertexColor;
uniform mat4 uModelViewMatrix;
uniform mat4 uProjectionMatrix;
varying lowp vec4 vColor;
void main(void) {
gl_Position = uProjectionMatrix * uModelViewMatrix * aVertexPosition;
vColor = aVertexColor;
}
`;
// 片元着色器
const fsSource = `
varying lowp vec4 vColor;
void main(void) {
gl_FragColor = vColor;
}
`;
// 初始化着色器程序
function initShaderProgram(gl, vsSource, fsSource) {
const vertexShader = loadShader(gl, gl.VERTEX_SHADER, vsSource);
const fragmentShader = loadShader(gl, gl.FRAGMENT_SHADER, fsSource);
const shaderProgram = gl.createProgram();
gl.attachShader(shaderProgram, vertexShader);
gl.attachShader(shaderProgram, fragmentShader);
gl.linkProgram(shaderProgram);
if (!gl.getProgramParameter(shaderProgram, gl.LINK_STATUS)) {
alert('Unable to initialize the shader program: ' + gl.getProgramInfoLog(shaderProgram));
return null;
}
return shaderProgram;
}
// 加载着色器
function loadShader(gl, type, source) {
const shader = gl.createShader(type);
gl.shaderSource(shader, source);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
alert('An error occurred compiling the shaders: ' + gl.getShaderInfoLog(shader));
gl.deleteShader(shader);
return null;
}
return shader;
}
// 初始化缓冲区
function initBuffers(gl) {
const positionBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
const positions = [
// Front face
-1.0, -1.0, 1.0,
1.0, -1.0, 1.0,
1.0, 1.0, 1.0,
-1.0, 1.0, 1.0,
// Back face
-1.0, -1.0, -1.0,
-1.0, 1.0, -1.0,
1.0, 1.0, -1.0,
1.0, -1.0, -1.0,
// Top face
-1.0, 1.0, -1.0,
-1.0, 1.0, 1.0,
1.0, 1.0, 1.0,
1.0, 1.0, -1.0,
// Bottom face
-1.0, -1.0, -1.0,
1.0, -1.0, -1.0,
1.0, -1.0, 1.0,
-1.0, -1.0, 1.0,
// Right face
1.0, -1.0, -1.0,
1.0, 1.0, -1.0,
1.0, 1.0, 1.0,
1.0, -1.0, 1.0,
// Left face
-1.0, -1.0, -1.0,
-1.0, -1.0, 1.0,
-1.0, 1.0, 1.0,
-1.0, 1.0, -1.0,
];
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(positions), gl.STATIC_DRAW);
const colorBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, colorBuffer);
const colors = [
[1.0, 0.0, 0.0, 1.0], // Front face: red
[1.0, 1.0, 0.0, 1.0], // Back face: yellow
[0.0, 1.0, 0.0, 1.0], // Top face: green
[0.0, 0.0, 1.0, 1.0], // Bottom face: blue
[1.0, 0.0, 1.0, 1.0], // Right face: purple
[0.0, 1.0, 1.0, 1.0], // Left face: cyan
];
let faceColors = [];
for (let j = 0; j < colors.length; ++j) {
const c = colors[j];
faceColors = faceColors.concat(c, c, c, c);
}
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(faceColors), gl.STATIC_DRAW);
const indexBuffer = gl.createBuffer();
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indexBuffer);
const indices = [
0, 1, 2, 0, 2, 3, // front
4, 5, 6, 4, 6, 7, // back
8, 9, 10, 8, 10, 11, // top
12, 13, 14, 12, 14, 15, // bottom
16, 17, 18, 16, 18, 19, // right
20, 21, 22, 20, 22, 23, // left
];
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, new Uint16Array(indices), gl.STATIC_DRAW);
return {
position: positionBuffer,
color: colorBuffer,
indices: indexBuffer,
};
}
// 绘制场景
function drawScene(gl, programInfo, buffers, projectionMatrix, viewMatrix) {
gl.clearColor(0.0, 0.0, 0.0, 1.0); // Clear to black, fully opaque
gl.clearDepth(1.0); // Clear everything
gl.enable(gl.DEPTH_TEST); // Enable depth testing
gl.depthFunc(gl.LEQUAL); // Near things obscure far things
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
// 创建模型视图矩阵
const modelViewMatrix = mat4.create();
mat4.translate(
modelViewMatrix, // destination matrix
modelViewMatrix, // matrix to translate
[cubePositionX, cubePositionY, -6.0]
); // amount to translate
mat4.rotate(modelViewMatrix, // destination matrix
modelViewMatrix, // matrix to rotate
cubeRotation, // amount to rotate in radians
[0, 0, 1]); // axis to rotate around (Z)
mat4.rotate(modelViewMatrix, // destination matrix
modelViewMatrix, // matrix to rotate
cubeRotation * .7,// amount to rotate in radians
[0, 1, 0]); // axis to rotate around (X)
{
const numComponents = 3;
const type = gl.FLOAT;
const normalize = false;
const stride = 0;
const offset = 0;
gl.bindBuffer(gl.ARRAY_BUFFER, buffers.position);
gl.vertexAttribPointer(
programInfo.attribLocations.vertexPosition,
numComponents,
type,
normalize,
stride,
offset);
gl.enableVertexAttribArray(
programInfo.attribLocations.vertexPosition);
}
{
const numComponents = 4;
const type = gl.FLOAT;
const normalize = false;
const stride = 0;
const offset = 0;
gl.bindBuffer(gl.ARRAY_BUFFER, buffers.color);
gl.vertexAttribPointer(
programInfo.attribLocations.vertexColor,
numComponents,
type,
normalize,
stride,
offset);
gl.enableVertexAttribArray(
programInfo.attribLocations.vertexColor);
}
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, buffers.indices);
gl.useProgram(programInfo.program);
gl.uniformMatrix4fv(
programInfo.uniformLocations.projectionMatrix,
false,
projectionMatrix);
gl.uniformMatrix4fv(
programInfo.uniformLocations.modelViewMatrix,
false,
modelViewMatrix);
{
const vertexCount = 36;
const type = gl.UNSIGNED_SHORT;
const offset = 0;
gl.drawElements(gl.TRIANGLES, vertexCount, type, offset);
}
cubeRotation += deltaTime;
}
// 初始化WebGL
function initWebGL() {
const canvas = document.getElementById('webgl-canvas');
gl = canvas.getContext('webgl', { xrCompatible: true });
if (!gl) {
alert("Unable to initialize WebGL. Your browser or hardware may not support it.");
return false;
}
return true;
}
// 开始VR
async function startVR() {
try {
xrSession = await navigator.xr.requestSession('immersive-vr', {
requiredFeatures: ['local-floor', 'hand-tracking'] // 请求手部追踪功能
});
onSessionStarted(xrSession);
} catch (error) {
console.error('Failed to start VR session:', error);
}
}
// 会话开始
function onSessionStarted(session) {
xrSession = session;
session.addEventListener('end', onSessionEnded);
gl = document.querySelector("canvas").getContext("webgl", {xrCompatible: true});
xrSession.updateRenderState({ baseLayer: new XRWebGLLayer(xrSession, gl) });
xrSession.requestReferenceSpace('local-floor').then((refSpace) => {
xrRefSpace = refSpace;
xrSession.requestAnimationFrame(render);
});
}
// 会话结束
function onSessionEnded(event) {
xrSession = null;
console.log('VR session ended.');
}
// 渲染循环
let cubePositionX = 0;
let cubePositionY = 0;
let then = 0;
let deltaTime = 0;
function render(time, frame) {
time *= 0.001; // convert to seconds
deltaTime = time - then;
then = time;
xrSession.requestAnimationFrame(render);
const pose = frame.getViewerPose(xrRefSpace);
if (pose) {
const glLayer = xrSession.renderState.baseLayer;
gl.bindFramebuffer(gl.FRAMEBUFFER, glLayer.framebuffer);
gl.clearColor(0.0, 0.0, 0.0, 1.0);
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
for (const view of pose.views) {
const viewport = glLayer.getViewport(view);
gl.viewport(viewport.x, viewport.y, viewport.width, viewport.height);
const projectionMatrix = view.projectionMatrix;
const viewMatrix = pose.transform.inverse.matrix;
drawScene(gl, programInfo, buffers, projectionMatrix, viewMatrix);
}
}
// 获取手柄输入并更新立方体位置
for (const source of xrSession.inputSources) {
if (source.gamepad) {
const gamepad = source.gamepad;
const xAxis = gamepad.axes[0];
const yAxis = gamepad.axes[1];
cubePositionX += xAxis * 0.01;
cubePositionY += yAxis * 0.01;
}
}
}
// 初始化WebGL和WebXR
if (initWebGL()) {
const programInfo = {
program: initShaderProgram(gl, vsSource, fsSource),
attribLocations: {
vertexPosition: gl.getAttribLocation(initShaderProgram(gl, vsSource, fsSource), 'aVertexPosition'),
vertexColor: gl.getAttribLocation(initShaderProgram(gl, vsSource, fsSource), 'aVertexColor'),
},
uniformLocations: {
projectionMatrix: gl.getUniformLocation(initShaderProgram(gl, vsSource, fsSource), 'uProjectionMatrix'),
modelViewMatrix: gl.getUniformLocation(initShaderProgram(gl, vsSource, fsSource), 'uModelViewMatrix'),
},
};
const buffers = initBuffers(gl);
navigator.xr.isSessionSupported('immersive-vr').then((supported) => {
if (supported) {
console.log('VR session is supported!');
startVR();
} else {
console.log('VR session is not supported.');
}
});
}
</script>
</body>
</html>
这段代码比较长,但主要做了以下几件事:
- 初始化WebGL: 创建WebGL上下文,并设置好视口。
- 初始化着色器: 创建顶点着色器和片元着色器,并编译链接成着色器程序。
- 初始化缓冲区: 创建顶点缓冲区和索引缓冲区,并填充数据。
- 请求VR会话: 调用
navigator.xr.requestSession('immersive-vr')
请求一个VR会话。 - 获取参考空间: 调用
xrSession.requestReferenceSpace('local-floor')
获取一个'local-floor'
参考空间。 - 渲染循环: 在渲染循环中,获取用户头显的姿态,并根据手柄的摇杆状态更新立方体的位置,然后渲染场景。
第八章:注意事项
- 兼容性: WebXR还在发展中,不同浏览器和设备的支持程度可能不同。
- 性能: VR/AR应用对性能要求很高,要尽量优化你的代码,避免卡顿。
- 权限: 浏览器可能会要求用户授权才能访问VR/AR设备。
- 安全: 注意保护用户的隐私,不要滥用VR/AR数据。
第九章:总结
WebXR Device API是WebVR/AR的基石,它让你的网页能够与各种VR/AR设备互动。虽然WebXR的学习曲线可能有点陡峭,但只要你掌握了基本的概念和方法,就能创造出令人惊艳的VR/AR体验。
希望今天的讲座对大家有所帮助! 如果想要更加深入的了解,还需要自己不断去实践和学习。 拜拜!