运动、方向和动作:一个 WebXR 示例

在本文中,我们将利用我们在之前的 WebXR 教程系列文章中介绍的信息来构建一个示例,该示例将围绕一个旋转的立方体进行动画,用户可以使用 VR 头显、键盘和/或鼠标在该立方体周围自由移动。这将有助于巩固您对 3D 图形和 VR 几何原理的理解,并帮助确保您理解 XR 渲染期间使用的函数和数据如何协同工作。

图:此示例运行时的屏幕截图 示例屏幕截图,显示用户可以四处移动的纹理立方体

此示例的核心——旋转、纹理、带光照的立方体——取自我们的 WebGL 教程系列;即该系列的倒数第二篇文章,涵盖了WebGL 中的光照

在阅读本文及随附的源代码时,请记住 3D 头显的显示屏是一个被分成两半的单一屏幕。屏幕的左侧只能被左眼看到,而右侧只能被右眼看到。渲染场景以实现沉浸式呈现需要多次渲染场景——一次从每只眼睛的角度渲染。

渲染左眼时,XRWebGLLayer视口 配置为限制绘制到绘图表面的左侧。相反,渲染右眼时,视口设置为限制绘制到表面的右侧。

此示例通过在屏幕上显示画布来演示这一点,即使使用 XR 设备以沉浸式显示呈现场景也是如此。

依赖项

虽然在本示例中我们不会依赖任何 3D 图形框架,例如three.js 或类似框架,但我们确实使用了glMatrix 库进行矩阵数学运算,我们之前在其他示例中也使用过它。此示例还导入了由沉浸式 Web 工作组维护的WebXR polyfill,该工作组负责 WebXR API 的规范。通过导入此 polyfill,我们允许示例在许多尚未实现 WebXR 的浏览器上运行,并且我们消除了在 WebXR 规范的这些仍然有些实验性的日子里发生的任何与规范的短暂偏差。

选项

此示例有一些选项,您可以通过在浏览器中加载它之前调整常量的值来配置这些选项。代码如下所示

js
const xRotationDegreesPerSecond = 25;
const yRotationDegreesPerSecond = 15;
const zRotationDegreesPerSecond = 35;
const enableRotation = true;
const allowMouseRotation = true;
const allowKeyboardMotion = true;
const enableForcePolyfill = false;
//const SESSION_TYPE = "immersive-vr";
const SESSION_TYPE = "inline";
const MOUSE_SPEED = 0.003;
xRotationDegreesPerSecond

每秒围绕 X 轴旋转的角度数。

yRotationDegreesPerSecond

每秒围绕 Y 轴旋转的角度数。

zRotationDegreesPerSecond

每秒围绕 Z 轴旋转的角度数。

enableRotation

一个布尔值,指示是否启用立方体的旋转。

allowMouseRotation

如果为true,则可以使用鼠标来俯仰和偏航视角。

allowKeyboardMotion

如果为true,则 W、A、S 和 D 键将分别使观察者向上、向左、向下和向右移动,而向上和向下箭头键将使观察者向前和向后移动。如果为false,则仅允许 XR 设备更改视图。

enableForcePolyfill

如果此布尔值为true,则即使浏览器实际支持 WebXR,示例也将尝试使用 WebXR polyfill。如果为false,则仅当浏览器未实现navigator.xr 时才使用 polyfill。

SESSION_TYPE

要创建的 XR 会话类型:inline 用于在文档上下文中呈现的内联会话,immersive-vr 用于将场景呈现到沉浸式 VR 头显。

MOUSE_SPEED

用于缩放用于俯仰和偏航控制的鼠标输入的乘数。

MOVE_DISTANCE

响应用于在场景中移动观察者的任何键移动的距离。

注意:此示例始终显示其渲染的内容,即使使用immersive-vr 模式也是如此。这使您可以比较两种模式之间的任何渲染差异,并使您即使没有头显也可以看到沉浸模式的输出。

设置和实用程序函数

接下来,我们声明应用程序中使用的变量和常量,首先是用于存储 WebGL 和 WebXR 特定信息的变量和常量

