移动、方向和运动:一个 WebXR 示例
在本文中,我们将利用我们 WebXR 系列教程前几篇文章中介绍的信息来构建一个示例,该示例动画化一个旋转的立方体,用户可以使用 VR 头戴设备、键盘和/或鼠标围绕该立方体自由移动。这将有助于巩固您对 3D 图形和 VR 几何原理的理解,并有助于确保您理解 XR 渲染过程中使用的函数和数据如何协同工作。
图:该示例的实际运行截图 
此示例的核心——旋转、有纹理、有光照的立方体——取自我们的 WebGL 教程系列;确切地说,是该系列倒数第二篇文章,涵盖了 WebGL 中的光照。
在阅读本文及随附的源代码时,请记住 3D 头戴设备的显示屏是单个屏幕,一分为二。屏幕的左半部分仅供左眼观看,而右半部分仅供右眼观看。为了沉浸式呈现而渲染场景需要对场景进行多次渲染——从每只眼睛的角度各渲染一次。
当渲染左眼时,XRWebGLLayer 的 视口 配置为将绘图限制在绘图表面的左半部分。相反,当渲染右眼时,视口设置为将绘图限制在表面的右半部分。
此示例通过在屏幕上显示画布来演示这一点,即使使用 XR 设备以沉浸式显示方式呈现场景也是如此。
依赖项
虽然我们不会为此示例依赖任何 3D 图形框架,例如 three.js 等,但我们确实使用 glMatrix 库进行矩阵数学运算,我们过去在其他示例中也使用过它。此示例还导入了由沉浸式 Web 工作组(负责 WebXR API 规范的团队)维护的 WebXR polyfill。通过导入此 polyfill,我们允许该示例在许多尚未实现 WebXR 的浏览器上运行,并且我们平滑了在 WebXR 规范的这些仍有些实验性的日子里发生的任何暂时的规范偏差。
选项
此示例有许多选项,您可以通过在浏览器中加载它之前调整常量的值来配置这些选项。代码如下所示
const xRotationDegreesPerSecond = 25;
const yRotationDegreesPerSecond = 15;
const zRotationDegreesPerSecond = 35;
const enableRotation = true;
const allowMouseRotation = true;
const allowKeyboardMotion = true;
const enableForcePolyfill = false;
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 特定信息的变量和常量开始
let polyfill = null;
let xrSession = null;
let xrInputSources = null;
let xrReferenceSpace = null;
const xrButton = document.querySelector("#enter-xr");
const projectionMatrixOut = document.querySelector("#projection-matrix div");
const modelMatrixOut = document.querySelector("#model-view-matrix div");
const cameraMatrixOut = document.querySelector("#camera-matrix div");
const mouseMatrixOut = document.querySelector("#mouse-matrix div");
let gl = null;
let animationFrameRequestID = 0;
let shaderProgram = null;
let programInfo = null;
let buffers = null;
let texture = null;
let mouseYaw = 0;
let mousePitch = 0;
接着是一组常量,主要用于包含渲染场景时使用的各种向量和矩阵。
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;
前两个——viewerStartPosition 和 viewerStartOrientation——指示查看器相对于空间中心的位置,以及他们最初将朝向的方向。cubeOrientation 将存储立方体的当前方向,而 cubeMatrix 和 mouseMatrix 用于存储渲染场景时使用的矩阵。inverseOrientation 是一个四元数,将用于表示应用于正在渲染的帧中对象的参考空间的旋转。
RADIANS_PER_DEGREE 是将角度(以度为单位)转换为弧度时要乘以的值。
声明的最后四个变量是用于引用我们将输出矩阵以显示给用户的 <div> 元素。
记录错误
实现了一个名为 LogGLError() 的函数,以提供一种易于自定义的方式来输出在执行 WebGL 函数时发生的错误的日志信息。
function LogGLError(where) {
let err = gl.getError();
if (err) {
console.error(`WebGL error returned by ${where}: ${err}`);
}
}
它只有一个输入,一个字符串 where,用于指示程序哪个部分生成了错误,因为相似的错误可能在多种情况下发生。
顶点和片段着色器
顶点和片段着色器都与我们文章 WebGL 中的光照 示例中使用的完全相同。如果您对此处使用的基本着色器的 GLSL 源代码感兴趣,请 参阅该文章。
简而言之,顶点着色器根据每个顶点的初始位置和需要应用的变换来计算每个顶点的位置,以模拟查看器的当前位置和方向。片段着色器返回每个顶点的颜色,根据纹理中找到的值进行插值并应用光照效果。
启动和关闭 WebXR
xrButton.addEventListener("click", onXRButtonClick);
if (!navigator.xr || enableForcePolyfill) {
console.log("Using the polyfill");
polyfill = new WebXRPolyfill();
}
setupXRButton();
我们添加了一个 click 事件的处理程序。然后我们检查 navigator.xr 是否已定义。如果未定义——并且/或 enableForcePolyfill 配置常量设置为 true——我们通过实例化 WebXRPolyfill 类来安装 WebXR polyfill。
处理启动和关闭 UI
然后我们调用 setupXRButton() 函数,该函数根据 SESSION_TYPE 常量中指定的会话类型对 WebXR 支持的可用性来配置“进入/退出 WebXR”按钮,以便根据需要启用或禁用它。
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() 事件处理程序完成的。
async function onXRButtonClick(event) {
if (!xrSession) {
navigator.xr.requestSession(SESSION_TYPE).then(sessionStarted);
} else {
await xrSession.end();
if (xrSession) {
sessionEnded();
}
}
}
这首先通过查看 xrSession 的值来检查我们是否已经有一个 XRSession 对象表示正在进行的 WebXR 会话。如果没有,则单击表示请求启用 WebXR 模式,因此调用 requestSession() 来请求所需 WebXR 会话类型的 WebXR 会话,然后调用 sessionStarted() 以在该 WebXR 会话中开始运行场景。
另一方面,如果我们已经有一个正在进行的会话,我们调用其 end() 方法来停止会话。
我们在这段代码中做的最后一件事是检查 xrSession 是否仍然不是 NULL。如果是,我们调用 sessionEnded(),这是 end 事件的处理程序。这段代码应该是不必要的,但似乎存在一个问题,即至少某些浏览器没有正确触发 end 事件。通过直接运行事件处理程序,我们在此情况下手动完成关闭过程。
启动 WebXR 会话
sessionStarted() 函数通过设置事件处理程序、编译和安装顶点和片段着色器的 GLSL 代码,以及在启动渲染循环之前将 WebGL 层附加到 WebXR 会话来实际设置和启动会话。它作为 requestSession() 返回的 Promise 的处理程序被调用。
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://mdn.github.io/shared-assets/images/examples/fx-nightly-512.png",
);
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() 时请求 xrCompatible 属性以访问画布的 WebGL 渲染上下文。这确保了上下文已配置为用作 WebXR 渲染的源。
接下来,我们为 mousemove 和 contextmenu 添加事件处理程序,但仅当 allowMouseRotation 常量为 true 时。mousemove 处理程序将根据鼠标的移动处理视图的俯仰和偏航。由于“旋转”功能仅在按下鼠标右键时才起作用,并且单击鼠标右键会触发上下文菜单,因此我们为画布添加了一个 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() 的函数。
function sessionEnded() {
xrButton.innerText = "Enter WebXR";
if (animationFrameRequestID) {
xrSession.cancelAnimationFrame(animationFrameRequestID);
animationFrameRequestID = 0;
}
xrSession = null;
}
如果我们希望以编程方式结束 WebXR 会话,我们也可以直接调用 sessionEnded()。无论哪种情况,按钮的标签都会更新,以指示单击将开始一个会话,然后,如果存在动画帧的待处理请求,我们会通过调用 cancelAnimationFrame 来取消它。
完成此操作后,xrSession 的值将更改为 NULL,表示我们已完成会话。
实现控制器
现在让我们看看处理将键盘和鼠标事件转换为可在 WebXR 场景中控制化身的代码。
使用键盘移动
为了允许用户在 3D 世界中移动,即使他们没有具有空间移动输入功能的 WebXR 设备,我们的 keydown 事件处理程序 handleKeyDown() 会根据按下的键通过更新对象原点的偏移量来响应。
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() 函数,以计算并存储新的俯仰(向上和向下看)和偏航(向左和向右看)值。
function handlePointerMove(event) {
if (event.buttons & 2) {
rotateViewBy(event.movementX, event.movementY);
}
}
新的俯仰和偏航值的计算由函数 rotateViewBy() 处理
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;
}
}
给定鼠标增量 dx 和 dy 作为输入,新的偏航值通过从 mouseYaw 的当前值中减去 dx 与 MOUSE_SPEED 缩放常量的乘积来计算。然后,您可以通过增加 MOUSE_SPEED 的值来控制鼠标的响应速度。
绘制一帧
我们为 XRSession.requestAnimationFrame() 实现的回调函数是下面所示的 drawFrame() 函数。它的任务是获取查看器的参考空间,计算自上一帧以来经过的时间量需要应用于任何动画对象的移动量,然后渲染查看器 XRPose 指定的每个视图。
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 设备,我们清除帧为黑色,并准备开始渲染。
自上一帧渲染以来经过的时间(以秒为单位)通过从当前时间(由 time 参数指定)减去上一帧的时间戳 lastFrameTime,然后乘以 0.001 将毫秒转换为秒来计算。然后将当前时间保存到 lastFrameTime 中;
drawFrame() 函数通过迭代 XRViewerPose 中找到的每个视图,为视图设置视口,并调用 renderScene() 来渲染帧。通过为每个视图设置视口,我们处理了每个眼睛的视图都渲染到 WebGL 帧的一半的典型场景。XR 硬件然后处理确保每只眼睛只看到该图像中为其意图的部分。
注意:在此示例中,我们同时在 XR 设备和屏幕上视觉呈现帧。为了确保屏幕上的画布具有正确的尺寸以允许我们执行此操作,我们将其宽度设置为等于单个 XRView 宽度乘以视图数;画布高度始终与视口的高度相同。在常规 WebXR 渲染循环中不需要调整画布尺寸的两行代码。
应用用户输入
applyViewerControls() 函数在开始渲染任何内容之前由 drawFrame() 调用,它根据 handleKeyDown() 和 handlePointerMove() 函数响应用户按下按键和用鼠标右键拖动鼠标所记录的三个方向上的偏移量、偏航偏移量和俯仰偏移量。它将对象的基准参考空间作为输入,并返回一个新的参考空间,该空间改变对象的位置和方向以匹配输入的结果。
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);
}
如果所有输入偏移量都为零,我们只需返回原始参考空间。否则,我们根据 mousePitch 和 mouseYaw 中的方向变化创建一个四元数,指定该方向的逆,以便将 inverseOrientation 应用于立方体将正确地反映查看器的移动。
然后是时候创建一个新的 XRRigidTransform 对象,表示将用于为移动和/或重新定向的对象创建新的 XRReferenceSpace 的变换。位置是一个新的向量,其 x、y 和 z 对应于沿这些轴移动的偏移量。方向是 inverseOrientation 四元数。
我们将变换的 matrix 复制到 mouseMatrix 中,我们稍后将使用它向用户显示鼠标跟踪矩阵(因此这一步通常可以跳过)。最后,我们将 XRRigidTransform 传递到对象的当前 XRReferenceSpace 中,以获取整合此变换的参考空间,以表示给定用户移动后立方体相对于用户的位置。该新的参考空间将返回给调用者。
渲染场景
调用 renderScene() 函数来实际渲染用户当前可见的世界部分。它为每只眼睛调用一次,每只眼睛的位置略有不同,以便建立 XR 设备所需的 3D 效果。
这段代码的大部分是典型的 WebGL 渲染代码,直接取自 WebGL 中的光照 一文中的 drawScene() 函数,您应该在那里查找有关此示例的 WebGL 渲染部分的详细信息(在 GitHub 上查看代码)。但这里它以一些特定于此示例的代码开始,所以我们将更深入地研究这部分。
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() 首先计算自上一帧渲染以来经过的时间内,围绕三个轴应发生的旋转量。这些值允许我们适当地调整动画立方体的旋转,以确保其移动速度保持一致,无论由于系统负载可能发生的帧速率变化。这些值计算为给定经过时间应用的旋转弧度数,并存储到常量 xRotationForTime、yRotationForTime 和 zRotationForTime 中。
启用和配置深度测试后,我们检查 enableRotation 常量的值以查看立方体旋转是否启用;如果启用,我们使用 glMatrix 旋转 cubeMatrix(表示立方体相对于世界空间的当前方向)围绕三个轴。确定立方体的全局方向后,我们将其乘以视图变换矩阵的逆矩阵,以获得最终的模型视图矩阵——应用于对象的矩阵,既用于动画目的旋转它,也用于移动和重新定向它以模拟查看器在空间中的运动。
然后通过取模型视图矩阵,对其进行反转并转置(交换其列和行)来计算视图的法线矩阵。
为此示例添加的最后几行代码是四次调用 displayMatrix(),该函数显示矩阵内容供用户分析。函数的其余部分与此代码派生自的旧 WebGL 示例相同或基本相同。
显示矩阵
为了教学目的,此示例显示了渲染场景时使用的重要矩阵的内容。displayMatrix() 函数用于此;如果用户的浏览器不支持 MathML,此函数使用 MathML 渲染矩阵,并回退到更像数组的格式。
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 纹理的优化渲染仅适用于 WebGL 1 中尺寸为 2 的幂的纹理。WebGL 2 支持任意大小的纹理进行 mipmapping。 isPowerOf2()-
如果指定的值是 2 的幂,则返回
true;否则返回false。
整合起来
当你将代码与 HTML 和一些额外的 JavaScript 结合起来时,你就会得到类似我们的 WebXR:带旋转对象和用户移动的示例 演示。记住:当你四处游荡时,如果迷路了,只需按下 R 键即可将自己重置到起点。
一个小提示:如果您没有 XR 设备,您可以尝试将脸部非常靠近屏幕,鼻子居中于画布中左右眼图像之间的边界线,这样也许可以获得一些 3D 效果。通过仔细地透过屏幕聚焦图像,并缓慢地向前和向后移动,您最终应该能够使 3D 图像对焦。这可能需要练习,并且您的鼻子可能真的会碰到屏幕,具体取决于您的视力敏锐程度。
以这个示例作为起点,您可以做很多事情。尝试向世界添加更多对象,或者改进移动控制以使其更逼真。添加墙壁、天花板和地板,将您封闭在一个空间中,而不是在一个看似无限的宇宙中迷失。添加碰撞检测或命中检测,或更改立方体每个面的纹理的能力。
只要你下定决心,几乎没有什么限制。
另见
- 学习 WebGL(包含一些关于相机及其与虚拟世界关系的精彩可视化)
- WebGL 基础知识
- 学习 OpenGL