渲染和 WebXR 帧动画回调
一旦你的 WebXR 环境已经设置好,并且创建了一个 XRSession 来表示一个正在进行的 XR 环境会话,你需要向 XR 设备提供场景的帧进行渲染。本文将介绍在渲染循环中将 XR 场景帧驱动到设备的过程,使用 XRSession 获取表示每一帧的 XRFrame 对象,然后用它来准备帧缓冲区,以便交付给 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> 时,它会调用你的帧渲染回调,该回调使用指定的时间戳和任何其他相关数据(如模型和纹理)以及应用程序状态,将场景(在指定时间应显示的样子)渲染到 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 刷新。只要你的代码编写正确,一切都会正常运行。
渲染器性能考量
显然,每帧你渲染场景的时间非常少。不仅如此,如果你的渲染器本身运行时间超过该时间,不仅可能导致帧被丢弃,而且该时间会完全浪费,阻止其他代码在该帧中运行。
不仅如此,如果你的渲染跨越了垂直刷新边界,你可能会遇到**撕裂**效应。当显示硬件开始下一个刷新周期而上一帧仍在屏幕上绘制时,就会发生撕裂。结果,你会看到屏幕上半部分显示新帧,而下半部分显示上一帧甚至可能是更早一帧的组合的视觉效果。
那么,你的任务就是让你的代码足够紧凑和轻量,以至于你不会超出可用时间,也不会导致丢帧或过度滥用主线程。
出于这些原因,除非你的渲染器相当小巧轻量,工作量不大,否则你应该考虑将所有能卸载的工作都卸载到 worker 中,这样你就可以在浏览器处理其他事情时计算下一帧。通过在实际需要帧之前准备好计算和数据,你可以使你的网站或应用程序渲染效率更高,从而提高主线程性能并普遍改善用户体验。
幸运的是,如果你的渲染需求特别繁重,可以使用一些技巧来进一步减少影响并优化性能。请参阅 WebXR 性能指南,了解可帮助你确保性能达到最佳水平的建议和技巧。
WebXR 帧
你的帧渲染回调函数接收两个参数作为输入:帧对应的时间,以及一个描述该时间场景状态的 XRFrame 对象。
3D 的光学原理
我们有两只眼睛是有原因的:通过两只眼睛,每只眼睛都能从稍微不同的角度看世界。由于它们之间存在一个已知且固定的距离,我们的大脑可以进行基本的几何和三角学计算,并从这些信息中找出现实的 3D 本质。我们还利用透视、大小差异,甚至我们对事物通常外观的理解来找出第三维度的细节。这些因素,以及其他因素,是我们深度知觉的来源。
为了在渲染图形时创建三维错觉,我们需要尽可能多地模拟这些因素。我们模拟的越多——以及我们模拟得越准确——我们就越能更好地欺骗人脑,使其以 3D 方式感知我们的图像。XR 的优势在于,我们不仅可以使用经典的单眼技术模拟 3D 图形(透视、大小和模拟视差),还可以通过为动画的每一帧渲染两次场景(每只眼睛一次)来模拟双眼视觉——即使用两只眼睛的视觉。
典型人类的瞳距(瞳孔中心之间的距离)在 54 到 74 毫米(0.054 到 0.074 米)之间。所以,如果观察者的头部中心位于 `[0.0, 2.0, 0.0]`(在水平空间中心地面以上约两米),我们首先需要从,比如说,`[-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 会话时获得的 viewerRefSpace 中的观察者参考空间。
有了观察者的姿态,我们就可以开始渲染帧了。第一步是获取 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 对象,我们需要首先将绘制限制在帧缓冲区中代表当前眼睛可见图像的区域。
我们首先通过调用 XRWebGLLayer 方法 getViewport() 获取将绘图限制在帧缓冲区内为当前眼睛图像保留的区域的视口,从而准备 WebGL 渲染眼睛的内容。然后,我们通过将视口的 X 和 Y 原点及其宽度和高度传入 gl.viewport() 来设置 WebGL 视口以匹配。
最后,我们调用方法 myDrawSceneIntoView() 来实际使用 WebGL 渲染场景。我们将代表我们正在绘制的眼睛的 XRView(为了执行透视映射等)和 deltaTime 传入此方法,以便场景绘制代码在确定随时间移动的物体位置时能够准确表示经过的时间。
当遍历视图的循环结束时,表示场景给观察者所需的所有图像都已渲染,并且返回时,帧缓冲区将通过 GPU 并最终到达 XR 设备的显示器。由于我们在函数顶部调用了 requestAnimationFrame(),因此当需要渲染场景动画的下一帧时,我们的回调将再次被调用。
这种方法的缺点
由于尽可能减少在此函数中花费的时间很重要,因此花在处理状态更改上的时间越多,实际绘制事物的时间就越少。这种技术对于少量对象非常有效,但由于它必须为每个对象重新绑定所有数据两次(左眼一次,右眼一次),因此你会花费大量时间调整状态、上传缓冲区和纹理等等。在下一节中,我们研究了一种修改后的方法,该方法大大减少了这些状态更改,提供了一种可能快得多的渲染方法,尤其是在对象数量增加时。
通过以对象优先顺序渲染进行优化
WebXR 方法的优势在于使用单个 WebGL 帧缓冲区来包含左右眼的视图,这使得通过重新安排操作顺序可以显着提高渲染性能。而不是为给定视图(例如左眼)设置视口,然后逐一渲染左眼可见的每个对象,并在操作过程中为每个对象重新配置缓冲区,你可以改为连续渲染每个对象两次,每只眼睛一次,从而只需为两只眼睛设置一次缓冲区、uniforms 等。
生成的伪代码看起来像这样
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()
通过这种方式改变,我们每帧只绑定程序、uniform、缓冲区、纹理以及可能其他东西一次,而不是场景中每个对象两次。这极大地减少了开销。
限制帧速率
如果你需要有意限制帧速率,以建立一个基线帧速率来尝试保持,同时为其他代码留出更多运行时间,你可以通过有意识地、定时地跳过帧来实现。
例如,要将帧速率降低 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 相关的和非 XR 相关的)的输入后,你需要将这些更改应用到场景中,以模拟用户的运动。请参阅文章 移动、方向和运动,了解详细信息和有关其工作原理的全面示例。
后续步骤
一旦你写好了渲染器——或者至少写出了一个能用的东西,即使它还没完成——你就可以开始处理摄像机及其在场景中的移动了。这在我们的关于 WebXR 中的视点和观察者的文章中有所涉及。