js
let polyfill = null;
let xrSession = null;
let xrInputSources = null;
let xrReferenceSpace = null;
let xrButton = null;
let gl = null;
let animationFrameRequestID = 0;
let shaderProgram = null;
let programInfo = null;
let buffers = null;
let texture = null;
let mouseYaw = 0;
let mousePitch = 0;

接下来是一组常量,主要用于包含渲染场景时使用的各种向量和矩阵。

js
const viewerStartPosition = vec3.fromValues(0, 0, -10);
const viewerStartOrientation = vec3.fromValues(0, 0, 1.0);

const cubeOrientation = vec3.create();
const cubeMatrix = mat4.create();
const mouseMatrix = mat4.create();
const inverseOrientation = quat.create();
const RADIANS_PER_DEGREE = Math.PI / 180.0;

前两个——viewerStartPositionviewerStartOrientation——指示观察者相对于空间中心的位置以及他们最初观察的方向。cubeOrientation 将存储立方体的当前方向,而 cubeMatrixmouseMatrix 是渲染场景期间使用的矩阵的存储位置。inverseOrientation 是一个四元数,将用于表示要应用于正在渲染的帧中对象的参考空间的旋转。

RADIANS_PER_DEGREE 是将角度(度数)乘以以将其转换为弧度的值。

声明的最后四个变量是<div> 元素的引用存储,我们将向其中输出矩阵,以便在希望向用户显示它们时显示。

记录错误

实现了名为LogGLError() 的函数,以便提供一种易于自定义的方式来输出执行 WebGL 函数时发生的错误的日志信息。

js
function LogGLError(where) {
  let err = gl.getError();
  if (err) {
    console.error(`WebGL error returned by ${where}: ${err}`);
  }
}

它仅将一个字符串where作为输入,用于指示程序的哪个部分生成了错误,因为类似的错误可能在多种情况下出现。

顶点和片段着色器

顶点和片段着色器与我们在文章WebGL 中的光照的示例中使用的着色器完全相同。请参阅该文章,如果您有兴趣了解此处使用的基本着色器的GLSL 源代码。

需要说明的是,顶点着色器根据每个顶点的初始位置和需要应用的变换来计算每个顶点的位置,以模拟观察者的当前位置和方向。片段着色器返回每个顶点的颜色,根据纹理中找到的值进行插值,并应用光照效果。

启动和关闭 WebXR

在最初加载脚本时,我们为load 事件安装了一个处理程序,以便我们可以执行初始化。

js
window.addEventListener("load", onLoad);

function onLoad() {
  xrButton = document.querySelector("#enter-xr");
  xrButton.addEventListener("click", onXRButtonClick);

  projectionMatrixOut = document.querySelector("#projection-matrix div");
  modelMatrixOut = document.querySelector("#model-view-matrix div");
  cameraMatrixOut = document.querySelector("#camera-matrix div");
  mouseMatrixOut = document.querySelector("#mouse-matrix div");

  if (!navigator.xr || enableForcePolyfill) {
    console.log("Using the polyfill");
    polyfill = new WebXRPolyfill();
  }
  setupXRButton();
}

load 事件处理程序获取切换 WebXR 开关的按钮的引用,将其放入xrButton中,然后添加click 事件的处理程序。然后获取四个<div> 块的引用,我们将向其中输出每个关键矩阵的当前内容,以便在场景运行时提供信息。

然后我们查看navigator.xr 是否已定义。如果未定义——以及/或者enableForcePolyfill 配置常量设置为true——我们通过实例化WebXRPolyfill 类来安装 WebXR polyfill。

处理启动和关闭 UI

然后我们调用setupXRButton() 函数,该函数处理配置“进入/退出 WebXR”按钮,以便根据SESSION_TYPE 常量中指定的会话类型的 WebXR 支持的可用性,在必要时启用或禁用该按钮。

js
function setupXRButton() {
  if (navigator.xr.isSessionSupported) {
    navigator.xr.isSessionSupported(SESSION_TYPE).then((supported) => {
      xrButton.disabled = !supported;
    });
  } else {
    navigator.xr
      .supportsSession(SESSION_TYPE)
      .then(() => {
        xrButton.disabled = false;
      })
      .catch(() => {
        xrButton.disabled = true;
      });
  }
}

按钮的标签在实际处理启动和停止 WebXR 会话的代码中进行了调整;我们将在下面看到这一点。

