探讨 WebXR Device API 如何利用 JavaScript 与虚拟现实 (VR) 和增强现实 (AR) 设备交互,并获取设备姿态和输入。

各位观众老爷,大家好!今天咱们来聊聊 WebXR Device API,这玩意儿可是个宝贝,能让你用 JavaScript 驾驭 VR 和 AR 设备,玩转虚拟和现实世界。

WebXR Device API:你的 VR/AR 遥控器

想象一下,你想用浏览器控制一个虚拟现实头盔,或者让你的手机屏幕上叠加一层增强现实图像。WebXR Device API 就是你的遥控器,它提供了一套标准的接口,让你的 JavaScript 代码可以和这些设备“对话”。

开场白:准备工作

在你开始之前,你需要确保:

  • 你的浏览器支持 WebXR。大部分现代浏览器都支持,但最好还是查一下。
  • 你有一个 VR/AR 设备,或者一个支持 WebXR 的模拟器。

第一幕:进入 XR 世界

首先,我们需要获取一个 XRSystem 对象,这是进入 XR 世界的大门。

navigator.xr.isSessionSupported('immersive-vr') // 检查是否支持 VR 会话
  .then((supported) => {
    if (supported) {
      console.log('VR is supported!');
    } else {
      console.log('VR is not supported :(');
    }
  });

navigator.xr.requestSession('immersive-vr') // 请求一个 VR 会话
  .then((session) => {
    console.log('Session started!');
    xrSession = session; // 保存会话对象,供后续使用
    startRendering(session); // 开始渲染
  })
  .catch((error) => {
    console.error('Failed to start session:', error);
  });

这里,navigator.xr 是 WebXR API 的入口。isSessionSupported() 检查是否支持特定的会话类型(比如 'immersive-vr'),requestSession() 尝试请求一个会话。如果成功,你会得到一个 XRSession 对象,这是你和 XR 设备交互的核心。

第二幕:配置舞台

有了会话,下一步是配置渲染。我们需要一个 XRWebGLLayer,它负责把 WebGL 的渲染结果显示到 XR 设备上。

function startRendering(session) {
  const canvas = document.createElement('canvas');
  document.body.appendChild(canvas);

  const gl = canvas.getContext('webgl', { xrCompatible: true }); // 创建 WebGL 上下文,并声明与 XR 兼容
  if (!gl) {
    console.error('Failed to create WebGL context.');
    return;
  }

  session.updateRenderState({
    baseLayer: new XRWebGLLayer(session, gl) // 创建 XRWebGLLayer 并设置到会话中
  });

  session.requestAnimationFrame(render); // 开始渲染循环
}

这段代码创建了一个 Canvas 元素,并获取了一个 WebGL 上下文。关键是 xrCompatible: true,这告诉 WebGL 上下文,它将用于 XR 渲染。然后,我们创建了一个 XRWebGLLayer,并把它设置到会话的渲染状态中。

第三幕:进入渲染循环

现在,我们需要一个渲染循环,不断地更新场景并显示到 XR 设备上。

function render(time, frame) {
  const session = frame.session;
  session.requestAnimationFrame(render); // 请求下一次渲染

  const pose = frame.getViewerPose(referenceSpace); // 获取观察者的姿态
  if (pose) {
    const glLayer = session.renderState.baseLayer;
    const gl = glLayer.context;

    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; // 获取视图矩阵 (实际上应该从 view 中获取)

      // 使用投影矩阵和视图矩阵渲染场景
      renderScene(projectionMatrix, viewMatrix);
    }
  }
}

requestAnimationFrame() 函数请求浏览器在下一次重绘之前调用 render() 函数。在 render() 函数中,我们首先获取观察者的姿态 XRViewerPose。然后,我们遍历所有的 XRView 对象(对于 VR 设备,通常是左右眼各一个),获取每个 View 的投影矩阵和视图矩阵,并使用这些矩阵渲染场景。

关键概念:参考空间 (Reference Space)

getReferenceSpace() 方法用于获取参考空间。参考空间定义了坐标系的原点和方向,所有的姿态都相对于这个空间来表示。

let referenceSpace;

navigator.xr.requestSession('immersive-vr')
  .then((session) => {
    xrSession = session;

    session.requestReferenceSpace('local') // 请求一个本地参考空间
      .then((space) => {
        referenceSpace = space;
        startRendering(session);
      });
  });

WebXR 定义了几种参考空间:

  • 'local': 坐标系的原点在会话开始时固定,可能会随着设备移动而缓慢漂移。
  • 'local-floor': 类似于 'local',但坐标系的原点在地面上。
  • 'bounded-floor': 类似于 'local-floor',但定义了一个边界区域,表示用户可以安全移动的范围。
  • 'unbounded': 坐标系的原点随着设备移动而改变,不会漂移。

