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

各位靓仔靓女,咱们今天来聊聊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.');
}

这段代码做了以下几件事:

  1. 检查支持性: 首先用navigator.xr.isSessionSupported('immersive-vr')检查浏览器是否支持VR会话。'immersive-ar'则代表AR会话。
  2. 请求会话: 调用navigator.xr.requestSession('immersive-vr')请求一个VR会话。 'immersive-vr'告诉浏览器,你想开启沉浸式的VR体验。 还可以是'immersive-ar'
  3. 处理成功: 如果会话创建成功,onSessionStarted()函数会被调用,进行后续的处理。
  4. 处理失败: 如果创建失败,catch语句会捕获错误,并打印到控制台。
  5. 绑定WebGL上下文: 创建一个canvas元素,并获取WebGL上下文,然后通过xrSession.updateRenderState()将WebGL上下文与XR会话绑定。
  6. 请求参考空间: 通过xrSession.requestReferenceSpace('local')请求一个参考空间,这里我们请求的是'local'参考空间,它表示以用户为中心的本地坐标系。
  7. 开始渲染循环: 通过xrSession.requestAnimationFrame(render)开始渲染循环,render函数会在每一帧被调用,用于渲染VR/AR场景。
  8. 监听会话结束事件: 监听'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);
    }
  }
}

这段代码做了以下几件事:

  1. 请求下一帧: 调用xrSession.requestAnimationFrame(render)请求下一帧的渲染。
  2. 获取姿态: 调用frame.getViewerPose(xrRefSpace)获取用户当前的姿态(位置和方向)。
  3. 绑定帧缓冲: 获取XRWebGLLayer对象,并将其帧缓冲绑定到WebGL上下文。
  4. 清空缓冲: 清空颜色缓冲和深度缓冲。
  5. 遍历视图: 遍历pose.views数组,获取每个视图的视口、投影矩阵和视图矩阵。
  6. 设置视口: 调用gl.viewport()设置WebGL视口。
  7. 渲染场景: 调用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);
      }
    }
  }
}

这段代码做了以下几件事:

  1. 遍历输入源: 遍历xrSession.inputSources数组,获取所有输入源的信息。
  2. 检查手柄: 判断输入源是否是手柄。
  3. 获取手柄状态: 获取手柄的按钮和摇杆状态。
  4. 获取按钮状态: 检查按钮是否被按下。
  5. 获取摇杆状态: 获取摇杆的X轴和Y轴的值。
  6. 获取手柄姿态: 如果存在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>

这段代码比较长,但主要做了以下几件事:

  1. 初始化WebGL: 创建WebGL上下文,并设置好视口。
  2. 初始化着色器: 创建顶点着色器和片元着色器,并编译链接成着色器程序。
  3. 初始化缓冲区: 创建顶点缓冲区和索引缓冲区,并填充数据。
  4. 请求VR会话: 调用navigator.xr.requestSession('immersive-vr')请求一个VR会话。
  5. 获取参考空间: 调用xrSession.requestReferenceSpace('local-floor')获取一个'local-floor'参考空间。
  6. 渲染循环: 在渲染循环中,获取用户头显的姿态,并根据手柄的摇杆状态更新立方体的位置,然后渲染场景。

第八章:注意事项

  • 兼容性: WebXR还在发展中,不同浏览器和设备的支持程度可能不同。
  • 性能: VR/AR应用对性能要求很高,要尽量优化你的代码,避免卡顿。
  • 权限: 浏览器可能会要求用户授权才能访问VR/AR设备。
  • 安全: 注意保护用户的隐私,不要滥用VR/AR数据。

第九章:总结

WebXR Device API是WebVR/AR的基石,它让你的网页能够与各种VR/AR设备互动。虽然WebXR的学习曲线可能有点陡峭,但只要你掌握了基本的概念和方法,就能创造出令人惊艳的VR/AR体验。

希望今天的讲座对大家有所帮助! 如果想要更加深入的了解,还需要自己不断去实践和学习。 拜拜!

发表回复

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