WebXR 会话由按钮上的click 事件的处理程序切换打开和关闭,该按钮的标签适当地设置为“进入 WebXR”或“退出 WebXR”。这是由onXRButtonClick() 事件处理程序完成的。

js
async function onXRButtonClick(event) {
  if (!xrSession) {
    navigator.xr.requestSession(SESSION_TYPE).then(sessionStarted);
  } else {
    await xrSession.end();

    if (xrSession) {
      sessionEnded();
    }
  }
}

首先检查xrSession的值,看我们是否已经拥有一个代表正在进行的 WebXR 会话的XRSession对象。如果没有,则点击代表启用 WebXR 模式请求,因此调用requestSession()请求所需 WebXR 会话类型的 WebXR 会话,然后调用sessionStarted()开始在该 WebXR 会话中运行场景。

另一方面,如果我们已经有一个正在进行的会话,则调用其end()方法来停止会话。

这段代码的最后一步是检查xrSession是否仍然不为NULL。如果是,则调用sessionEnded(),这是end事件的处理程序。这段代码应该是不必要的,但似乎存在一个问题,至少某些浏览器没有正确触发end事件。通过直接运行事件处理程序,我们在此情况下手动完成关闭过程。

启动 WebXR 会话

sessionStarted()函数负责实际设置和启动会话,方法是设置事件处理程序、编译并安装顶点和片段着色器的 GLSL 代码,以及在启动渲染循环之前将 WebGL 层附加到 WebXR 会话。它被调用作为requestSession()返回的 Promise 的处理程序。

js
function sessionStarted(session) {
  let refSpaceType;

  xrSession = session;
  xrButton.innerText = "Exit WebXR";
  xrSession.addEventListener("end", sessionEnded);

  let canvas = document.querySelector("canvas");
  gl = canvas.getContext("webgl", { xrCompatible: true });

  if (allowMouseRotation) {
    canvas.addEventListener("pointermove", handlePointerMove);
    canvas.addEventListener("contextmenu", (event) => {
      event.preventDefault();
    });
  }

  if (allowKeyboardMotion) {
    document.addEventListener("keydown", handleKeyDown);
  }

  shaderProgram = initShaderProgram(gl, vsSource, fsSource);

  programInfo = {
    program: shaderProgram,
    attribLocations: {
      vertexPosition: gl.getAttribLocation(shaderProgram, "aVertexPosition"),
      vertexNormal: gl.getAttribLocation(shaderProgram, "aVertexNormal"),
      textureCoord: gl.getAttribLocation(shaderProgram, "aTextureCoord"),
    },
    uniformLocations: {
      projectionMatrix: gl.getUniformLocation(
        shaderProgram,
        "uProjectionMatrix",
      ),
      modelViewMatrix: gl.getUniformLocation(shaderProgram, "uModelViewMatrix"),
      normalMatrix: gl.getUniformLocation(shaderProgram, "uNormalMatrix"),
      uSampler: gl.getUniformLocation(shaderProgram, "uSampler"),
    },
  };

  buffers = initBuffers(gl);
  texture = loadTexture(
    gl,
    "https://cdn.glitch.com/a9381af1-18a9-495e-ad01-afddfd15d000%2Ffirefox-logo-solid.png?v=1575659351244",
  );

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

  const isImmersiveVr = SESSION_TYPE === "immersive-vr";
  refSpaceType = isImmersiveVr ? "local" : "viewer";

  mat4.fromTranslation(cubeMatrix, viewerStartPosition);

  vec3.copy(cubeOrientation, viewerStartOrientation);

  xrSession.requestReferenceSpace(refSpaceType).then((refSpace) => {
    xrReferenceSpace = refSpace.getOffsetReferenceSpace(
      new XRRigidTransform(viewerStartPosition, cubeOrientation),
    );
    animationFrameRequestID = xrSession.requestAnimationFrame(drawFrame);
  });

  return xrSession;
}

将新创建的XRSession对象存储到xrSession中后,按钮的标签将设置为“退出 WebXR”,以指示在启动场景后其新的功能,并为end事件安装了一个处理程序,以便我们在XRSession结束时收到通知。