选择哪种参考空间取决于你的应用场景。

第四幕:处理输入

VR/AR 设备通常都有控制器,我们需要处理控制器的输入。

xrSession.addEventListener('inputsourceschange', (event) => {
  console.log('Input sources changed:', event.added, event.removed);
});

xrSession.addEventListener('selectstart', (event) => {
  console.log('Select start:', event.inputSource);
});

xrSession.addEventListener('selectend', (event) => {
  console.log('Select end:', event.inputSource);
});

xrSession.addEventListener('squeezestart', (event) => {
  console.log('Squeeze start:', event.inputSource);
});

xrSession.addEventListener('squeezeend', (event) => {
  console.log('Squeeze end:', event.inputSource);
});

inputsourceschange 事件在输入源(比如控制器)添加或移除时触发。selectstartselectend 事件在用户按下和释放“选择”按钮时触发。squeezestartsqueezeend 事件在用户开始和停止挤压控制器时触发。

要获取控制器的姿态和按钮状态,需要在渲染循环中遍历所有的输入源。

function render(time, frame) {
  // ...

  for (const inputSource of session.inputSources) {
    const targetRayPose = frame.getPose(inputSource.targetRaySpace, referenceSpace); // 获取控制器的目标射线姿态
    if (targetRayPose) {
      // 使用控制器的目标射线姿态来更新场景
    }

    if (inputSource.gamepad) {
      const gamepad = inputSource.gamepad;
      const buttons = gamepad.buttons;
      const axes = gamepad.axes;

      // 处理按钮和摇杆输入
      if (buttons[0].pressed) {
        console.log('Button 0 pressed!');
      }

      const xAxis = axes[0]; // X 轴
      const yAxis = axes[1]; // Y 轴
      console.log('X Axis:', xAxis, 'Y Axis:', yAxis);
    }
  }
}

getPose() 方法用于获取输入源的姿态。inputSource.gamepad 属性可以访问 Gamepad API,获取按钮和摇杆的状态。

第五幕:退出 XR 世界

当你不再需要 XR 会话时,应该主动结束它。

xrSession.end()
  .then(() => {
    console.log('Session ended.');
  })
  .catch((error) => {
    console.error('Failed to end session:', error);
  });

WebXR 的秘密武器:Pose 和 Transform

XRFrame.getViewerPose()XRFrame.getPose() 返回的是 XRViewerPoseXRPose 对象,它们包含了位置和方向信息。这些信息以 XRRigidTransform 对象的形式存储。

XRRigidTransform 对象有两个属性:

  • position: 一个 DOMPointReadOnly 对象,表示位置。
  • orientation: 一个 DOMQuaternionReadOnly 对象,表示方向。

你可以使用这些属性来更新场景中的对象的位置和方向。

表格:WebXR API 核心接口

接口 描述
navigator.xr WebXR API 的入口。
XRSystem 代表 XR 系统,可以用来查询设备功能和请求会话。
XRSession 代表一个 XR 会话,是和 XR 设备交互的核心。
XRFrame 代表一个 XR 渲染帧,包含了渲染所需的信息,比如观察者姿态和输入源状态。
XRReferenceSpace 定义坐标系的原点和方向,所有的姿态都相对于这个空间来表示。
XRWebGLLayer 负责把 WebGL 的渲染结果显示到 XR 设备上。
XRViewerPose 代表观察者的姿态,包含了位置和方向信息。
XRPose 代表一个对象的姿态,包含了位置和方向信息。
XRRigidTransform 代表一个刚性变换,包含了位置和方向信息。
XRInputSource 代表一个输入源,比如控制器。

实战演练:创建一个简单的 VR 场景

