WebXR交互式体验:VR/AR应用开发实战
大家好,今天我们来深入探讨WebXR API,并学习如何利用它来构建沉浸式的VR和AR应用。WebXR是一个开放的Web标准,它允许我们在浏览器中访问VR和AR设备,打破了以往VR/AR开发需要依赖原生应用的壁垒。
1. WebXR API 概览
WebXR API 提供了一套用于创建和管理XR会话的核心接口。它主要包含以下几个关键概念:
- XRSystem: XR系统的入口点,用于请求XR会话。
- XRSession: 代表一个活动的XR会话,管理设备的追踪、渲染和输入。
- XRReferenceSpace: 定义XR空间中的坐标系,用于定位虚拟物体和用户。
- XRFrame: 代表一个渲染帧,包含设备姿态信息和可用于渲染的数据。
- XRViewerPose: 代表用户的视角信息,包括位置和朝向。
- XRInputSource: 代表一个输入设备,例如手柄或触摸屏。
2. 创建一个基本的WebXR场景
让我们从一个最简单的例子开始,创建一个可以在VR头显中显示的场景。
2.1 HTML 结构
首先,我们需要一个基本的HTML文件:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>WebXR Demo</title>
<style>
body { margin: 0; overflow: hidden; }
canvas { width: 100%; height: 100%; display: block; }
</style>
</head>
<body>
<canvas id="xr-canvas"></canvas>
<script src="script.js"></script>
</body>
</html>
这里定义了一个canvas元素,用于渲染我们的WebXR场景,并引入了一个JavaScript文件 script.js
,用于编写WebXR逻辑。
2.2 JavaScript 代码
接下来,在 script.js
文件中,我们将编写核心的WebXR代码:
let xrSession = null;
let xrRefSpace = null;
let gl = null;
let canvas = null;
async function initXR() {
canvas = document.getElementById('xr-canvas');
// 1. 检查 WebXR 是否可用
if (navigator.xr) {
// 2. 请求 WebXR 支持
try {
const supported = await navigator.xr.isSessionSupported('immersive-vr'); // 或者 'immersive-ar'
if (supported) {
// 3. 请求 XR 会话
const xrButton = document.createElement('button');
xrButton.textContent = 'Enter VR';
document.body.appendChild(xrButton);
xrButton.addEventListener('click', async () => {
await startXR();
});
} else {
console.log("VR not supported");
}
} catch (e) {
console.error("Error checking VR support:", e);
}
} else {
console.log("WebXR not available");
}
}
async function startXR() {
try {
// 4. 请求 XR 会话
xrSession = await navigator.xr.requestSession('immersive-vr'); // 或者 'immersive-ar'
xrSession.addEventListener('end', onSessionEnded);
// 5. 创建 WebGL 上下文
gl = canvas.getContext('webgl', { xrCompatible: true });
if (!gl) {
gl = canvas.getContext('webgl2', { xrCompatible: true }); // 尝试 WebGL2
if (!gl) {
throw new Error("Unable to get WebGL context");
}
}
// 6. 配置 XR WebGL 层
xrSession.updateRenderState({
baseLayer: new XRWebGLLayer(xrSession, gl)
});
// 7. 获取参考空间
xrRefSpace = await xrSession.requestReferenceSpace('local'); // 或者 'local-floor', 'bounded-floor', 'unbounded'
// 8. 启动渲染循环
xrSession.requestAnimationFrame(render);
} catch (e) {
console.error("Error starting XR:", e);
}
}
function render(time, frame) {
if (!xrSession) {
return;
}
// 9. 获取 XR 姿态
const pose = frame.getViewerPose(xrRefSpace);
if (pose) {
// 10. 清除 WebGL 缓冲区
gl.clearColor(0.8, 0.8, 0.8, 1.0);
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
// 11. 渲染场景(这里只是一个占位符,需要添加实际的渲染逻辑)
const layer = xrSession.renderState.baseLayer;
gl.viewport(0, 0, layer.framebufferWidth, layer.framebufferHeight);
// 渲染每个视角
for (const view of pose.views) {
const viewport = layer.getViewport(view);
gl.viewport(viewport.x, viewport.y, viewport.width, viewport.height);
// 获取投影矩阵和视图矩阵
const projectionMatrix = view.projectionMatrix;
const viewMatrix = view.transform.inverse.matrix;
// 将投影矩阵和视图矩阵传递给着色器(这里只是一个占位符,需要添加实际的着色器代码)
// ...
}
}
// 12. 请求下一帧
xrSession.requestAnimationFrame(render);
}
function onSessionEnded(event) {
xrSession = null;
gl = null;
}
initXR();
代码解释:
- 检查WebXR是否可用:
navigator.xr
用于检测浏览器是否支持WebXR API。 - 请求WebXR支持:
navigator.xr.isSessionSupported('immersive-vr')
检查设备是否支持沉浸式VR会话。'immersive-ar'
用于AR。 - 请求XR会话:
navigator.xr.requestSession('immersive-vr')
请求一个VR会话。用户可能会被要求允许访问VR设备。 - 创建WebGL上下文:
canvas.getContext('webgl', { xrCompatible: true })
创建一个WebGL上下文,并设置xrCompatible: true
,以便WebGL可以与WebXR协同工作。 - 配置XR WebGL层:
xrSession.updateRenderState({ baseLayer: new XRWebGLLayer(xrSession, gl) })
创建一个XRWebGLLayer,用于将WebGL渲染到VR头显中。 - 获取参考空间:
xrSession.requestReferenceSpace('local')
请求一个参考空间,用于定义场景的坐标系。local
表示相对于用户起始位置的坐标系。其他选项包括local-floor
(地面),bounded-floor
(有边界的地面),和unbounded
(无限制)。 - 启动渲染循环:
xrSession.requestAnimationFrame(render)
启动一个循环,不断渲染场景。 - 获取XR姿态:
frame.getViewerPose(xrRefSpace)
获取用户的视角信息,包括位置和朝向。 - 渲染场景: 在
render
函数中,我们清除WebGL缓冲区,并渲染场景。 这里只是一个占位符,需要添加实际的渲染逻辑,例如加载3D模型和应用材质。 - 渲染每个视角: VR头显通常需要为每个眼睛渲染一个图像,所以我们需要遍历
pose.views
并为每个视角设置视口和矩阵。 - 请求下一帧:
xrSession.requestAnimationFrame(render)
再次调用requestAnimationFrame
,以便在下一帧更新场景。 - 会话结束处理:
onSessionEnded
函数处理会话结束的情况。
2.3 运行代码
将HTML和JavaScript文件放在同一个目录下,然后在支持WebXR的浏览器中打开HTML文件。如果一切正常,你应该看到一个按钮,点击它可以进入VR模式。虽然现在看到的只是一个空白的灰色屏幕,但它已经是一个运行中的WebXR应用了。
3. 添加交互功能
光有一个静态的场景是不够的,我们需要添加交互功能,让用户可以与虚拟环境进行互动。
3.1 输入源 (XRInputSource)
XRInputSource
代表一个输入设备,例如手柄或触摸屏。我们可以通过监听 selectstart
和 selectend
事件来检测用户的输入。
3.2 修改代码
在 startXR
函数中,添加以下代码来监听输入事件:
xrSession.addEventListener('selectstart', onSelectStart);
xrSession.addEventListener('selectend', onSelectEnd);
然后,定义 onSelectStart
和 onSelectEnd
函数:
function onSelectStart(event) {
const inputSource = event.inputSource;
console.log("Select Start", inputSource);
// 在这里处理选择开始事件,例如创建一个物体
}
function onSelectEnd(event) {
const inputSource = event.inputSource;
console.log("Select End", inputSource);
// 在这里处理选择结束事件,例如删除一个物体
}
3.3 获取输入源的姿态
我们可以使用 frame.getPose(inputSource.targetRaySpace, xrRefSpace)
来获取输入源的姿态信息。targetRaySpace
代表输入源发出的射线,可以用于检测用户指向的方向。
示例:使用手柄创建方块
我们创建一个简单的场景:当用户按下手柄上的扳机时,在手柄指向的方向创建一个方块。
首先,我们需要一个简单的WebGL着色器。这里使用一个简单的顶点着色器和片元着色器:
顶点着色器 (vertexShader.glsl):
attribute vec3 aVertexPosition;
attribute vec4 aVertexColor;
uniform mat4 uModelViewMatrix;
uniform mat4 uProjectionMatrix;
varying lowp vec4 vColor;
void main(void) {
gl_Position = uProjectionMatrix * uModelViewMatrix * vec4(aVertexPosition, 1.0);
vColor = aVertexColor;
}
片元着色器 (fragmentShader.glsl):
varying lowp vec4 vColor;
void main(void) {
gl_FragColor = vColor;
}
然后,修改 script.js
文件:
// ... (之前的代码)
let cubeBuffer = null;
let cubeColorBuffer = null;
let shaderProgram = null;
let projectionMatrixUniform = null;
let modelViewMatrixUniform = null;
async function startXR() {
// ... (之前的代码)
initShaders(); // 初始化着色器
initBuffers(); // 初始化缓冲区
xrSession.addEventListener('selectstart', onSelectStart);
xrSession.addEventListener('selectend', onSelectEnd);
}
function initShaders() {
const vertexShaderSource = `
attribute vec3 aVertexPosition;
attribute vec4 aVertexColor;
uniform mat4 uModelViewMatrix;
uniform mat4 uProjectionMatrix;
varying lowp vec4 vColor;
void main(void) {
gl_Position = uProjectionMatrix * uModelViewMatrix * vec4(aVertexPosition, 1.0);
vColor = aVertexColor;
}
`;
const fragmentShaderSource = `
varying lowp vec4 vColor;
void main(void) {
gl_FragColor = vColor;
}
`;
const vertexShader = gl.createShader(gl.VERTEX_SHADER);
gl.shaderSource(vertexShader, vertexShaderSource);
gl.compileShader(vertexShader);
if (!gl.getShaderParameter(vertexShader, gl.COMPILE_STATUS)) {
console.error('An error occurred compiling the shaders: ' + gl.getShaderInfoLog(vertexShader));
gl.deleteShader(vertexShader);
return null;
}
const fragmentShader = gl.createShader(gl.FRAGMENT_SHADER);
gl.shaderSource(fragmentShader, fragmentShaderSource);
gl.compileShader(fragmentShader);
if (!gl.getShaderParameter(fragmentShader, gl.COMPILE_STATUS)) {
console.error('An error occurred compiling the shaders: ' + gl.getShaderInfoLog(fragmentShader));
gl.deleteShader(fragmentShader);
return null;
}
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;
}
gl.useProgram(shaderProgram);
shaderProgram.vertexPositionAttribute = gl.getAttribLocation(shaderProgram, "aVertexPosition");
gl.enableVertexAttribArray(shaderProgram.vertexPositionAttribute);
shaderProgram.vertexColorAttribute = gl.getAttribLocation(shaderProgram, "aVertexColor");
gl.enableVertexAttribArray(shaderProgram.vertexColorAttribute);
projectionMatrixUniform = gl.getUniformLocation(shaderProgram, "uProjectionMatrix");
modelViewMatrixUniform = gl.getUniformLocation(shaderProgram, "uModelViewMatrix");
}
function initBuffers() {
// 方块的顶点坐标
const vertices = [
-0.05, -0.05, 0.05,
0.05, -0.05, 0.05,
0.05, 0.05, 0.05,
-0.05, 0.05, 0.05,
-0.05, -0.05, -0.05,
-0.05, 0.05, -0.05,
0.05, 0.05, -0.05,
0.05, -0.05, -0.05,
-0.05, 0.05, -0.05,
-0.05, 0.05, 0.05,
0.05, 0.05, 0.05,
0.05, 0.05, -0.05,
-0.05, -0.05, -0.05,
0.05, -0.05, -0.05,
0.05, -0.05, 0.05,
-0.05, -0.05, 0.05,
0.05, -0.05, -0.05,
0.05, 0.05, -0.05,
0.05, 0.05, 0.05,
0.05, -0.05, 0.05,
-0.05, -0.05, -0.05,
-0.05, -0.05, 0.05,
-0.05, 0.05, 0.05,
-0.05, 0.05, -0.05,
];
cubeBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, cubeBuffer);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(vertices), gl.STATIC_DRAW);
// 方块的颜色
const colors = [
1.0, 0.0, 0.0, 1.0, // 前面
1.0, 0.0, 0.0, 1.0,
1.0, 0.0, 0.0, 1.0,
1.0, 0.0, 0.0, 1.0,
0.0, 1.0, 0.0, 1.0, // 后面
0.0, 1.0, 0.0, 1.0,
0.0, 1.0, 0.0, 1.0,
0.0, 1.0, 0.0, 1.0,
0.0, 0.0, 1.0, 1.0, // 上面
0.0, 0.0, 1.0, 1.0,
0.0, 0.0, 1.0, 1.0,
0.0, 0.0, 1.0, 1.0,
1.0, 1.0, 0.0, 1.0, // 下面
1.0, 1.0, 0.0, 1.0,
1.0, 1.0, 0.0, 1.0,
1.0, 1.0, 0.0, 1.0,
1.0, 0.0, 1.0, 1.0, // 右面
1.0, 0.0, 1.0, 1.0,
1.0, 0.0, 1.0, 1.0,
1.0, 0.0, 1.0, 1.0,
0.0, 1.0, 1.0, 1.0, // 左面
0.0, 1.0, 1.0, 1.0,
0.0, 1.0, 1.0, 1.0,
0.0, 1.0, 1.0, 1.0
];
cubeColorBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, cubeColorBuffer);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(colors), gl.STATIC_DRAW);
}
const cubes = []; // 存储创建的方块
function onSelectStart(event) {
const inputSource = event.inputSource;
const frame = event.frame;
// 获取手柄的姿态
const pose = frame.getPose(inputSource.targetRaySpace, xrRefSpace);
if (pose) {
// 创建一个方块
const cube = {
position: [pose.transform.position.x, pose.transform.position.y, pose.transform.position.z],
rotation: [0, 0, 0], // 示例:没有旋转
scale: [1, 1, 1] // 示例:没有缩放
};
cubes.push(cube);
}
}
function onSelectEnd(event) {
// 这里可以添加删除方块的逻辑
}
function drawCube(projectionMatrix, modelViewMatrix, cube) {
// 设置投影矩阵
gl.uniformMatrix4fv(projectionMatrixUniform, false, projectionMatrix);
// 创建模型矩阵
const modelMatrix = mat4.create(); // 使用gl-matrix库创建矩阵
mat4.translate(modelMatrix, modelMatrix, cube.position); // 平移
mat4.rotateX(modelMatrix, modelMatrix, cube.rotation[0]); // 旋转
mat4.rotateY(modelMatrix, modelMatrix, cube.rotation[1]);
mat4.rotateZ(modelMatrix, modelMatrix, cube.rotation[2]);
mat4.scale(modelMatrix, modelMatrix, cube.scale); // 缩放
// 计算模型视图矩阵
const mvMatrix = mat4.create();
mat4.multiply(mvMatrix, modelViewMatrix, modelMatrix);
// 设置模型视图矩阵
gl.uniformMatrix4fv(modelViewMatrixUniform, false, mvMatrix);
// 设置顶点缓冲区
gl.bindBuffer(gl.ARRAY_BUFFER, cubeBuffer);
gl.vertexAttribPointer(shaderProgram.vertexPositionAttribute, 3, gl.FLOAT, false, 0, 0);
// 设置颜色缓冲区
gl.bindBuffer(gl.ARRAY_BUFFER, cubeColorBuffer);
gl.vertexAttribPointer(shaderProgram.vertexColorAttribute, 4, gl.FLOAT, false, 0, 0);
// 绘制方块
gl.drawArrays(gl.TRIANGLE_FAN, 0, 4);
gl.drawArrays(gl.TRIANGLE_FAN, 4, 4);
gl.drawArrays(gl.TRIANGLE_FAN, 8, 4);
gl.drawArrays(gl.TRIANGLE_FAN, 12, 4);
gl.drawArrays(gl.TRIANGLE_FAN, 16, 4);
gl.drawArrays(gl.TRIANGLE_FAN, 20, 4);
}
function render(time, frame) {
if (!xrSession) {
return;
}
const pose = frame.getViewerPose(xrRefSpace);
if (pose) {
gl.clearColor(0.8, 0.8, 0.8, 1.0);
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
gl.enable(gl.DEPTH_TEST); // 启用深度测试
const layer = xrSession.renderState.baseLayer;
gl.viewport(0, 0, layer.framebufferWidth, layer.framebufferHeight);
for (const view of pose.views) {
const viewport = layer.getViewport(view);
gl.viewport(viewport.x, viewport.y, viewport.width, viewport.height);
const projectionMatrix = view.projectionMatrix;
const viewMatrix = view.transform.inverse.matrix;
// 绘制所有方块
for (const cube of cubes) {
drawCube(projectionMatrix, viewMatrix, cube);
}
}
}
xrSession.requestAnimationFrame(render);
}
// ... (之前的代码)
代码解释:
- 初始化着色器和缓冲区:
initShaders
和initBuffers
函数用于初始化WebGL着色器和缓冲区,以便绘制方块。 - 创建方块:
onSelectStart
函数获取手柄的姿态信息,并使用该信息创建一个方块对象,将其添加到cubes
数组中。 - 绘制方块:
drawCube
函数使用WebGL绘制一个方块。它接收投影矩阵、视图矩阵和方块对象作为参数。 - 深度测试: 启用深度测试
gl.enable(gl.DEPTH_TEST);
确保方块正确地遮挡其他物体。
需要注意的是,以上代码使用了gl-matrix库进行矩阵运算,需要在HTML文件中引入该库。
<script src="https://cdnjs.cloudflare.com/ajax/libs/gl-matrix/2.8.1/gl-matrix-min.js"></script>
现在,当你进入VR模式并按下手柄上的扳机时,应该可以在手柄指向的方向看到一个方块。
4. AR 应用开发
WebXR API 也支持AR应用开发。与VR不同的是,AR需要访问设备的摄像头,并将虚拟物体叠加到现实世界中。
4.1 修改会话模式
要创建一个AR应用,需要将 requestSession
函数的参数改为 'immersive-ar'
:
xrSession = await navigator.xr.requestSession('immersive-ar');
4.2 平面检测 (Plane Detection)
在AR中,一个常见的需求是检测现实世界中的平面,例如桌面或地面。WebXR API 提供了一个 XREstimatedPlane
接口,用于表示检测到的平面。
4.3 示例:放置虚拟物体在平面上
以下是一个简单的示例,演示如何使用 XREstimatedPlane
将一个虚拟物体放置在检测到的平面上:
// ... (之前的代码)
let planes = []; // 存储检测到的平面
async function startXR() {
// ... (之前的代码)
// 请求平面检测功能
const planeDetectionFeatures = {
requiredFeatures: ['plane-detection']
};
xrSession = await navigator.xr.requestSession('immersive-ar', planeDetectionFeatures);
xrSession.addEventListener('planesadded', onPlanesAdded);
xrSession.addEventListener('planesupdated', onPlanesUpdated);
xrSession.addEventListener('planesremoved', onPlanesRemoved);
// ... (之前的代码)
}
function onPlanesAdded(event) {
const addedPlanes = event.planes;
for (const plane of addedPlanes) {
planes.push(plane);
console.log("Plane added", plane);
}
}
function onPlanesUpdated(event) {
const updatedPlanes = event.planes;
for (const plane of updatedPlanes) {
// 更新平面的信息
console.log("Plane updated", plane);
}
}
function onPlanesRemoved(event) {
const removedPlanes = event.planes;
for (const plane of removedPlanes) {
// 移除平面
console.log("Plane removed", plane);
}
}
function render(time, frame) {
if (!xrSession) {
return;
}
const pose = frame.getViewerPose(xrRefSpace);
if (pose) {
gl.clearColor(0.0, 0.0, 0.0, 1.0); // AR中通常使用黑色背景
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
gl.enable(gl.DEPTH_TEST);
const layer = xrSession.renderState.baseLayer;
gl.viewport(0, 0, layer.framebufferWidth, layer.framebufferHeight);
for (const view of pose.views) {
const viewport = layer.getViewport(view);
gl.viewport(viewport.x, viewport.y, viewport.width, viewport.height);
const projectionMatrix = view.projectionMatrix;
const viewMatrix = view.transform.inverse.matrix;
// 绘制所有检测到的平面 (仅作为示例,实际应用中需要更复杂的逻辑)
for (const plane of planes) {
// 获取平面的姿态
const planePose = frame.getPose(plane.planeSpace, xrRefSpace);
if (planePose) {
// 创建一个变换矩阵,将物体放置在平面上
const modelMatrix = mat4.create();
mat4.fromRotationTranslation(modelMatrix, planePose.transform.orientation, planePose.transform.position);
mat4.scale(modelMatrix, modelMatrix, [0.5, 0.5, 0.5]); // 缩小平面
// 计算模型视图矩阵
const mvMatrix = mat4.create();
mat4.multiply(mvMatrix, viewMatrix, modelMatrix);
// 设置投影矩阵和模型视图矩阵
gl.uniformMatrix4fv(projectionMatrixUniform, false, projectionMatrix);
gl.uniformMatrix4fv(modelViewMatrixUniform, false, mvMatrix);
// 绘制平面 (这里简单地绘制一个矩形)
gl.bindBuffer(gl.ARRAY_BUFFER, cubeBuffer);
gl.vertexAttribPointer(shaderProgram.vertexPositionAttribute, 3, gl.FLOAT, false, 0, 0);
gl.bindBuffer(gl.ARRAY_BUFFER, cubeColorBuffer);
gl.vertexAttribPointer(shaderProgram.vertexColorAttribute, 4, gl.FLOAT, false, 0, 0);
gl.drawArrays(gl.TRIANGLE_FAN, 0, 4);
}
}
// 绘制所有方块
for (const cube of cubes) {
drawCube(projectionMatrix, viewMatrix, cube);
}
}
}
xrSession.requestAnimationFrame(render);
}
// ... (之前的代码)
代码解释:
- 请求平面检测功能: 在
requestSession
函数中,我们添加了plane-detection
功能,以便启用平面检测。 - 监听平面事件: 我们监听
planesadded
、planesupdated
和planesremoved
事件,以便获取检测到的平面的信息。 - 绘制平面: 在
render
函数中,我们遍历planes
数组,并使用frame.getPose
获取每个平面的姿态信息。然后,我们创建一个变换矩阵,将一个矩形放置在平面上。
重要提示: AR 功能需要设备的支持。不是所有设备都支持平面检测。
5. 优化 WebXR 体验
WebXR 应用的性能至关重要,因为它直接影响用户的沉浸感。以下是一些优化WebXR体验的技巧:
- 减少绘制调用 (Draw Calls): 尽量将多个物体合并到一个绘制调用中。
- 使用低多边形模型: 减少模型的复杂度可以显著提高性能。
- 优化着色器: 复杂的着色器会降低性能。尽量使用简单的着色器,并避免不必要的计算。
- 使用纹理压缩: 纹理压缩可以减少纹理的内存占用和加载时间。
- 避免内存泄漏: 及时释放不再使用的资源。
- 使用性能分析工具: 使用浏览器的性能分析工具来识别性能瓶颈。
- 使用LOD(Level of Detail)技术: 根据物体距离摄像头的远近,动态调整模型的精细度。 距离近时使用高精度模型,距离远时使用低精度模型。
6. WebXR 开发的挑战与未来
WebXR 仍然是一个快速发展的技术,面临着一些挑战:
- 兼容性: 不同的浏览器和设备对WebXR API的支持程度可能不同。
- 性能: WebXR 应用的性能要求很高,需要进行精细的优化。
- 内容创建: 创建高质量的WebXR内容需要专业的技能和工具。
然而,WebXR 的未来是光明的。随着技术的不断成熟,WebXR 将会成为VR/AR应用开发的重要平台。
快速构建沉浸式体验
通过WebXR API,我们可以轻松构建交互式VR/AR应用,将虚拟世界与现实世界融合。