然后,我们获取 HTML 中找到的<canvas>的引用——以及它的 WebGL 渲染上下文——它将用作场景的绘图表面。在元素上调用getContext()获取画布的 WebGL 渲染上下文时,会请求xrCompatible属性。这确保了上下文配置为可用于 WebXR 渲染的源。

接下来,我们为mousemovecontextmenu添加事件处理程序,但前提是allowMouseRotation常量为truemousemove处理程序将根据鼠标的移动处理视图的俯仰和偏航。由于“鼠标观察”功能仅在按住鼠标右键时才起作用,并且使用鼠标右键单击会触发上下文菜单,因此我们为画布的contextmenu事件添加了一个处理程序,以防止在用户最初开始拖动鼠标时出现上下文菜单。

接下来,我们编译着色器程序;获取其变量的引用;初始化存储每个位置数组的缓冲区;每个顶点的顶点位置表索引;顶点法线;以及每个顶点的纹理坐标。所有这些都直接来自 WebGL 示例代码,因此请参考WebGL 中的灯光及其之前的文章使用 WebGL 创建 3D 对象在 WebGL 中使用纹理。然后调用我们的loadTexture()函数来加载纹理文件。

现在渲染结构和数据已加载,我们开始准备运行XRSession。我们通过调用XRSession.updateRenderState()并将baseLayer设置为一个新的XRWebGLLayer来将会话连接到 WebGL 层,以便它知道使用什么作为渲染表面。

然后,我们查看SESSION_TYPE常量的值,以查看 WebXR 上下文应该是沉浸式还是内联式。沉浸式会话使用local参考空间,而内联式会话使用viewer参考空间。

glMatrix库的 4x4 矩阵的fromTranslation()函数用于将viewerStartPosition常量中给出的查看器起始位置转换为变换矩阵cubeMatrix。查看器的起始方向viewerStartOrientation常量被复制到cubeOrientation中,后者将用于跟踪立方体随时间的旋转。

sessionStarted()通过调用会话的requestReferenceSpace()方法来获取描述对象创建空间的参考空间对象来结束。当返回的 Promise 解析为XRReferenceSpace对象时,我们调用其getOffsetReferenceSpace方法以获取一个参考空间对象来表示对象的坐标系。新空间的原点位于由viewerStartPosition指定的全局坐标,其方向设置为cubeOrientation。然后,我们通过调用其requestAnimationFrame()方法让会话知道我们已准备好绘制帧。我们记录返回的请求 ID,以防我们以后需要取消请求。

最后,sessionStarted()返回表示用户 WebXR 会话的XRSession

会话结束时

当 WebXR 会话结束时——无论是由于用户关闭还是通过调用XRSession.end()——都会发送end事件;我们已将其设置为调用名为sessionEnded()的函数。

js
function sessionEnded() {
  xrButton.innerText = "Enter WebXR";

  if (animationFrameRequestID) {
    xrSession.cancelAnimationFrame(animationFrameRequestID);
    animationFrameRequestID = 0;
  }
  xrSession = null;
}

如果我们希望以编程方式结束 WebXR 会话,也可以直接调用sessionEnded()。无论哪种情况,按钮的标签都会更新以指示点击将启动会话,然后,如果有挂起的动画帧请求,我们通过调用cancelAnimationFrame取消它。

完成后,xrSession的值将更改为NULL,以指示我们已完成会话。

实现控件

现在让我们看一下处理将键盘和鼠标事件转换为可用于控制 WebXR 场景中化身的内容的代码。

使用键盘移动

为了允许用户即使没有具有在空间中执行移动的输入的 WebXR 设备也能在 3D 世界中移动,我们的keydown事件处理程序handleKeyDown()通过根据按下哪个键更新对象原点的偏移量来响应。

js
function handleKeyDown(event) {
  switch (event.key) {
    case "w":
    case "W":
      verticalDistance -= MOVE_DISTANCE;
      break;
    case "s":
    case "S":
      verticalDistance += MOVE_DISTANCE;
      break;
    case "a":
    case "A":
      transverseDistance += MOVE_DISTANCE;
      break;
    case "d":
    case "D":
      transverseDistance -= MOVE_DISTANCE;
      break;
    case "ArrowUp":
      axialDistance += MOVE_DISTANCE;
      break;
    case "ArrowDown":
      axialDistance -= MOVE_DISTANCE;
      break;
    case "r":
    case "R":
      transverseDistance = axialDistance = verticalDistance = 0;
      mouseYaw = mousePitch = 0;
      break;
    default:
      break;
  }
}