为了让你更好地理解 WebXR,我们来创建一个简单的 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>
  <script>
    let xrSession = null;
    let gl = null;
    let cubeRotation = 0.0;
    let cubeBuffer;
    let cubeIndexBuffer;
    let shaderProgram;
    let referenceSpace;

    function main() {
      navigator.xr.isSessionSupported('immersive-vr')
        .then((supported) => {
          if (supported) {
            console.log('VR is supported!');
            document.body.addEventListener('click', startVR); // 添加点击事件,开始 VR 会话
          } else {
            console.log('VR is not supported :(');
          }
        });
    }

    function startVR() {
        navigator.xr.requestSession('immersive-vr')
          .then((session) => {
            console.log('Session started!');
            xrSession = session;
            session.onend = () => {
                console.log('Session ended');
                xrSession = null;
            };

            session.requestReferenceSpace('local')
              .then((space) => {
                referenceSpace = space;
                startRendering(session);
              });
          })
          .catch((error) => {
            console.error('Failed to start session:', error);
          });
    }

    function startRendering(session) {
      const canvas = document.createElement('canvas');
      document.body.appendChild(canvas);

      gl = canvas.getContext('webgl', { xrCompatible: true });
      if (!gl) {
        console.error('Failed to create WebGL context.');
        return;
      }

      session.updateRenderState({
        baseLayer: new XRWebGLLayer(session, gl)
      });

      initBuffers();
      initShaderProgram();

      session.requestAnimationFrame(render);
    }

    function initBuffers() {
      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,
      ];

      cubeBuffer = gl.createBuffer();
      gl.bindBuffer(gl.ARRAY_BUFFER, cubeBuffer);
      gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(positions), gl.STATIC_DRAW);

      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
      ];

      cubeIndexBuffer = gl.createBuffer();
      gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, cubeIndexBuffer);
      gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, new Uint16Array(indices), gl.STATIC_DRAW);
    }

    function initShaderProgram() {
      const vsSource = `
        attribute vec4 aVertexPosition;
        uniform mat4 uProjectionMatrix;
        uniform mat4 uModelViewMatrix;
        void main() {
          gl_Position = uProjectionMatrix * uModelViewMatrix * aVertexPosition;
        }
      `;

      const fsSource = `
        void main() {
          gl_FragColor = vec4(1.0, 1.0, 1.0, 1.0); // White color
        }
      `;

      const vertexShader = gl.createShader(gl.VERTEX_SHADER);
      gl.shaderSource(vertexShader, vsSource);
      gl.compileShader(vertexShader);

      const fragmentShader = gl.createShader(gl.FRAGMENT_SHADER);
      gl.shaderSource(fragmentShader, fsSource);
      gl.compileShader(fragmentShader);

      shaderProgram = gl.createProgram();
      gl.attachShader(shaderProgram, vertexShader);
      gl.attachShader(shaderProgram, fragmentShader);
      gl.linkProgram(shaderProgram);

      gl.useProgram(shaderProgram);

      shaderProgram.vertexPositionAttribute = gl.getAttribLocation(shaderProgram, "aVertexPosition");
      gl.enableVertexAttribArray(shaderProgram.vertexPositionAttribute);
      shaderProgram.projectionMatrixUniform = gl.getUniformLocation(shaderProgram, "uProjectionMatrix");
      shaderProgram.modelViewMatrixUniform = gl.getUniformLocation(shaderProgram, "uModelViewMatrix");
    }

    function render(time, frame) {
      const session = frame.session;
      session.requestAnimationFrame(render);

      const pose = frame.getViewerPose(referenceSpace);
      if (pose) {
        const glLayer = session.renderState.baseLayer;
        const gl = glLayer.context;

        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);
        gl.enable(gl.DEPTH_TEST);

        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; // 从 pose 获取 transform

          drawCube(projectionMatrix, viewMatrix);
        }

        cubeRotation += 0.01; // Rotate the cube
      }
    }

    function drawCube(projectionMatrix, viewMatrix) {
      const modelViewMatrix = mat4.create();
      mat4.translate(modelViewMatrix, modelViewMatrix, [0.0, 0.0, -6.0]); // Move the cube back
      mat4.rotate(modelViewMatrix, modelViewMatrix, cubeRotation, [0, 1, 0]); // Rotate the cube

      gl.bindBuffer(gl.ARRAY_BUFFER, cubeBuffer);
      gl.vertexAttribPointer(shaderProgram.vertexPositionAttribute, 3, gl.FLOAT, false, 0, 0);

      gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, cubeIndexBuffer);

      gl.uniformMatrix4fv(shaderProgram.projectionMatrixUniform, false, projectionMatrix);
      gl.uniformMatrix4fv(shaderProgram.modelViewMatrixUniform, false, modelViewMatrix);

      gl.drawElements(gl.TRIANGLES, 36, gl.UNSIGNED_SHORT, 0);
    }

    window.onload = main;
  </script>
  <script src="https://cdnjs.cloudflare.com/ajax/libs/gl-matrix/2.8.1/gl-matrix-min.js" integrity="sha512-zhHQRCI3+aPfg6CpqALaaGyyip6IaLG0r0lNHqzEqvQWXshmR6mbcCtSs8Jx+bjXPWHvAqN1oKWO9iseP1Ts+Q==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
</body>
</html>

这个例子使用了 gl-matrix 库来进行矩阵运算。代码创建了一个简单的 WebGL 场景,包含一个旋转的立方体。当你点击页面时,会尝试启动 VR 会话,并将立方体渲染到 VR 设备上。

总结

WebXR Device API 是一把打开虚拟现实和增强现实大门的钥匙。它提供了一套标准的接口,让你可以用 JavaScript 和 VR/AR 设备交互,创造出令人惊叹的体验。虽然入门需要一些耐心,但一旦掌握了基本概念,你就可以自由地探索 XR 世界的无限可能。

希望今天的讲座对你有所帮助!下次再见!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注