渲染和 WebXR 帧动画回调
一旦你的 WebXR 环境设置完成,并创建了一个 XRSession
来代表正在进行的 XR 环境会话,你需要为 XR 设备提供场景帧以进行渲染。本文介绍了使用 XRSession
获取表示每一帧的 XRFrame
对象,并使用它来准备帧缓冲区以传递给 XR 设备,从而在渲染循环中驱动 XR 场景的帧到设备的过程。
在你能够渲染虚拟环境之前,你需要通过使用 navigator.xr.requestSession()
方法创建 XRSession
来建立一个 WebXR 会话;你还需要将会话与一个帧缓冲区关联,并执行其他设置任务。这些设置任务在文章 启动和关闭 WebXR 会话 中有描述。
准备渲染器
一旦 XR 会话设置完成,WebGL 帧缓冲区连接,并且 WebGL 用它渲染场景所需的数据进行初始化,你就可以设置渲染器以开始运行。这从获取你想绘制的参考空间开始,它的原点和方向设置在观察者的起始位置和观察方向。一旦拥有它,你就请求浏览器在下次需要帧缓冲区来渲染你的场景时调用你的渲染函数。这是通过调用 XRSession
方法 requestAnimationFrame()
来完成的。
因此,启动渲染器看起来像这样
let worldRefSpace;
async function runXR(xrSession) {
worldRefSpace = await xrSession.requestReferenceSpace("local");
if (worldRefSpace) {
viewerRefSpace = worldRefSpace.getOffsetReferenceSpace(
new XRRigidTransform(viewerStartPosition, viewerStartOrientation),
);
animationFrameRequestID = xrSession.requestAnimationFrame(myDrawFrame);
}
}
在获取沉浸式世界的参考空间之后,这将创建一个代表观察者位置和方向的偏移参考空间,方法是创建一个代表该位置和方向的 XRRigidTransform
,然后调用 XRReferenceSpace
方法 getOffsetReferenceSpace()
。
然后,通过调用 XRSession
方法 requestAnimationFrame()
,并提供一个回调函数 myDrawFrame()
,其任务是渲染帧,来调度第一个动画帧。
注意,这段代码没有循环!相反,帧渲染代码——在本例中,是一个名为 myDrawFrame()
的函数——负责通过再次调用 requestAnimationFrame()
来调度绘制另一帧的时间。
刷新率和帧率
假设你自上次屏幕刷新后调用了 XRSession
方法 requestAnimationFrame()
,浏览器将在每次准备重新绘制你的应用程序或站点窗口时调用你的帧渲染回调。在这种情况下,“重新绘制”意味着确保屏幕上显示的内容与 DOM 及其内部元素此时试图呈现的内容一致。
硬件垂直刷新率
当浏览器准备刷新显示你的 WebXR 内容的 <canvas>
时,它会调用你的帧渲染回调,该回调使用指定的 timestamps 和任何其他相关数据(如模型和纹理)以及应用程序状态,将场景渲染到 WebGL 后缓冲区——因为它应该在指定时间出现。当你的回调返回时,浏览器将该后缓冲区传输到显示器或 XR 设备,以及自上次屏幕刷新以来发生更改的任何其他内容。
从历史上看,显示器每秒刷新 60 次。这是因为早期的显示器使用交流电网的电流波动波形进行计时,该波形在美国每秒循环 60 次(欧洲为 50 次)。这个数字有许多不同的名称,但它们都等效或几乎等效
- 刷新率
- 垂直刷新率
- 垂直消隐率 (VBL)
- 垂直同步率
还有其他类似的术语,但无论叫什么,其测量单位都是赫兹,即 Hz。每秒刷新 60 次的显示器具有 60 Hz 的刷新率。这意味着它每秒可以显示的帧数最大为 60。无论你每秒渲染多少帧,在一秒钟内只有 60 帧能够到达屏幕。
但并非所有显示器都以 60 Hz 运行;如今,更高性能的显示器开始使用更高的刷新率。例如,120 Hz——或每秒 120 帧——显示器越来越普遍。浏览器始终尝试以与显示器相同的速率刷新,这意味着在某些计算机上,你的回调最多每秒运行 60 次,而在其他计算机上,它可能每秒运行 90 次或 120 次,甚至更多,具体取决于帧率。
渲染每一帧可用的时间
这使得充分利用帧之间可用的时间至关重要。如果用户的设备使用的是 60 Hz 显示器,你的回调最多每秒调用 60 次,你的目标是尽你所能确保它不会比这更频繁地调用。你可以通过尽可能多地在主线程之外执行操作,以及使帧渲染回调尽可能高效来实现这一点。下图显示了将时间划分为 60 Hz 块,每个块至少部分用于渲染场景。
这很重要,因为随着计算机越来越繁忙,它可能无法准确地每帧调用你的回调,并且可能不得不跳过帧。这被称为丢帧。当渲染帧所需的时间超过帧之间可用的时间时,就会发生这种情况,无论是由于渲染延迟还是由于渲染本身花费的时间超过了可用时间。
在上图中,第 3 帧被丢弃,因为第 2 帧直到第 3 帧应该绘制之后才完成渲染。接下来绘制的帧将是第 4 帧。这是传递给你的渲染回调的时间戳很有用的另一个原因。通过根据时间而不是帧号配置场景,你可以确保你渲染的帧与预期相符,而不是落后。
当一帧被丢弃时,受影响的显示区域的内容在该帧循环通过时不会改变。因此,偶尔丢弃的帧通常不会特别明显,但如果它开始频繁发生——特别是如果在非常短的时间内丢弃了许多帧——它可能会变得令人不适,甚至会让你的显示器无法使用。
幸运的是,你可以很容易地计算出你每帧可以使用的最大时间,即 1/refreshRate
秒。也就是说,将 1 除以显示器的刷新率。所得值是每帧渲染所需的时间,以避免丢帧。例如,60 Hz 显示器有一秒的 1/60 用于渲染单个帧,或 0.0166667 秒。如果设备的刷新率为 120 Hz,那么如果你想避免丢帧,你只有 0.00883333 秒的时间来渲染每帧。
即使硬件实际上是 120 Hz,你也可以只以每秒 60 次的速度刷新,并且以该速度为目标通常是一个很好的基线。60 FPS 已经超出了大多数人能够轻松检测到动画不是一系列静止图像快速播放的程度。换句话说,如果有疑问,你可以假设显示器正在以 60 Hz 的速度刷新。只要你的代码编写得当,一切都会好起来的。
渲染器性能问题
显然,你每帧渲染场景的时间非常少。不仅如此,如果你的渲染器本身运行时间超过了这个时间量,你可能会导致不仅帧被丢弃,而且该时间完全浪费,阻止其他代码在该帧中运行。
不仅如此,如果您的渲染跨越了垂直刷新边界,您可能会遇到 **撕裂** 现象。撕裂现象发生在显示硬件开始下一个刷新周期时,而前一帧仍在屏幕上绘制。结果,您最终会看到屏幕顶部显示新帧,而屏幕底部显示前一帧的组合,甚至可能包括前一帧之前的帧。
因此,您的任务是让您的代码足够紧凑和轻量级,以至于您不会超出可用的时间,或以其他方式导致帧丢失或过度使用主线程。
出于这些原因,除非您的渲染器相当小且轻量级,而且工作量很少,否则您应该考虑将所有可以卸载的内容卸载到工作线程,这样您可以在浏览器处理其他事务时计算下一帧。通过在实际调用帧之前准备好计算和数据,您可以使您的网站或应用程序更有效地渲染,从而提高主线程性能并总体上改善用户体验。
幸运的是,如果您对渲染的需求特别繁重,您可以使用一些技巧来进一步减少影响并优化性能。请参阅 WebXR 性能指南,其中包含有助于确保您性能尽可能好的建议和技巧。
WebXR 帧
您的帧渲染回调函数接收两个参数作为输入:帧对应的时间,以及一个描述场景在该时间的 XRFrame
对象。
3D 的光学原理
我们有两个眼睛是有原因的:通过两只眼睛,每只眼睛本质上都从略微不同的角度看待世界。由于它们之间有已知的固定距离,我们的大脑可以进行基本的几何和三角运算,并从这些信息中推断出现实的 3D 特性。我们还利用透视、大小差异,甚至我们对事物通常外观的理解来弄清楚该第三维的细节。这些因素,以及其他因素,是我们 深度感知 的来源。
为了在渲染图形时创建三维错觉,我们需要模拟尽可能多的这些因素。我们模拟的因素越多,以及模拟的准确度越高,我们就越能欺骗人脑使其感知我们的图像为 3D。XR 的优势在于,我们不仅可以使用经典的单眼技术来模拟 3D 图形(透视、大小和模拟视差),还可以通过为动画的每一帧渲染场景两次来模拟双眼视觉,即使用两只眼睛的视觉,一次为一只眼睛渲染一次。
典型的人类 瞳距,即瞳孔中心之间的距离,在 54 到 74 毫米(0.054 到 0.074 米)之间。因此,如果观察者头部中心位于 [0.0, 2.0, 0.0]
(水平空间中心上方约 2 米处),我们首先需要从 [-0.032, 2.0, 0.0]
(中心左侧 32 毫米)渲染场景,然后在 [0.032, 2.0, 0.0]
(中心右侧 32 毫米)再次渲染场景。这样,我们将观察者眼睛的位置放在平均为 64 毫米的人类瞳距上。
该距离(或 XR 系统配置使用的任何瞳距)足以使我们的大脑能够看到由于视网膜视差(每个视网膜看到的内容的差异)和视差效应而产生的足够差异,从而使我们的大脑能够计算出物体到物体的距离和深度,从而使我们能够感知到三维空间,尽管我们的视网膜只是二维表面。
下图说明了这一点,其中我们看到了每只眼睛如何感知位于观察者正前方的骰子。虽然该图为了说明目的在某些方面夸大了效果,但概念是一样的。每只眼睛看到的区域的边界在眼睛前面形成一条弧线。由于每只眼睛都偏离头部的中心线一侧,并且每只眼睛都看到大约相同的视野,因此结果是每只眼睛看到它面前世界的略微不同的部分,并且从略微不同的角度看到。
左眼从中心略微左侧看到骰子,右眼从中心略微右侧看到骰子。结果,左眼看到的物体左侧比右侧多一点,反之亦然。这两幅图像聚焦在视网膜上,由此产生的信号通过视神经传递到大脑的视觉皮层,视觉皮层位于枕叶的后面。
大脑接收来自左右眼的这些信号,并在观察者的大脑中构建一个统一的单一 3D 世界图像,这就是看到的内容。由于左眼和右眼看到的内容之间存在这些差异,因此大脑能够推断出有关物体深度、大小等的许多信息。通过将这些推断出的深度信息与其他线索(如透视、阴影、对这些关系含义的记忆等)相结合,我们可以弄清楚周围世界的大量信息。
帧、姿态、视图和帧缓冲区
获得代表场景在某个时间点状态的 XRFrame
后,您需要确定场景中物体相对于观察者的位置,以便您能够渲染它们。观察者相对于参考空间的位置和方向由一个 XRViewerPose
表示,该对象可以通过调用 XRFrame
方法 getViewerPose()
来获得。
XRFrame
不会直接跟踪您世界中物体的定位或方向。相反,它提供了一种将定位和方向转换为场景坐标系的方法,并且它从 XR 硬件收集观察者的定位和方向数据,将其转换为您配置的参考空间,并将带有时间戳的数据传递给您的帧渲染代码。您使用该时间戳和您自己的数据来确定如何渲染场景。
在将场景渲染两次之后,一次渲染到帧缓冲区的左侧,一次渲染到帧缓冲区的右侧,帧缓冲区被发送到 XR 硬件,XR 硬件将帧缓冲区的每一半显示到相应的眼睛。这通常(但并非总是)通过将图像绘制到单个屏幕并使用透镜将该图像的正确一半传递到每只眼睛来完成。
您可以在 用 WebXR 表示 3D 中了解有关 WebXR 如何表示 3D 的更多信息。
绘制场景
当准备帧缓冲区以使浏览器能够绘制场景的下一帧时,您提供给 requestAnimationFrame()
的函数被调用。它接收正在绘制的帧的时间以及提供有关要渲染的帧场景状态详细信息的 XRFrame
对象作为输入。
理想情况下,您希望这段代码足够快,能够保持 60 FPS 的帧速率,或尽可能接近该速率,请记住,除了这个函数中的代码之外,还有其他更多的事情在发生。您需要确保主线程在每一帧中不需要运行的时间超过帧本身的持续时间。
基本的渲染器
在这个版本的 WebXR 渲染回调中,我们使用了一种非常直接的方法,这种方法非常适合相对简单的项目。此伪代码概述了该过程
for each view in the pose's views list: get the WebXR GL layer's viewport set the WebGL viewport to match for each object in the scene bindProgram() bindVertices() bindMatrices() bindUniforms() bindBuffers() bindTextures() drawMyObject()
简而言之,这种形式的渲染器使用的是 **视图优先顺序**。组成 XR 设备显示器的两个视图中的每一个都背靠背渲染,其中所有对象都在一个视图上渲染,然后在另一个视图上渲染相同的对象集。结果,存在大量的重复工作,因为绘制对象所需的大部分数据最终会每帧发送到 GPU 两次。但是,它简化了现有 WebGL 代码的移植,并且通常足以完成工作,因此我们首先研究这种方法。
请参阅 通过以对象优先顺序渲染来优化,了解另一种方法,该方法将每个对象渲染两次,一次为一只眼睛,一次为另一只眼睛,然后转到组成该帧场景的下一个对象;也就是说,以 **对象优先顺序** 渲染。
示例渲染回调
让我们看一些遵循这种基本模式的真实代码。由于我们在上面的示例中将此函数命名为 myDrawFrame()
,因此我们将在这里继续使用它。
let lastFrameTime = 0;
function myDrawFrame(currentFrameTime, frame) {
const session = frame.session;
let viewerPose;
// Schedule the next frame to be painted when the time comes.
animationFrameRequestID = session.requestAnimationFrame(myDrawFrame);
// Get an XRViewerPose representing the position and
// orientation of the viewer. If successful, render the
// frame.
viewerPose = frame.getViewerPose(viewerRefSpace);
if (viewerPose) {
const glLayer = session.renderState.baseLayer;
gl.bindFrameBuffer(gl.FRAMEBUFFER, glLayer.framebuffer);
// Start by erasing the color and depth framebuffers.
gl.clearColor(0, 0, 0, 1.0);
gl.clearDepth(1.0);
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
// Compute the time elapsed since the last frame was rendered.
// Use this value to ensure your animation runs at the exact
// rate you intend.
const deltaTime = currentFrameTime - lastFrameTime;
lastFrameTime = currentFrameTime;
// Now call the scene rendering code once for each of
// the session's views.
for (const view of viewerPose.views) {
const viewport = glLayer.getViewport(view);
gl.viewport(viewport.x, viewport.y, viewport.width, viewport.height);
myDrawSceneIntoView(view, deltaTime);
}
}
}
myDrawFrame()
函数从 frame
参数指定的 XRFrame
对象中获取 XRSession
,然后调用会话的 requestAnimationFrame()
方法,以立即安排下一帧的渲染。这确保我们立即进入队列,使在 myDrawFrame()
函数的这次迭代中花费的剩余时间计入绘制下一帧的时间。
然后,我们使用帧的 getViewerPose()
方法获取描述观察者姿态(其位置和方向)的 XRViewerPose
对象,并将之前 在设置 WebXR 会话时 获取的观察者的参考空间传递给它。
有了观察者的姿态,我们就可以开始渲染帧了。第一步是获取对帧缓冲区的访问权限,WebXR 设备希望将帧绘制到该帧缓冲区中;这是通过从会话的 renderState
对象的 baseLayer
属性获取目标 WebGL 层,然后从该 XRWebGLLayer
对象获取 framebuffer
来完成的。然后,我们调用 gl.bindFrameBuffer()
将该帧缓冲区绑定为所有即将到来的绘图命令的目标。
下一步是清除帧缓冲区。虽然理论上您可以跳过此步骤,前提是您的渲染代码保证写入帧缓冲区中的每个像素,但通常最安全的方法是在开始绘制之前清除它,除非您需要竭尽全力获得所有可能的性能,并且知道您已经触碰了所有像素。背景颜色设置为完全不透明的黑色,使用 gl.clearColor()
设置;通过调用 gl.cleardepth()
将清除深度设置为 1.0,以便清除所有像素,无论它们所属的物体距离多远;最后,通过调用 gl.clear()
并传递一个位掩码,其中 COLOR_BUFFER_BIT
和 DEPTH_BUFFER_BIT
都已设置,来清除帧的像素和深度缓冲区。
由于 WebXR 对每个视图使用一个帧缓冲区,并且在视图上的视口用于在帧缓冲区内分离每只眼睛的视点,因此我们只需要清除一个帧缓冲区,而不是分别为每只眼睛(或其他视点,如果有)清除它。
接下来,通过从当前时间(由 `currentFrameTime` 参数指定)减去保存的上次渲染帧时间 `lastFrameTime` 来计算自上次渲染帧以来的时间差。结果是一个 DOMHighResTimeStamp
值,表示自上次渲染帧以来的毫秒数。在绘制场景时,我们可以使用此值来确保以适当的距离移动所有内容,以反映真实的时间差,而不是假设回调将以一致的帧速率触发。此时间差保存在变量 `deltaTime` 中,并且 `lastFrameTime` 的值将被替换为当前帧的时间,准备计算下一帧的差值。
现在是为每个眼睛实际渲染场景的时候了。我们遍历观察者姿态的 views
数组中的视图。对于每个表示眼睛对场景视角的 XRView
对象,我们需要首先将绘制限制在帧缓冲区中代表当前眼睛可见图像的区域。
我们首先通过获取视口来准备 WebGL 渲染眼睛的内容,该视口将绘制限制在帧缓冲区中为当前眼睛图像保留的区域,方法是调用 XRWebGLLayer
方法 getViewport()
。然后我们将 WebGL 视口设置为匹配,将视口的 X 和 Y 原点以及宽度和高度传递到 gl.viewport()
中。
最后,我们调用我们的方法 `myDrawSceneIntoView()` 来实际使用 WebGL 渲染场景。我们向其中传递了表示我们正在绘制的眼睛的 XRView
(为了执行透视映射等)以及 `deltaTime`,以便场景绘制代码可以在确定随时间移动的对象的位置时准确地表示时间差。
当遍历视图的循环结束时,表示场景到观察者的所有图像都已渲染,并且在返回时,帧缓冲区将通过 GPU 传递,最终到达 XR 设备的显示器或显示器。由于我们在函数开头调用了 requestAnimationFrame()
,因此当需要渲染场景动画的下一帧时,我们的回调将再次被调用。
这种方法的缺点
由于尽可能减少在此函数中花费的时间很重要,因此您花费在处理状态更改上的时间越多,您实际绘制事物的时间就越少。这种技术对于少量对象非常有效,但因为它必须为每个对象重新绑定所有数据两次(一次用于左眼,一次用于右眼),因此您花费了大量时间调整状态,上传缓冲区和纹理等等。在下一节中,我们将介绍一种改变的方法,它可以大幅减少这些状态更改,从而提供一种可能更快的渲染方法,尤其是在对象数量增加的情况下。
通过以对象优先的顺序渲染进行优化
WebXR 使用单个 WebGL 帧缓冲区来包含左眼和右眼的视图在一个帧缓冲区中的方法的优势在于,可以大幅提高渲染性能,方法是重新排列完成事物的顺序。与其为给定视图(例如左眼)设置视口,然后逐个渲染所有对左眼可见的对象,并在进行时为每个对象重新配置缓冲区,不如将每个对象渲染两次,一次用于每只眼睛,从而只需要为两只眼睛设置一次缓冲区、制服和等等。
最终的伪代码如下所示
for each object in the scene bindProgram() bindUniforms() bindBuffers() bindTextures() for each view in the pose's views list get the XRWebGLLayer's viewport set the WebGL viewport to match bindVertices() bindMatrices() drawMyObject()
通过这种方式改变事物,我们只在每帧绑定一次程序、制服、缓冲区、纹理和潜在的其他内容,而不是在场景中发现的每个对象绑定两次。这将潜在的巨大幅度减少了开销。
限制帧速率
如果您需要有意地限制帧速率,以便在允许更多时间运行其他代码的同时,建立一个基本帧速率以尝试维护,您可以通过有意识地跳过帧来实现这一点,并在计时基础上。
例如,要将帧速率降低 50%,只需跳过每隔一帧
let tick = 0;
function drawFrame(time, frame) {
animationFrameRequestID = frame.session.requestAnimationFrame(drawFrame);
if (!(tick % 2)) {
/* Draw the scene */
}
tick++;
}
此渲染回调版本维护一个 `tick` 计数器。只有当 `tick` 是一个偶数值时才会渲染帧。这样,只有每隔一帧才会被渲染。
您可以类似地使用 `!(tick % 4)` 渲染每第四帧,等等。
将动画与时间差匹配
渲染回调接收 `time` 参数是有充分理由的。这个 DOMHighResTimeStamp
值是一个浮点值,表示调度帧渲染的时间。因为您的回调的执行不会以精确的 1/60 秒的间隔发生——并且实际上,如果用户的显示器具有不同的帧速率,可能会以其他速率发生——您不能依赖于您的代码运行的简单事实来假设自上次帧以来已经过去了 1/60 秒。
因此,您需要使用提供的时间戳来确保您的动画以精确的所需速度渲染。为此,您需要做的第一件事是计算自上次渲染帧以来经过的时间
let lastFrameTime = 0;
function drawFrame(time, frame) {
// schedule next frame, prepare the buffer, etc.
const deltaTime = (time - lastFrameTime) * 0.001;
lastFrameTime = time;
for (const view of pose.views) {
/* render each view */
}
}
这维护了一个名为 `lastFrameTime` 的全局变量(或对象属性),其中包含前一帧的渲染时间。在这种情况下,由于时间值以毫秒存储,因此我们乘以 0.001 将时间转换为秒。在某些情况下,这可以节省以后的时间。在其他情况下,您需要以毫秒为单位的时间,因此您不需要进行任何更改。
有了时间差,您的渲染代码就可以计算出每个移动对象在经过的时间内移动了多少。例如,如果一个对象正在旋转,您可能会像这样应用旋转
const xDeltaRotation =
xRotationDegreesPerSecond * RADIANS_PER_DEGREE * deltaTime;
const yDeltaRotation =
yRotationDegreesPerSecond * RADIANS_PER_DEGREE * deltaTime;
const zDeltaRotation =
zRotationDegreesPerSecond * RADIANS_PER_DEGREE * deltaTime;
这计算了自上次绘制帧以来对象围绕三个轴中的每一个旋转了多少。如果没有这个,形状会在每一帧旋转给定量,而不管经过的时间如何。这在许多情况下会导致严重卡顿。
相同概念适用于移动而不是旋转的对象
const xDistanceMoved = xSpeedPerSecond * deltaTime;
const yDistanceMoved = ySpeedPerSecond * deltaTime;
const ZDistanceMoved = zSpeedPerSecond * deltaTime;
xSpeedPerSecond
、ySpeedPerSecond
和 zSpeedPerSecond
各自包含该轴的物体速度分量。换句话说,[xDistanceMoved, yDistanceMoved, zDistanceMoved]
是一个向量,表示物体的速度。
与动画场景相关的其他任务
当然,可能还需要在每次遍历渲染器时执行其他操作。两种最常见的操作是 处理用户输入 并根据已知因素(例如用户控制状态或场景中对象的已知动画路径)更新对象(或观察者)的位置。
处理用户控制输入
用户在使用 WebXR 应用程序时可能通过三种方法提供输入。首先,WebXR 支持直接处理与 XR 硬件本身集成的控制器的输入。这些输入源可能包括手柄控制器、光学跟踪系统、加速度计和磁力计以及类似的设备。
第二种类型的输入是通过 XR 系统连接的游戏手柄。这使用从 游戏手柄 API 继承的接口,但您可以通过 WebXR 与它们进行交互。
第三种也是最后一种类型的输入是传统非 XR 输入设备,例如键盘、鼠标、触控板、触摸屏以及非 XR 游戏手柄和操纵杆。
可以从 XR 硬件直接收集的方位和位置信息会自动应用。因此,您需要自己处理其他类型的输入
- 指向设备目标和按钮按下
- 游戏手柄输入
- 非 XR 输入设备输入
要详细了解如何在使用 WebXR 展示场景时处理用户输入,请参阅文章 输入和输入源。
更新对象位置
大多数(但并非全部)场景都包含某种形式的动画,其中事物以适当的方式移动并相互反应。
例如,虚拟现实或增强现实游戏可能会有由计算机控制并在场景中四处移动的敌人非玩家角色。不仅它们在世界中的位置会随着时间推移而改变,而且每个 NPC 可能有身体部位或组件在相互之间移动。手臂和腿在生物行走时摆动,头部晃动和转动,头发弹跳和摇摆,躯干在角色呼吸时膨胀和收缩。
此外,可能还有一些运动的物体和结构。在体育游戏中,可能有一个球在空中划弧,需要模拟它的运动。在赛车游戏中,可能会有汽车或其他车辆,它们有需要动画的活动部件,包括车轮。如果场景中有水,则需要水波纹或波浪才能看起来逼真。结构的某些部分可能正在移动,例如门、墙和地板(对于某些类型的游戏),等等。
玩家本身是运动的另一个常见来源。在解释来自控制器的输入(包括与 XR 相关的输入和其他输入)后,您需要将这些更改应用到场景中,以模拟用户的移动。有关详细信息以及此工作原理的完整示例,请参阅文章 移动、方位和运动。
下一步
一旦您写好了渲染器——或者至少得到了可以正常工作的东西,即使它还没有完成——您就可以开始处理相机及其在场景中的移动。这在我们的关于 视点和观察者 的 WebXR 文章中有所介绍。