键及其效果如下:

  • W 键将查看器向上移动MOVE_DISTANCE
  • S 键将查看器向下移动MOVE_DISTANCE
  • A 键将查看器向左滑动MOVE_DISTANCE
  • D 键将查看器向右滑动MOVE_DISTANCE
  • 向上箭头键将查看器向前滑动MOVE_DISTANCE
  • 向下箭头键将查看器向后滑动MOVE_DISTANCE
  • R 键将查看器重置到其起始位置和方向,方法是将所有输入偏移量重置为 0。

这些偏移量将从绘制的下一帧开始由渲染器应用。

用鼠标进行俯仰和偏航

我们还有一个mousemove事件处理程序,它会检查鼠标右键是否按下,如果按下,则调用接下来定义的rotateViewBy()函数来计算和存储新的俯仰(向上和向下看)和偏航(向左和向右看)值。

js
function handlePointerMove(event) {
  if (event.buttons & 2) {
    rotateViewBy(event.movementX, event.movementY);
  }
}

计算新的俯仰和偏航值由函数rotateViewBy()处理。

js
function rotateViewBy(dx, dy) {
  mouseYaw -= dx * MOUSE_SPEED;
  mousePitch -= dy * MOUSE_SPEED;

  if (mousePitch < -Math.PI * 0.5) {
    mousePitch = -Math.PI * 0.5;
  } else if (mousePitch > Math.PI * 0.5) {
    mousePitch = Math.PI * 0.5;
  }
}

给定鼠标增量dxdy作为输入,新的偏航值通过从mouseYaw的当前值中减去dxMOUSE_SPEED缩放常数的乘积来计算。然后,您可以通过增加MOUSE_SPEED的值来控制鼠标的响应速度。

绘制帧

我们对XRSession.requestAnimationFrame()的回调在下面显示的drawFrame()函数中实现。它的工作是获取查看器的参考空间,计算自上一帧以来经过的时间需要对任何动画对象应用多少移动,然后渲染查看器的XRPose指定的每个视图。

js
let lastFrameTime = 0;

function drawFrame(time, frame) {
  const session = frame.session;
  let adjustedRefSpace = xrReferenceSpace;
  let pose = null;

  animationFrameRequestID = session.requestAnimationFrame(drawFrame);
  adjustedRefSpace = applyViewerControls(xrReferenceSpace);
  pose = frame.getViewerPose(adjustedRefSpace);

  if (pose) {
    const glLayer = session.renderState.baseLayer;

    gl.bindFramebuffer(gl.FRAMEBUFFER, glLayer.framebuffer);
    LogGLError("bindFrameBuffer");

    gl.clearColor(0, 0, 0, 1.0);
    gl.clearDepth(1.0); // Clear everything
    gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
    LogGLError("glClear");

    const deltaTime = (time - lastFrameTime) * 0.001; // Convert to seconds
    lastFrameTime = time;

    for (const view of pose.views) {
      const viewport = glLayer.getViewport(view);
      gl.viewport(viewport.x, viewport.y, viewport.width, viewport.height);
      LogGLError(`Setting viewport for eye: ${view.eye}`);
      gl.canvas.width = viewport.width * pose.views.length;
      gl.canvas.height = viewport.height;
      renderScene(gl, view, programInfo, buffers, texture, deltaTime);
    }
  }
}

我们首先调用requestAnimationFrame()来请求再次调用drawFrame()以渲染下一帧。然后,我们将对象的参考空间传递到applyViewerControls()函数,该函数返回一个修改后的XRReferenceSpace,该函数转换对象的位置和方向以考虑用户使用键盘和鼠标应用的移动、俯仰和偏航。请记住,与往常一样,移动和重新定向的是世界中的对象,而不是查看器。返回的参考空间使我们能够轻松地做到这一点。

有了新的参考空间,我们获取表示查看器视点的XRViewerPose——用于他们的两只眼睛。如果成功,我们将开始准备渲染,方法是获取会话正在使用的XRWebGLLayer并将它的帧缓冲区绑定为 WebGL 帧缓冲区(以便渲染 WebGL 绘制到该层,因此绘制到 XR 设备的显示屏上)。现在 WebGL 已配置为渲染到 XR 设备,我们清除帧为黑色并准备开始渲染。

自渲染上一帧以来经过的时间(以秒为单位)通过从上一帧的时间戳lastFrameTime中减去time参数指定的当前时间,然后乘以 0.001 将毫秒转换为秒来计算。然后将当前时间保存到lastFrameTime中;

drawFrame()函数通过遍历XRViewerPose中找到的每个视图、设置视图的视口以及调用renderScene()来渲染帧来结束。通过为每个视图设置视口,我们处理了每个眼睛的视图都渲染到 WebGL 帧的一半的典型场景。然后 XR 硬件处理确保每只眼睛只看到该图像中专为该眼睛设计的的那部分。

注意:在此示例中,我们正在 XR 设备屏幕上直观地呈现帧。为了确保屏幕上的画布具有正确的尺寸以允许我们执行此操作,我们将它的宽度设置为等于单个XRView宽度乘以视图的数量;画布高度始终与视口高度相同。调整画布大小的两行代码在常规 WebXR 渲染循环中不需要。

应用用户输入

applyViewerControls() 函数由 drawFrame() 函数调用,在开始渲染任何内容之前调用。它接收三个方向上的偏移量、偏航偏移量和俯仰偏移量,这些偏移量由 handleKeyDown()handlePointerMove() 函数记录,这些函数分别响应用户按下按键和拖动鼠标(右键按下)的操作。它以对象的基准参考空间作为输入,并返回一个新的参考空间,该空间会修改对象的位置和方向,以匹配输入的结果。

js
function applyViewerControls(refSpace) {
  if (
    !mouseYaw &&
    !mousePitch &&
    !axialDistance &&
    !transverseDistance &&
    !verticalDistance
  ) {
    return refSpace;
  }

  quat.identity(inverseOrientation);
  quat.rotateX(inverseOrientation, inverseOrientation, -mousePitch);
  quat.rotateY(inverseOrientation, inverseOrientation, -mouseYaw);

  let newTransform = new XRRigidTransform(
    { x: transverseDistance, y: verticalDistance, z: axialDistance },
    {
      x: inverseOrientation[0],
      y: inverseOrientation[1],
      z: inverseOrientation[2],
      w: inverseOrientation[3],
    },
  );
  mat4.copy(mouseMatrix, newTransform.matrix);

  return refSpace.getOffsetReferenceSpace(newTransform);
}

如果所有输入偏移量都为零,我们只需返回原始参考空间。否则,我们根据 mousePitchmouseYaw 中的方向变化创建一个四元数,该四元数指定该方向的反方向,以便将 inverseOrientation 应用于立方体时,能够正确地反映查看器的移动。

然后,是时候创建一个新的 XRRigidTransform 对象,该对象表示将用于创建新的 XRReferenceSpace 的变换,用于移动和/或重新定向对象。位置是一个新的向量,其 xyz 分别对应于沿每个轴移动的偏移量。方向是 inverseOrientation 四元数。

我们将变换的 matrix 复制到 mouseMatrix 中,我们稍后将使用它向用户显示鼠标跟踪矩阵(因此,这通常是您可以跳过的一步)。最后,我们将 XRRigidTransform 传递到对象的当前 XRReferenceSpace 中,以便获得将此变换整合到一起的参考空间,以表示立方体相对于用户的位置,考虑到用户移动的位置。此新参考空间将返回给调用方。

渲染场景

renderScene() 函数被调用以实际渲染当前对用户可见的世界部分。它针对每个眼睛调用一次,每个眼睛的位置略有不同,以便建立 XR 设备所需的 3D 效果。

此代码的大部分是典型的 WebGL 渲染代码,直接取自 WebGL 中的灯光 文章中的 drawScene() 函数,您应该在其中查找有关此示例的 WebGL 渲染部分的详细信息 [在 GitHub 上查看代码]。但此处它以一些特定于此示例的代码开头,因此我们将深入了解该部分。

js
const normalMatrix = mat4.create();
const modelViewMatrix = mat4.create();

function renderScene(gl, view, programInfo, buffers, texture, deltaTime) {
  const xRotationForTime =
    xRotationDegreesPerSecond * RADIANS_PER_DEGREE * deltaTime;
  const yRotationForTime =
    yRotationDegreesPerSecond * RADIANS_PER_DEGREE * deltaTime;
  const zRotationForTime =
    zRotationDegreesPerSecond * RADIANS_PER_DEGREE * deltaTime;

  gl.enable(gl.DEPTH_TEST); // Enable depth testing
  gl.depthFunc(gl.LEQUAL); // Near things obscure far things

  if (enableRotation) {
    mat4.rotate(
      cubeMatrix, // destination matrix
      cubeMatrix, // matrix to rotate
      zRotationForTime, // amount to rotate in radians
      [0, 0, 1],
    ); // axis to rotate around (Z)
    mat4.rotate(
      cubeMatrix, // destination matrix
      cubeMatrix, // matrix to rotate
      yRotationForTime, // amount to rotate in radians
      [0, 1, 0],
    ); // axis to rotate around (Y)
    mat4.rotate(
      cubeMatrix, // destination matrix
      cubeMatrix, // matrix to rotate
      xRotationForTime, // amount to rotate in radians
      [1, 0, 0],
    ); // axis to rotate around (X)
  }

  mat4.multiply(modelViewMatrix, view.transform.inverse.matrix, cubeMatrix);
  mat4.invert(normalMatrix, modelViewMatrix);
  mat4.transpose(normalMatrix, normalMatrix);

  displayMatrix(view.projectionMatrix, 4, projectionMatrixOut);
  displayMatrix(modelViewMatrix, 4, modelMatrixOut);
  displayMatrix(view.transform.matrix, 4, cameraMatrixOut);
  displayMatrix(mouseMatrix, 4, mouseMatrixOut);

  {
    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 = 2;
    const type = gl.FLOAT;
    const normalize = false;
    const stride = 0;
    const offset = 0;
    gl.bindBuffer(gl.ARRAY_BUFFER, buffers.textureCoord);
    gl.vertexAttribPointer(
      programInfo.attribLocations.textureCoord,
      numComponents,
      type,
      normalize,
      stride,
      offset,
    );
    gl.enableVertexAttribArray(programInfo.attribLocations.textureCoord);
  }

  {
    const numComponents = 3;
    const type = gl.FLOAT;
    const normalize = false;
    const stride = 0;
    const offset = 0;
    gl.bindBuffer(gl.ARRAY_BUFFER, buffers.normal);
    gl.vertexAttribPointer(
      programInfo.attribLocations.vertexNormal,
      numComponents,
      type,
      normalize,
      stride,
      offset,
    );
    gl.enableVertexAttribArray(programInfo.attribLocations.vertexNormal);
  }

  gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, buffers.indices);
  gl.useProgram(programInfo.program);

  gl.uniformMatrix4fv(
    programInfo.uniformLocations.projectionMatrix,
    false,
    view.projectionMatrix,
  );
  gl.uniformMatrix4fv(
    programInfo.uniformLocations.modelViewMatrix,
    false,
    modelViewMatrix,
  );
  gl.uniformMatrix4fv(
    programInfo.uniformLocations.normalMatrix,
    false,
    normalMatrix,
  );
  gl.activeTexture(gl.TEXTURE0);
  gl.bindTexture(gl.TEXTURE_2D, texture);

  gl.uniform1i(programInfo.uniformLocations.uSampler, 0);

  {
    const vertexCount = 36;
    const type = gl.UNSIGNED_SHORT;
    const offset = 0;
    gl.drawElements(gl.TRIANGLES, vertexCount, type, offset);
  }
}

renderScene() 从计算自渲染上一帧以来经过的时间内应围绕三个轴中的每个轴旋转多少开始。这些值使我们能够以正确的量调整动画立方体的旋转,以确保其移动速度保持一致,而不管由于系统负载而可能发生的帧速率变化。这些值计算为给定经过时间应用的旋转弧度数,并存储到常量 xRotationForTimeyRotationForTimezRotationForTime 中。

启用并配置深度测试后,我们检查 enableRotation 常量的值以查看是否启用了立方体的旋转;如果是,我们使用 glMatrix 围绕三个轴旋转 cubeMatrix(表示立方体相对于世界空间的当前方向)。建立立方体的全局方向后,我们将其乘以视图变换矩阵的反矩阵以获得最终的模型视图矩阵——应用于对象的矩阵,既可以旋转以实现其动画目的,也可以移动和重新定向以模拟查看器在空间中的运动。

然后通过获取模型视图矩阵,将其反转并转置(交换其列和行)来计算视图的法线矩阵。

为此示例添加的最后几行代码是对 displayMatrix() 的四次调用,该函数显示矩阵的内容以供用户分析。该函数的其余部分与从中派生此代码的旧 WebGL 示例相同或基本相同。

显示矩阵

出于教学目的,此示例显示了渲染场景时使用的重要矩阵的内容。displayMatrix() 函数用于此;此函数使用 MathML 渲染矩阵,如果用户的浏览器不支持 MathML,则回退到更类似数组的格式。

js
function displayMatrix(mat, rowLength, target) {
  let outHTML = "";

  if (mat && rowLength && rowLength <= mat.length) {
    let numRows = mat.length / rowLength;
    outHTML = "<math display='block'>\n<mrow>\n<mo>[</mo>\n<mtable>\n";

    for (let y = 0; y < numRows; y++) {
      outHTML += "<mtr>\n";
      for (let x = 0; x < rowLength; x++) {
        outHTML += `<mtd><mn>${mat[x * rowLength + y].toFixed(2)}</mn></mtd>\n`;
      }
      outHTML += "</mtr>\n";
    }

    outHTML += "</mtable>\n<mo>]</mo>\n</mrow>\n</math>";
  }

  target.innerHTML = outHTML;
}

这会将 target 指定的元素的内容替换为新创建的 <math> 元素,该元素包含 4x4 矩阵。每个条目都显示最多两位小数。

其他所有内容

其余代码与早期示例中的代码相同。

initShaderProgram()

初始化 GLSL 着色器程序,调用 loadShader() 加载并编译每个着色器的程序,然后将每个着色器附加到 WebGL 上下文。编译完成后,程序将被链接并返回给调用方。

loadShader()

创建一个着色器对象并将指定的源代码加载到其中,然后编译代码并检查编译是否成功,然后再将新编译的着色器返回给调用方。如果发生错误,则返回 NULL

initBuffers()

初始化包含要传递到 WebGL 的数据的缓冲区。这些缓冲区包括顶点位置数组、顶点法线数组、立方体每个表面的纹理坐标以及顶点索引数组(指定顶点列表中的哪个条目表示立方体的每个角)。

loadTexture()

加载给定 URL 上的图像并从中创建 WebGL 纹理。如果图像的尺寸都不是 2 的幂(请参阅 isPowerOf2() 函数),则禁用 mipmapping 并将包裹钳制到边缘。这是因为优化渲染的 mipmapped 纹理仅适用于尺寸为 2 的幂的纹理(在 WebGL 1 中)。WebGL 2 支持任意大小的纹理进行 mipmapping。

isPowerOf2()

如果指定值为 2 的幂,则返回 true;否则返回 false

综合起来

当您获取所有这些代码并添加 HTML 和上面未包含的其他 JavaScript 代码时,您将获得在 在 Glitch 上试用此示例 时看到的内容。请记住:当您四处走动时,如果您迷路了,只需按 R 键即可将自己重置到起点。

提示:如果您没有 XR 设备,如果将脸部靠近屏幕,鼻子位于画布中左右眼图像之间的边界线上,则您可能能够获得一些 3D 效果。通过仔细聚焦穿过屏幕上的图像,并缓慢向前和向后移动,您最终应该能够将 3D 图像聚焦。这可能需要练习,并且您的鼻子可能实际上会接触到屏幕,具体取决于您的视力有多锐利。

您可以使用此示例作为起点做很多事情。尝试向世界添加更多对象,或改进移动控件以更逼真地移动。添加墙壁、天花板和地板以将您封闭在一个空间中,而不是拥有一个看似无限的宇宙迷失其中。添加碰撞测试或命中测试,或更改立方体每个面的纹理的能力。

如果您下定决心,几乎没有什么可以限制您能做什么。

另请参阅