ReactVR-入门手册-二-

103 阅读1小时+

ReactVR 入门手册(二)

原文:zh.annas-archive.org/md5/BB76013B3798515A13405091AD7CB582

译者:飞龙

协议:CC BY-NC-SA 4.0

第七章:坐在(虚拟)茶壶旁

在上一章中,我们了解了很多关于多边形以及如何在实时图形中使用它们的知识。我们将继续使用多边形,并学习更多关于给它们贴图的知识。

在本章中,我们将学习以下内容:

  • 如何使用 Blender 的基础知识

  • 如何应用基本的 UV 纹理映射

  • 如何导出纹理映射

  • 如何创建 MTL 文件以正确显示实时 OBJ 纹理和材质

  • 为我们的茶壶画廊整合一切

Blender 只是许多多边形建模器之一,您可以使用它来制作用于 WebVR 的虚拟对象。如果您已经熟悉多边形建模的概念,并且创建和编辑 UV 映射,那么您实际上不需要本章的大部分内容。一旦我们完成 UV 映射,我们就将模型导入到世界中。我还将本章的静态文件放在了bit.ly/VR_Chap7,这样您就可以下载它们,而不是自己构建它们。

UV 建模可能会很乏味。如果您只是下载文件,我不会介意的。但请浏览以下内容,因为我们构建这些模型时,我们将把它们放在虚拟世界中。

在 Blender 中的茶壶

要学习如何 UV 映射,让我们在 Blender 中放一个茶壶。今天,这将运行得相当顺利,但通常茶壶不会适合在 Blender 中。

您可以在blender.org下载 Blender。在那里,我强烈推荐网站上的教程bit.ly/BlendToots。Packt 还有很多关于 Blender 的好书。您可以在bit.ly/BlenderBooks找到这些书。如果您还没有通过这些教程,对基本的光标移动和选择可能会感到有些困惑或沮丧;看到光标移动的动画比写作更有帮助。特别是,请观看入门下的光标选择教程:bit.ly/BlendStart

为了开始贴图,我们将使用 Martin Newell 的著名的“犹他州茶壶”。这是计算机图形学中更著名的“测试模型”之一。这是原始的犹他州茶壶,目前在加利福尼亚州山景城的计算机历史博物馆展出(由 Marshall Astor 提供):

计算机图形学版本被压扁在演示中,这种压扁是固定的。您可以在bit.ly/DrBlinn了解更多信息。

这是 Blender 中的茶壶。您可以通过在首选项中打开额外形状来到这里:

  1. 点击菜单文件,然后用户首选项(文件->用户首选项),然后点击额外对象:

  1. 不要忘记然后点击屏幕底部的按钮“保存用户设置”,否则下次进入时对象将不在那里。保存后,关闭 Blender 用户首选项窗口。

  2. 然后,在 3D 窗口底部的菜单上,点击“添加->网格->额外->茶壶+”:

  1. 一旦你这样做了,仅供教学目的,选择左下角窗格上的分辨率为 3,如图所示。

增加茶壶的分辨率是相当不错的;如果我早点注意到这一点,写这一章节时就可以节省我一个小时在互联网上搜索了。我们将其更改为 3,以使多边形更大,这样在进行本教程时更容易点击。

  1. 然后,您要在 3D 窗口中点击茶壶(左键)以选择它;然后茶壶将有一个橙色的轮廓。然后通过点击对象菜单旁边的“对象模式”一词,返回到编辑模式,然后选择“编辑模式”:

一旦你进入编辑模式,我们需要在选择茶壶的多边形时能够看到 UV 贴图。最初,可能不会有 UV 贴图;继续跟着我们,我们会创建一个。

  1. 将鼠标放在时间轴窗口上方的细线上,在屏幕底部的窗口(以下截图中用红色圈出的区域)上拖动窗口向上。这将为窗口留出足够的空间。

  1. 我们不做动画,所以我们不需要那个窗口,我们会把它改成 UV 显示。要做到这一点,点击时间轴显示的小时钟图标(哇,还记得模拟时钟吗?),选择 UV/Image Editor:

这只是改变窗口布局的一种方式。在 Blender 中令人困惑的一点是,你可能会因为不小心点击了一些东西而真正搞乱你的用户界面,但其中一个很棒的地方是你可以通过鼠标点击轻松地创建窗口、子窗口、拉出、架子等等。我刚刚向你展示的方法是教学中最直接的方式,但对于真正的工作,你应该按照自己的意愿自定义窗口。

一旦你改变了这个视图,请注意你可以像其他 Blender 窗口一样放大、平移和移动窗口。关于如何放大、平移等等,你应该观看位于bit.ly/BlendStart的教程视频文件。

  1. 所以,我们可以看到我们的模型使用我们的纹理是什么样子的;点击“打开”并找到一个你想要映射到你的茶壶(或模型)上的纹理文件。我正在使用ButcherTile_Lettered.jpg

  2. 完成后,进行第一次 UV 展开!在上窗口的菜单中,点击 Mesh->UV Unwrap->Unwrap,就像这样:

在底部窗口,它会显示出纹理的展开情况。

看起来很糟糕。你的结果可能会因不同的模型而有所不同。

为什么这个 UV 贴图看起来很糟糕?从实时图形的角度来看,它并不糟糕;它将所有多边形都打包到一个纹理贴图上,这将有助于视频卡的内存:

对于一些物体来说,这可能没问题。如果你看右上角和右下角,我们可以看到壶嘴和手柄,它们看起来有点奇怪。渲染出来可能会有点滑稽;让我们看看它的效果。为了做到这一点,我们必须分配一些纹理,然后导出茶壶。(我们稍后会介绍导出;现在,我们只需要看到我们在 Blender 中还有额外的工作要做。)

请注意,你可以通过在 Blender 内部渲染来快速查看,但这可能会让你失望,因为 Blender 几乎肯定会以完全不同的方式渲染你的模型。总体的颜色和纹理将是相同的,但 React VR 和 WebGL 能够实现的更微妙(也更重要)的纹理细节将会丢失(或者更好的是,使用离线、非实时渲染器);相反,如果你真的在 Blender 中工作或者想要更好的效果,渲染可以产生惊人的作品。

例如,在 Blender 中,使用循环渲染器,渲染我们的茶壶花了 11.03 秒。

在 React VR 中,为了保持至少 60 帧每秒,这必须在不到 0.016 秒内完成。而 Blender 花了 600 多倍的时间来生成相同的图像;难道它不应该看起来更好吗?茶壶看起来并不差,但 UV 映射只是很奇怪。

我们可以看到方块在茶壶上有点奇怪地拉长了。(如果你停下来想想我们在做什么,我们只是在茶壶上放了一个瓷砖图案;这就是计算机图形的奇迹。我正在使用棋盘格图案,所以我们可以看到壶上的拉伸。以后,我会用 Substance Designer 制作一个更好的纹理。)

你可以在 Blender 中进行实验,点击多边形(在编辑模式中),看看该多边形在 UV 映射中的位置。为了辩护 Blender,这个映射并不是很糟糕,只是不是我们想要的。有时(几乎总是),需要一个人来真正创作艺术。

修复茶壶的 UV 映射

为了更容易地给壶上纹理,首先让我们为壶嘴、手柄和盖子创建单独的材料。这将使我们的纹理地图更大,拉伸得更少。你也可以通过将纹理打包在一个更大的位图中来做到这一点,老实说,有时这对于 VR 来说更好一些;总体方法是相同的,只是更多地打包在一个较小的区域内。

让我们为壶、手柄、壶嘴和盖子创建四种材料(你应该仍然处于编辑模式)。

  1. 点击那个看起来有点像闪亮的地球的小图标。然后,点击“+”键四次,如图所示,然后点击“新建”:

  1. 一旦你点击了“+”键四次,你将有四个我们正在创建的材料的插槽。然后你点击“新建”来实际添加一个材料。这似乎有点笨拙,但这就是 Blender 的工作方式:

  1. 点击“新建”时,你会得到一个 Material.001:

  1. 你可以点击红圈中的区域并更改名称。这样,创建四种材料,如下所示:

  2. 创建一个壶材料(将是陶瓷涂层金属)。

  3. 创建一个盖子材料(和壶一样的纹理)。

  4. 创建一个壶嘴材料(让我们把它做成铜制的)。

  5. 创建一个手柄材料(让我们把它做成磨损的橡胶)。

我们并不真的需要创建这些材质;你可以在几个 UV 上叠加相同的纹理贴图,但我想对茶壶进行一次新的尝试(正如我们所看到的,它是一个实心的陶瓷制品),看到不同的材质是有益的。

现在这些额外的材质已经创建,你可以移动 UV 以更好地映射对象。UV 映射是一个庞大的主题,需要一定的技术和艺术技能才能做好,或者 PC 可以自动完成。这超出了本书的范围,但我会向你展示一个快速而粗糙的方法来对一些常见的物体进行 UV 映射。你在网上找到的许多文件可能没有应用良好的 UV 映射,所以你可能会发现自己处于这样一种情况,你认为自己不需要学习建模,但会用它来纠正 UV 映射(这在多边形建模时是一个相当高端的活动!)。

一旦你创建了这四种材质,你可以将每个部分独立地映射到自己的 UV 映射上;当我们在 VR 世界中展示时,我们将为每个部分使用不同的纹理贴图。如果你想制作一个单独的陶瓷壶,你可以使用相同的纹理贴图,但我们破旧的金属壶可能看起来更好。

这是艺术;美在于观者的眼中。

一旦你像上面那样确定了四种材质,选择每个主要区域的多边形,然后点击“分配”使它们成为这种材质的一部分:

  1. 按下键盘上的“A”键(或选择->(取消)选择所有| A)取消选择所有的多边形。然后我们将选择每个区域的多边形,盖子、把手、壶嘴和壶(主体)。

  2. 切换到“多边形选择”。Blender 有不同的选择模式-点、线、多边形。对于这个,你需要切换到选择多边形,点击这个图标:

  1. 点击主壶多边形,使用Shift + 点击选择多个多边形。Blender 拥有丰富的选择工具,如框选等,可以参考教程:bit.ly/BlendStart

  2. 一旦你选择了主体的多边形,点击“分配”按钮将该多边形分配给一个材质,比如“壶”材质。

  1. 一旦你分配了多边形,点击“视图->前视”,然后点击“网格->UV 展开->圆柱投影”。然后在我们之前设置的图像编辑器中会有一个 UV 映射,尽管它会从你分配的图像上拉伸出来。

  2. 要解决这个问题,在屏幕下半部分的菜单中,选择 UVs->Pack Islands:

这是基本的纹理映射。你可以对此进行很多调整(这可能会让人沮丧)。Blender 有许多有用的自动 UV 分配工具;在 3D(建模)窗口中,正如我们之前看到的那样,Mesh->UV Unwrap->(选项)提供了许多解包的方法。我发现从视图投影以及圆柱投影,都可以从严格的上/下/左/右视图中很好地展开 UV。在说了这些之后,一些艺术性就会发挥作用。壶嘴、壶盖和手柄比壶身小,所以如果你希望你的纹理与主要的壶和纹理更或多或少地对齐,你可能需要浪费一些 UV 空间并将这些部分缩小。

或者你可以从 GitHub 文件中下载teapot2.objteapot2_Mats.mtl,并节省一些理智:bit.ly/VR_Chap7

这四个 UV 映射不错(但是请随意学习,研究,做得更好!我不是艺术家!)。主体的 UV 映射,壶的材质在这里显示:

盖子材质的 UV 映射:

手柄材质的 UV 映射(故意缩小,以使方块与主壶更或多或少对齐):

壶嘴材质的 UV 映射(故意缩小,以使方块与主壶更或多或少对齐):

使用这些 UV 分配,我们的茶壶显示两次,在每次之间略微旋转,看起来好多了:

你可以对 UV 进行很多调整。在前面的截图中,如果我们要在壶上映射大部分是瓷砖方块的纹理,我们可以看到,尽管手柄和壶嘴与主体相匹配得很好,但是盖子,虽然看起来没有我们第一张图片那样拉伸,但仍然比其他方块小一点。解决这个问题的方法是进入 3D 面板,仅选择盖子多边形(首先按下"a"直到没有选择任何内容),转到属性选项卡中的材质,点击盖子材质,“选择”以选择所有多边形,然后转到 UV 窗口,将 UV 映射的多边形缩小一点。

然而,在我们的情况下,无论如何,我们都希望为这些物品制作完全不同的材料,所以在这一点上过于担心 UV 可能是错误的。

你的效果可能会有所不同。

导入材料

同时,我们可以利用 React VR 在材料方面提供的所有功能。不幸的是,MTL 文件并不总是具有可能的值。如果您使用的是现代材料,具有基本颜色、凹凸贴图或法线贴图、高度、镜面(光泽)或金属(类似于光泽)贴图,我发现您可能需要手动编辑 MTL 文件。

你可能会认为有这么多的计算机图形程序,我们不会到这一步。不幸的是,不同的渲染系统,特别是基于节点的系统,对于 OBJ 导出器来说太复杂,无法真正理解;因此,通常随 OBJ 文件一起使用的大多数 MTL 文件(材料)只有基本颜色作为纹理贴图。

如果您使用 Quixel 或 Substance Designer 等程序,大多数基于物理的渲染PBR)材料由以下大部分纹理贴图(图像)组成,这也受到 OBJ 文件格式的支持:

  • 基本颜色:这通常是材料的外观,几乎总是与大多数 CAD 系统一起导出到 OBJ(MTL)文件中作为map_Ka

  • 漫反射贴图:通常与基本颜色相同,它是物体的“漫反射”颜色。您可以将其实现为map_Ka

  • 凹凸贴图:凹凸贴图是“高度”信息,但不会物理变形多边形。它们看起来像是被雕刻的,但如果你仔细看,多边形实际上并没有位移。这可能会在 VR 中引起问题。你的一只眼睛会说这是凹陷的,但你的立体深度感知会说不是。然而,在适当的情况下,凹凸可以让事物看起来非常好。在 MTL 文件中写为bump

  • 高度贴图:与凹凸贴图非常相似,高度贴图通常会在物体表面上物理位移多边形。然而,在大多数网络渲染中,它只会位移建模的多边形,因此比离线渲染器要不太有用。(游戏引擎可以进行微位移。)

  • 法线贴图:法线贴图是一种 RGB 表示,比高度或凹凸贴图更复杂,后者是灰度。法线贴图是 RGB 贴图,可以使多边形向位移,而不仅仅是上下。现代游戏引擎会从高分辨率(数十万到数百万)模型计算法线贴图到低分辨率模型。它使得简单多边形的物体看起来像是由数百万多边形构建而成。它可能会或可能不会在物体上产生物理变形(取决于着色器)。它不受 OBJ/MTL 文件格式直接支持,但受到 WebGL 和 three.js 的支持,尽管实现留给读者自行完成。

  • 高光贴图:这控制着物体的光泽度。通常是灰色贴图(没有颜色信息)。更具体地说,高光贴图控制着纹理的某个区域是否有光泽。这是 map_Ns。Map_Ks 也是高光贴图,但控制着高光的颜色。例如,可以用于汽车上的“幽灵漆”。

  • 光泽度:与高光不完全相同,但经常被混淆。光泽度是指高光的亮度;它可以是宽泛但有光泽,如暗橡胶,也可以是紧致而有光泽,如糖苹果或铬。基本上是应用于高光贴图的。通常与 PBR 一起使用,不受 OBJ/MTL 文件格式支持。

  • 粗糙度:与高光和光泽度贴图非常相似,通常是替代或与前者一起使用。通常与 PBR 一起使用,不受 OBJ/MTL 文件格式支持。

  • 反射率:一般来说,OBJ 文件格式用于离线渲染,进行射线追踪反射,近似模拟真实世界的工作方式。出于性能原因,WebGL 并不对所有内容进行射线追踪,但可以使用反射贴图模拟反射。在 OBJ 文件中,反射的程度是静态的;你无法直接制作斑驳的反射。这个贴图在 OBJ 文件中被编码为refl,但在 OBJ/MTL 文件格式中,React VR 不模拟它。

  • 透明度:映射为dmap_d。(d 在原始 MTL 文件中代表“密度”)。这不是折射透明度;光线要么穿过要么不穿过。对于玻璃瓶之类的物体很有用,但 React VR 不使用。

  • 贴花:这会在物体顶部应用模板,并且非常有用,可以避免重复的纹理外观,并在顶部添加文字。在 MTL 中,文件被编码为decal。这可能非常有用,并且在 React VR 中支持贴花。但是,我发现大多数建模者不会导出它,因此您可能需要手动编辑材质文件以包含贴花。这并不太糟糕,因为通常您的世界中的不同模型将具有不同的贴花(例如标志、污渍等)。

修复甲板板

现在我们已经学会了如何进行 UV 映射,让我们修复那些用来表示甲板板的立方体。在对基本的 React VR 对象进行纹理处理时,我们发现,立方体在所有六个面上都表示相同的纹理。因此,当我们制作一个薄的立方体,就像我们为基座的顶部和底部或甲板板所做的那样时,纹理贴图在侧面看起来“挤压”。红色箭头显示了挤压的纹理;这是因为我们有一个高度只有.1,宽度为 5 的盒子,而纹理是正方形的(双重红色箭头),所以看起来被挤压了。

我们可以在 Blender 中用一个立方体来修复这个问题。我们还将添加我们下载的额外纹理贴图。

我有 Substance Designer,这是一个很棒的纹理工具;还有许多其他工具,比如 Quixel。它将根据您的设置输出不同的纹理贴图。您还可以使用各种软件包来烘焙纹理。WebGL 将允许您使用着色器,但这有些复杂。它通过 React Native 支持,但目前有点困难,因此让我们讨论不同材质值的个别纹理贴图的情况。通常在.obj 文件中,这将会分解为这样的情况(.obj 没有现代 GPU 着色器的概念):

  1. 在 Blender 中创建一个立方体,并调整其大小(在编辑模式中),使其比宽或高短得多。这将成为我们的甲板板。在我们的 VR 世界中,我们将其设置为 5x5x.1,因此让 Blender 立方体也设置为 5x5x.1。

  2. 然后,我们粗略地对其进行纹理贴图,如下所示:

  1. 将其导出为 OBJ 并选择以下参数;重要的参数是-Z 向前,Y 向上(Y 向上!)和 Strip Path(否则,它将包括您的物理磁盘位置,显然无法从 Web 服务器中调用):

一旦完成这些,我们将以困难但直接的方式来做,即修改甲板板的 MTL 文件,直接包含我们想要的纹理:

# Blender MTL File: 'DeckPlate_v1.blend'
# Material Count: 1 newmtl Deck_Plate

Ns 96.078431
Ka 1.000000 1.000000 1.000000
Kd 0.640000 0.640000 0.640000
Ks 0.500000 0.500000 0.500000
Ke 0.000000 0.000000 0.000000
Ni 1.000000
d 1.000000
illum 2
map_Kd 1_New_Graph_Base_Color.jpg
bump -bm 0.01 1_New_Graph_Height.jpg # disp will be mostly ignored, unless you have a high-polygon cube
# disp -mm .1 5 1_New_Graph_Height.png
map_Ks 1_New_Graph_Metallic.jpg

位移纹理有点无用;当前的渲染引擎会应用位移贴图,但不会自动细分任何多边形以实现微位移。因此,你必须生成具有尽可能多多边形的几何体来进行位移。

如果你生成了那么多多边形,更好的方法是在建模程序中直接烘烤位移,并导出已经位移的多边形。这样无论如何都是相同数量的多边形,而且你有更多的控制。你也可以选择性地减少多边形数量,并仍然保留你的表面细节。

烘烤位移会显着增加场景中的顶点和多边形数量,所以这是一个权衡。在离线渲染器(非虚拟现实渲染)中使用位移贴图通常是为了减少多边形数量,但并不总是适用于虚拟现实。可能虚拟现实着色器会进行微位移和自适应细分,因为技术不断前进。

如果你得到一个刺眼的白色纹理,或者某些东西看起来不像你期望的那样,双重检查 node.js 控制台,并寻找 404,就像这样:

Transforming modules 100.0% (557/557), done.

::1 - - [20/Sep/2017:21:57:12 +0000] "GET /static_assets/1_New_Graph_Metallic_Color.jpg HTTP/1.1" **404** 57 "http://localhost:8081/vr

/?hotreload" "Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:57.0) Gecko/20100101 Firefox/57.0"

这意味着你拼错了纹理名称。

然后,我们将使用面向对象的设计编码来修改我们创建的类,这将更新所有的甲板板!将平台调用更改为新的甲板板 OBJ 文件,而不是一个盒子。

完成的虚拟现实世界

你的完整代码应该是这样的:

import React, {Component } from 'react';

import {
  AppRegistry,
  asset,
  AmbientLight,
  Box,
  DirectionalLight,
  Div,
  Model,
  Pano,
  Plane,
  Text,
  Vector,
  View,
  } from 'react-vr';

class Pedestal extends Component {
    render() {
        return (
          <View>
          <Box 
          dimWidth={.4}
          dimDepth={.4}
          dimHeight={.5}
          lit
          texture={asset('travertine_striata_vein_cut_honed_filled_Base_Color.jpg')}
          style={{
            transform: [ { translate: [ this.props.MyX, -1.4, this.props.MyZ] } ]
            }}
        />
          <Box 
          dimWidth={.5}
          dimDepth={.5}
          dimHeight={.1}
          lit
          texture={asset('travertine_striata_vein_cut_honed_filled_Base_Color.jpg')}
          style={{
            transform: [ { translate: [ this.props.MyX, -1.1, this.props.MyZ] } ]
            }}
        />
          <Box 
          dimWidth={.5}
          dimDepth={.5}
          dimHeight={.1}
          lit
          texture={asset('travertine_striata_vein_cut_honed_filled_Base_Color.jpg')}
          style={{
            transform: [ { translate: [ this.props.MyX, -1.7, this.props.MyZ] } ]
            }}
          />
     </View>
    )
     }
     }

         class Platform extends Component {
             render() {
                 return ( 
                    <Model
                    source={{
                        obj: asset('DeckPlate_v1.obj'),
                        mtl: asset('DeckPlate_v1_AllMats.mtl'),
                        }}
                        lit
                        style={{
                            transform: [ {
                            translate: [ this.props.MyX, -1.8, this.props.MyZ]
                        }] }}
                    /> 

        );
          }
         }

export default class SpaceGallery extends React.Component {
    render() {
        return (
          <View>
            <Pano source={asset('BabbageStation_v6_r5.jpg')}/>
            <AmbientLight

    intensity = {.3}

    />
    <DirectionalLight
    intensity = {.7}
    style={{
        transform:[{
            rotateZ: -45
        }]
    }}
         /> 
         <Platform MyX={ 0.0} MyZ={-5.1}/>
         <Platform MyX={ 0.0} MyZ={ 0.0}/>
         <Platform MyX={ 0.0} MyZ={ 5.1}/>
         <Platform MyX={ 5.1} MyZ={-5.1}/>
         <Platform MyX={ 5.1} MyZ={ 0.0}/>
         <Platform MyX={ 5.1} MyZ={ 5.1}/>
         <Platform MyX={-5.1} MyZ={-5.1}/>
         <Platform MyX={-5.1} MyZ={ 0.0}/>
         <Platform MyX={-5.1} MyZ={ 5.1}/>

         <Pedestal MyX={ 0.0} MyZ={-5.1}/>
         <Pedestal MyX={ 0.0} MyZ={ 0.0}/>
         <Pedestal MyX={ 0.0} MyZ={ 5.1}/>
         <Pedestal MyX={ 5.1} MyZ={-5.1}/>
         <Pedestal MyX={ 5.1} MyZ={ 0.0}/>
         <Pedestal MyX={ 5.1} MyZ={ 5.1}/>
         <Pedestal MyX={-5.1} MyZ={-5.1}/>
         <Pedestal MyX={-5.1} MyZ={ 0.0}/>
         <Pedestal MyX={-5.1} MyZ={ 5.1}/>

         <Model
            source={{
                obj: asset('teapot2.obj'),
                mtl: asset('teapot2.mtl'),
                }}
                lit
                style={{
                    transform: [{ translate: [ -5.1, -1, -5.1 ] }]
                    }}
            />
            <Model
            source={{
                obj: asset('Teapot2_NotSmooth.obj'),
                mtl: asset('teapot2.mtl'),
                }}
                lit
                style={{
                    transform: [{ translate: [ -5.1, -1, 0 ] },
                    { rotateY: -30 },
                    { scale: 0.5} ]

                    }}
            />

            <Model
            source={{
                obj: asset('Chap6_Teapot_V2.obj'),
                mtl: asset('Chap6_Teapot_V2.mtl'),
                }}
                lit
                style={{
                    transform: [{ translate: [ -5.1, -1, 5.2 ] },
                    { rotateY: -30 },
                    { scale: 0.5} ]
                }}
            />

            <Model
            source={{
                obj: asset('Chap6_Teapot_V5_SpoutDone.obj'),
                mtl: asset('Chap6_Teapot_V5_SpoutDone.mtl'),
                }}
                lit
                style={{
                    transform: [{ translate: [ 5.1, -1, 0 ] },
                    { rotateY: -30 },
                    { rotateX: 45 },
                    { scale: 0.5} ]

                    }}
            />

            <Model
            source={{
                obj: asset('Chap6_Teapot_V5_SpoutDone.obj'),
                mtl: asset('Chap6_Teapot_V5_SpoutDone.mtl'),
                }}
                lit
                style={{
                    transform: [{ translate: [ 5.1, -1, 5.1 ] },
                    { rotateY: 46 },
                    { scale: 0.5} ]

                    }}
            />
        <Text
            style={{
                backgroundColor: '#777879',
                fontSize: 0.1,
                fontWeight: '400',
                layoutOrigin: [0.0, 0.5],
                paddingLeft: 0.2,
                paddingRight: 0.2,
                textAlign: 'center',
                textAlignVertical: 'center',
                transform: [ 
                    {translate: [-5.2, -1.4, -4.6] }]
                    }}>
            Utah teapot
        </Text>
        <Text
            style={{
                backgroundColor: '#777879',
                fontSize: 0.1,
                fontWeight: '400',
                layoutOrigin: [0.0, 0.5],
                paddingLeft: 0.2,
                paddingRight: 0.2,
                textAlign: 'center',
                textAlignVertical: 'center',
                transform: [ 
                    {translate: [0, -1.3, -4.6] }]
                    }}>
            One Tri
        </Text>

        &amp;amp;lt;Model
        lit
        source={{
            obj: asset('OneTriSkinnyWUVTexture_1.obj'),
            mtl: asset('OneTriSkinnyWUVTexture_1.mtl'),
            }}
            style={{
                transform: [
                    { translate: [ -0, -.8, -5.2 ] },
                    { rotateY: 10 },
                    { scale: .2 },
]
                }}
        />

         <Text
         style={{
             backgroundColor: '#777879',
             fontSize: 0.2,
             fontWeight: '400',
             layoutOrigin: [0.0, 0.5],
             paddingLeft: 0.2,
             paddingRight: 0.2,
             textAlign: 'center',
             textAlignVertical: 'center',
             transform: [ 
                {translate: [0, 1, -6] }]
         }}>
    Space Gallery
  </Text>
</View>
);
    }
};

AppRegistry.registerComponent('SpaceGallery', () => SpaceGallery);

这是一个很多要输入的内容,也是很多 UV 建模。你可以在这里下载所有这些文件:bit.ly/VR_Chap7

在上述代码中,我使用了这个:

<Platform MyX='0' MyZ='-5.1'/>

这样做是可以的,但更正确的做法是这样的:

<Platform MyX={0} MyZ={-5.1}/>

如果你懂 JSX 和 React,这将是一个明显的错误,但不是每个人都会注意到它(老实说,作为 C++程序员,我一开始也没有注意到)。花括号{}内的任何内容都是代码,而任何带引号的都是文本。文档中说:

Props - 组件可以接受参数,例如 <Greeting name='Rexxar'/>*中的名称。这些参数称为属性或 props,并通过 this.props 变量访问。例如,从这个例子中,名称可以作为{this.props.name}访问。您可以在组件、props 和状态下阅读更多关于这种交互的信息。

关于参数的提及仅适用于文本属性。对于数字属性,使用引号语法如'0.5*'*似乎可以工作,但会产生奇怪的后果。我们将在第十一章中看到更多内容,走进野生,但基本上,对于数字变量,您应该使用{0.5}(大括号)。

总结

在本章中,我们学习了如何使用 Blender 进行多边形建模,以及如何覆盖纹理分配并将纹理包裹在模型周围。我们学会了制作可以使您的世界看起来更真实的纹理。

然而,世界仍然是静态的。在下一章中,您将学习如何使事物移动,真正让您的世界生动起来。

第八章:给你的世界注入生命

在上一章中,我们通过材料使物体看起来更真实。我们知道这对于 VR 来说并不是完全必要的,正如我们在第一章中讨论的那样,虚拟现实到底是什么,但这确实有所帮助。现在,我们将学习如何通过使它们移动来使事物看起来真实。这样做有两个好处:移动的东西看起来更有生命力,而且还有助于视差深度感知。

React VR 具有许多 API,这将使包含流畅和自然的动画变得非常容易。在大多数传统 CGI 中,使动画流畅并不容易;您必须慢慢开始运动,加速到速度,然后轻轻地减速,否则运动看起来是假的。

我们将在本章中涵盖以下主题:

  • 用于动画化对象的Animated API

  • 一次性动画

  • 连续动画

  • 生命周期事件,如componentDidMount()

  • 如何将声音注入到世界中

运动和声音在使世界看起来活跃方面起到了很大作用。让我们来做吧!

动画 API

React 和 React VR 使这变得容易,因为动画 API 具有许多动画类型,使这变得简单易懂,无需进行数学计算或使用关键帧,就像传统动画一样。您可以逐渐增加事物,弹跳和停顿。这些属性是 spring,decay 和 timing;有关这些的更多详细信息,请参阅在线文档bit.ly/ReactAnims

动画是可以的,但我们需要知道我们要去哪里。为此,动画 API 具有两种值类型:标量(单个值)和矢量的 ValueXY。您可能会想知道为什么在这种情况下,矢量只是XY - ValueXY 是用于 UI 元素的,它们的性质是平的。如果您需要动画化 X,Y 和 Z 位置,您将使用三个标量。

首先,我们将创建一个旋转的动画茶壶。这将特别有助于了解我们的纹理映射是如何工作的。如果您一直在跟着代码,您的SpaceGallery应用程序应该已经具备我们开始编写本章所需的大部分内容。如果没有,您可以下载源文件开始:bit.ly/VR_Chap7。如果您真的不想输入所有这些,我把最终文件放在了:bit.ly/VR_Chap8

假设你已经下载或完成了上一章,从第七章中拿出index.vr.js与(虚拟)茶壶一起坐下,在文件的顶部但在import语句下面输入以下新类TurningPot()(请注意,我们仍然在SpaceGallery应用程序中)。

 class TurningPot extends React.Component {
    constructor(props) {
      super(props);
      this.state = {
        yRotation: new Animated.Value(0),
      };
    }

这设置了我们的动画值/变量—yRotation。我们已经将它创建为一个标量,这是可以的,因为我们将把它映射到rotateY

不要忘记import动画关键字。

接下来,我们将使用一个叫做componentDidMount的生命周期重写。生命周期重写是在加载和创建(渲染)VR 世界期间特定时间调用的事件;在这种情况下,componentDidMount函数在挂载后被调用(根据事件名称中“Did”片段的含义)。挂载意味着对象已加载、可用,并在 three.js 内创建;换句话说,它在世界中。componentWillMount函数在该组件即将被挂载但尚不存在时被调用;我们不使用这个函数,因为我们希望对象在实际可见对象时移动,尽管它对加载对象、初始化状态等非常有用。

请注意,我们还没有完成声明,所以最终的闭合{括号还没有出现:

   componentDidMount() {
        Animated.timing( 
          this.state.yRotation, // Animate variable `yRotation`
          {
            duration: 10000,    // Time
            toValue: 360,       // Spin around a full circle
          }
        ).start();              // Start the animation
      } 

componentDidMount()是一个重要的对象生命周期 API 调用,用于做像我们正在做的事情;开始动画。

这个事件很可能会在浏览器加载完所有内容之前发生,所以你可能会错过实际的开始。如果这是一个问题,你可以重载一些其他方法来确保它在正确的时间触发,或者引入一个小的延迟。

飞行的茶壶

现在是重要的事情,渲染本身。使用Animated.View关键字编写以下方法:

    render() {
      return (
        <Animated.View // Base: Image, Text, View
          style={{
            flex: 1,
            width: 1,
            height: 1,
            transform: [ 
              {rotateY: this.state.yRotation}, // Map yRotation to rotateY
            ]
          }}
          >
          <Model
          source={{
              obj: asset('teapot2.obj'),
              mtl: asset('teapot2_Mats.mtl'),
              }}
              lit
              style={{
                  transform: [{ translate: [0, -0.7, -5.1 ] }]
                  }}
          />
      </Animated.View>
      );
    }

  }

现在保存这个文件。如果你在 URL localhost:8081/vr/?hotreload 中使用了?hotreload,并且输入了一切正确,你会看到茶壶在你面前自动旋转。否则,点击浏览器中的“刷新”按钮。

等等,什么?刚刚发生了什么?为什么壶在飞!

茶壶围绕我们,即<view>的中心旋转,而不是围绕它自己的轴旋转。为什么会这样?记住翻译顺序很重要。在这种情况下,我们有一个单独的平移和旋转:

 render() {
      return (
        <Animated.View 
...
          {rotateY: this.state.yRotation}, // Map yRotation to rotateY
...
          <Model
...
                  transform: [{ translate: [0, -0.7, -5.1 ] }]
...
      </Animated.View>
      );

这里发生的是视图在旋转,然后模型在变换。我们希望以相反的顺序进行。一个解决方案是将模型保持在原地,并将render()循环更改为以下内容(注意粗体部分):

    render() {
      return (
        <Animated.View // Base: Image, Text, View
          style={{
            transform: [ 
 {translate: [0, -0.7, -5.1 ] },
 {rotateY: this.state.yRotation}, // Map `yRotation' to rotateY 
            ]
          }}
          >
          <Model
          source={{
              obj: asset('teapot2.obj'),
              mtl: asset('teapot2_Mats.mtl'),
              }}
              lit
              // we comment this out because we translate the view above
 // style={{
              // transform: [{ translate: [0, -0.7, -5.1 ] }]
              // }}
          />
      </Animated.View>
      );
    }

一旦旋转,永远

当我们保存这个文件并在 VR 浏览器中再次查看它时,我们会看到壶转动一次。请注意,我们可能看不到启动,并且当壶完成转动时,它会优雅地完成,而不是计算机动画的“猛然停止”:

这太棒了,但是壶转动然后停止了。我们可能希望它继续转动。所以让我们这样做!

修改组件创建以执行以下操作(是的,我们有点摆脱了所有酷炫的 Animate 关键字):

  class TurningPot extends React.Component {
    constructor(props) {
      super(props);
      this.state = {yRotation: 0};
      this.lastUpdate = Date.now();
      this.rotate = this.rotate.bind(this); 
    }

好的,在这部分,注意几件事。我们使用的变量称为yRotation;我们还使用了单词rotate,这实际上是一个新函数:

    rotate() { //custom function, called when it is time to rotate
        const now = Date.now();
        const delta = now - this.lastUpdate;
        this.lastUpdate = now;
        console.log("Spinning the pot");

        //note: the 20 is the rotation speed; bad form to
        //hard code it- this is for instructional purposes only
        this.setState({yRotation: this.state.yRotation + delta / 20} );
        //requestAnimationFrame calls the routine specified, not a variable
        this.frameHandle = requestAnimationFrame(this.rotate);
      } 

我们还需要改变对象的加载/卸载例程,既开始旋转,也结束定时器回调:

   componentDidMount() { //do the first rotation
        this.rotate();
    } 
    componentWillUnmount() { //Important clean up functions
        if (this.frameHandle) {
          cancelAnimationFrame(this.frameHandle);
          this.frameHandle = null;
        }
      } 

<View>本身不会改变;它只是像驱动函数一样旋转对象;这一次,我们使用一个名为render()的自定义函数来驱动它。

检查经过的时间非常重要,因为不同的平台会有不同的帧率,取决于硬件、GPU 和许多其他因素。为了确保所有类型的计算机和移动设备看到壶以相同的速度旋转,我们使用now变量并计算nowthis.lastUpdate之间的差值,得到一个增量时间。我们使用增量来确定实际的旋转速度。

最终代码

现在我们已经解决了所有这些问题,我们有一个良好渲染的旋转茶壶。在编码过程中,我们还修复了一个糟糕的编程错误;壶的速度被硬编码为 20 左右。从编程的最大化来看,最好是将其作为const,“永远不要将常量嵌入程序主体中”:

import React, {Component } from 'react';

import {
  Animated,
  AppRegistry,
  asset,
  AmbientLight,
  Box,
  DirectionalLight,
  Div,
  Model,
  Pano,
  Plane,
  Text,
  Vector,
  View,
  } from 'react-vr';

  class TurningPot extends React.Component {
    constructor(props) {
      super(props);
      this.state = {yRotation: 0};
      this.lastUpdate = Date.now();
      this.rotate = this.rotate.bind(this); 
    }
    rotate() { //custom function, called when it is time to rotate
        const now = Date.now();
        const delta = now - this.lastUpdate;
        const potSpeed = 20;
        this.lastUpdate = now;
        this.setState({yRotation: this.state.yRotation + delta / potSpeed} );
        //requestAnimationFrame calls the routine specified, not a variable
        this.frameHandle = requestAnimationFrame(this.rotate);
      } 
    componentDidMount() { //do the first rotation
        this.rotate();
    } 
    componentWillUnmount() { //Important clean up functions
        if (this.frameHandle) {
          cancelAnimationFrame(this.frameHandle);
          this.frameHandle = null;
        }
      } 
    render() {
      return (
        <Animated.View // Base: Image, Text, View
          style={{
            transform: [ // `transform` is an ordered array
              {translate: [0, -0.5, -5.1 ] },
              {rotateY: this.state.yRotation}, // Map `yRotation' to rotateY 
            ]
          }}
          >
          <Model
          source={{
              obj: asset('teapot2.obj'),
              mtl: asset('teapot2_Mats.mtl'),
              }}
              lit
              //style={{
              // transform: [{ translate: [0, -0.7, -5.1 ] }]
              // }}
          />
      </Animated.View>
      );
    }

  }

class Pedestal extends Component {
    render() {
        return (
          <View>
          <Box 
          dimWidth={.4}
          dimDepth={.4}
          dimHeight={.5}
          lit
          texture={asset('travertine_striata_vein_cut_honed_filled_Base_Color.jpg')}
          style={{
            transform: [ { translate: [ this.props.MyX, -1.4, this.props.MyZ] } ]
            }}
        />
          <Box 
          dimWidth={.5}
          dimDepth={.5}
          dimHeight={.1}
          lit
          texture={asset('travertine_striata_vein_cut_honed_filled_Base_Color.jpg')}
          style={{
            transform: [ { translate: [ this.props.MyX, -1.1, this.props.MyZ] } ]
            }}
        />
          <Box 
          dimWidth={.5}
          dimDepth={.5}
          dimHeight={.1}
          lit
          texture={asset('travertine_striata_vein_cut_honed_filled_Base_Color.jpg')}
          style={{
            transform: [ { translate: [ this.props.MyX, -1.7, this.props.MyZ] } ]
            }}
          />
     </View>
    )
     }
     }

         class Platform extends Component {
             render() {
                 return ( 
                    <Model
                    source={{
                        obj: asset('DeckPlate_v1.obj'),
                        mtl: asset('DeckPlate_v1_AllMats.mtl'),
                        }}
                        lit
                        style={{
                            transform: [ {
                            translate: [ this.props.MyX, -1.8, this.props.MyZ]
                        }] }}
                    /> 

    );
          }
         }

export default class SpaceGallery extends React.Component {
    render() {
        return (
          <View>
            <Pano source={asset('BabbageStation_v6_r5.jpg')}/>
            <AmbientLight

    intensity = {.3}

    />
    <DirectionalLight
    intensity = {.7}
    style={{
        transform:[{
            rotateZ: -45
        }]
    }}
         /> 
         <Platform MyX='0' MyZ='-5.1'/>
         <Platform MyX='0' MyZ='0'/>
         <Platform MyX='0' MyZ='5.1'/>
         <Platform MyX='5.1' MyZ='-5.1'/>
         <Platform MyX='5.1' MyZ='0'/>
         <Platform MyX='5.1' MyZ='5.1'/>
         <Platform MyX='-5.1' MyZ='-5.1'/>
         <Platform MyX='-5.1' MyZ='0'/>
         <Platform MyX='-5.1' MyZ='5.1'/>

         <Pedestal MyX='0' MyZ='-5.1'/>
         <Pedestal MyX='0' MyZ='5.1'/>
         <Pedestal MyX='5.1' MyZ='-5.1'/>

         <Pedestal MyX='5.1' MyZ='5.1'/>
         <Pedestal MyX='-5.1' MyZ='-5.1'/>
         <Pedestal MyX='-5.1' MyZ='0'/>
         <Pedestal MyX='-5.1' MyZ='5.1'/>

         <Model
            source={{
                obj: asset('teapot2.obj'),
                mtl: asset('teapot2_Mats.mtl'),
                }}
                lit
                style={{
                    transform: [{ translate: [ -5.1, -1, -5.1 ] }]
                    }}
            />

        <Text
            style={{
                backgroundColor: '#777879',
                fontSize: 0.1,
                fontWeight: '400',
                layoutOrigin: [0.0, 0.5],
                paddingLeft: 0.2,
                paddingRight: 0.2,
                textAlign: 'center',
                textAlignVertical: 'center',
                transform: [ 
                    {translate: [-5.2, -1.4, -4.6] }]
                    }}>
            Utah Teapot
        </Text>
        <Text
            style={{
                backgroundColor: '#777879',
                fontSize: 0.1,
                fontWeight: '400',
                layoutOrigin: [0.0, 0.5],
                paddingLeft: 0.2,
                paddingRight: 0.2,
                textAlign: 'center',
                textAlignVertical: 'center',
                transform: [ 
                    {translate: [0, -1.3, -4.6] }]
                    }}>
            Spinning Pot
        </Text> 

         <Text
         style={{
             backgroundColor: '#777879',
             fontSize: 0.2,
             fontWeight: '400',
             layoutOrigin: [0.0, 0.5],
             paddingLeft: 0.2,
             paddingRight: 0.2,
             textAlign: 'center',
             textAlignVertical: 'center',
             transform: [ 
                {translate: [0, 1, -6] }]
         }}>
    Space Gallery
  </Text>
  <TurningPot/>

</View>
);
    }
};

AppRegistry.registerComponent('SpaceGallery', () => SpaceGallery);

声音

VR 中的声音实际上非常复杂。我们的耳朵听到的声音与别人的耳朵听到的声音不同。许多 VR 系统都采用简单的“如果在右边,对我的右耳来说更响”的立体声定位,但这并不是实际声音工作的方式。对于 VR 和它们所需的高帧率,就像我们的光照效果跳过完整的光线追踪一样,这种声音定位是可以的。

更复杂的 VR 系统将使用一种叫做头部相关传递函数(HRTF)的东西。HRTF 是指当你转动头部时声音如何变化。换句话说,声音如何根据你的头部“传递”?每个人都有自己的 HRTF;它考虑了他们的耳朵形状、头部的骨密度以及鼻子和口腔的大小和形状。我们的耳朵,再加上我们的成长方式,在这个过程中我们训练我们的大脑,让我们能够用 HRTF 做出惊人的事情。例如,人类可以通过只从两个点听到声音来在三维空间中定位某物。这就像只用一只眼睛就能看立体影像一样!HRTF 给了我们视觉所不能给的;它给了我们对周围发生的事情的空间意识,即使我们看不见。

使用 HRTF 进行虚拟现实需要每个在虚拟世界中听到声音的人都将他们的 HRTF 加载到 VR 世界的声音系统中。此外,这个 HRTF 必须在无反射室(墙壁上覆盖有泡沫衬里以消除回声的房间)中进行测量。这显然并不常见。

因此,大多数 VR 声音只是左右平移。

这是 VR 可以取得重大突破的领域。声音非常重要,让我们能够在三维空间中感知事物;这是沉浸的重要方面。许多人认为立体声平移就是 3D;这只是声音在一个耳朵比另一个耳朵更响。在音频系统中,这是平衡旋钮。在耳机中,听起来会很奇怪,但实际上并没有定位声音。在现实世界中,你的右耳会在左耳之前(或之后)听到声音,当你转动头部时,你的耳朵的曲线会改变这种延迟,你的大脑会说“啊,声音就在那里”。

没有 HRTF 测量,立体声平移是唯一能做的事情,但 HRTF 要好得多。好消息是,现在音频硬件和计算能力非常强大,有了 HRTF 或合理的软件来模拟平均 HRTF,更复杂的声音处理是可能的。期待未来在这个领域的进展。

React VR 的强大再次拯救了我们。我们不必担心这一切;我们只需要把声音放在我们的世界里。

说真的,不要因为所有这些谈话而感到沮丧,只要意识到声音很难(和图形渲染一样重要),但在这一点上,你真正需要做的就是获得一个好的单声道(不是立体声)声音,并在场景文件中描述它。

这就是 React VR 的全部意义。描述你想要的东西;你不需要告诉人们如何做。不过,你需要知道幕后发生了什么。

在我们的世界中放置声音

现在,让我们真的发出一些声音。Freesound.com是一个获取免费游戏声音的好地方。那里的大部分声音都需要归属。给那些帮助建立你的世界的人以信用是正确的做法。去这个网站下载几个你喜欢的声音文件。我在freesound.com找到的一些是这些:

我以.mp3文件格式下载了这些;这应该是相当跨平台的。把它们复制到static_assets目录中一个名为sounds的新文件夹中。我只在实际世界中使用了其中一个,但你可以尝试其他的。有时你不知道它是否有效,直到你在世界中听到它。

声音是一个必须附加到视图、图像或文本的节点——React VR 的唯一组件。你可能想把它附加到一个盒子、模型或其他东西上;只需用<View>包裹对象,并把sound组件放在其中,如下所示:

 <View>
    <Model
       source={{
        obj: asset('teapot2.obj'),
        mtl: asset('teapot2_Mats.mtl'),
        }}
        lit
        style={{
            transform: [{ translate: [ -5.1, -1, -5.1 ] }]
            }}
    >
    </Model>
 <Sound 
        loop
        source={{wav: asset('sounds/211491__abrez__boiling-water.mp3') }}
        />
    </View>

有一件有趣的事情是,声音并不是来自我们的茶壶所在的地方(当你第一次看到这个世界时,它在左上角)。为什么呢?看看前面的代码;我们只是简单地在Model周围包裹了View标签;所以它的变换与声音不同。

有些声音比其他的效果更好;你需要进行实验或录制自己的声音。修复变换留给读者作为练习。(实际上,这很容易,但确保你不要把变换粘贴为子 XML 元素。)正确的代码是这样的:

<View
    style={{
 transform: [{ translate: [-5.1, -1, -5.1] }]
 }}
>
    <Model
        source={{
            obj: asset('teapot2.obj'),
            mtl: asset('teapot2_Mats.mtl'),
        }}
        lit
    >
    </Model>
    <Sound
        loop
        source={{ wav: asset('sounds/211491__abrez__boiling-water.mp3') }} />
</View>

总结

我们学会了如何通过程序性地改变对象的位置和使用更高级的方法来构建动画,比如使用定时器和动画 API。我们明显看到了如果使用错误的<View>来进行动画会发生什么,并开发了一种让对象永远动画的方法。Energizer 兔会感到自豪。我们还添加了声音,这对虚拟世界来说是非常重要的事情。

定时器可以做很多事情;我强烈建议你研究在线文档并进行实验!

到目前为止,我们一直在 React VR 范围内。有时,有些事情是 React 不允许我们做的。在下一章中,我们将转向原生(即原生 React)!

有人能把那个沸腾的锅炉关掉吗?

第九章:自己动手-本机模块和 Three.js

React VR 使得在不需要了解 three.js 的情况下进行 VR 变得容易。three.js 是帮助实现 WebGL 的包装类,WebGL 本身是一种本机 OpenGL 渲染库的形式。

React VR 相当包容,但像所有 API 一样,它无法做到一切。幸运的是,React VR 预料到了这一点;如果 React VR 不支持某个功能并且您需要它,您可以自己构建该功能。

在本章中,您将涵盖以下主题:

  • 从 React VR 代码内部使用 three.js

  • 基本的 three.js 程序代码

  • 设置 three.js 以与我们的 React VR 组件进行交互

  • 使用 three.js 在视觉上执行低级别的操作

本机模块和视图

也许您确实了解 three.js 并且需要使用它。React Native 模块是您的代码可以直接包含原始的 three.js 编程。如果您需要以编程方式创建本机的 three.js 对象,修改材质属性,或者使用 React VR 没有直接暴露的其他 three.js 代码,这将非常有用。

您可能有一些执行业务逻辑的 JavaScript 代码,并且不想或无法将其重写为 React VR 组件。您可能需要从 React VR 访问 three.js 或 WebVR 组件。您可能需要构建一个具有多个线程的高性能数据库查询,以便主渲染循环不会变慢。所有这些都是可能的,React Native 可以实现。

这是一个相当高级的主题,通常不需要编写引人入胜、有效的 WebVR 演示;但是,了解 React VR 和 React 是如此可扩展,这仍然是令人难以置信的。

制作一个 three.js 立方体演示

首先,让我们看一个简单的盒子演示。让我们从一个新生成的站点开始。转到您的 node.js 命令行界面,并关闭任何正在运行的npm start窗口,并通过发出以下命令重新创建一个新的、新鲜的站点:

f:\ReactVR>React-vr init GoingNative

第一个任务是转到vr文件夹并编辑client.js。到目前为止,我们还没有必须编辑此文件;它包含样板 React VR 代码。今天,我们将编辑它,因为我们不只是在做样板。以下代码中的粗体行是我们将添加到client.js中的行:

// Auto-generated content.
// This file contains the boilerplate to set up your React app.
// If you want to modify your application, start in "index.vr.js"

// Auto-generated content.
import {VRInstance} from 'react-vr-web';
import {Module} from 'react-vr-web';
import * as THREE from 'three';

function init(bundle, parent, options) {
const scene = new THREE.Scene();
const cubeModule = new CubeModule();
const vr = new VRInstance(bundle, 'GoingNative', parent, {
 // Add custom options here
 cursorVisibility: 'visible',
 nativeModules: [ cubeModule ],
 scene: scene,
 ...options,
 });

 const cube = new THREE.Mesh(
 new THREE.BoxGeometry(1, 1, 1),
 new THREE.MeshBasicMaterial()
 );
 cube.position.z = -4;
 scene.add(cube);
 cubeModule.init(cube);

 vr.render = function(timestamp) {
 // Any custom behavior you want to perform on each frame goes here
//animate the cube
 const seconds = timestamp / 1000;
 cube.position.x = 0 + (1 * (Math.cos(seconds)));
 cube.position.y = 0.2 + (1 * Math.abs(Math.sin(seconds)));
 };
 // Begin the animation loop
 vr.start();
 return vr;
};

window.ReactVR = {init};

我们还需要创建 CubeModule 对象。如果它变得复杂,您可以将其放在一个单独的文件中。现在,我们可以将其添加到 client.js 的底部:

export default class CubeModule extends Module {
  constructor() {
    super('CubeModule');
  }
  init(cube) {
    this.cube = cube;
  }
  changeCubeColor(color) {
    this.cube.material.color = new THREE.Color(color);
  }
}

不需要做其他更改。现在你会看到一个弹跳的纯白色立方体。我们没有改变 index.vr.js,所以它仍然显示着 hello 文本。这表明 React VR 和原生代码,在这种情况下是 three.js,同时运行。

好的,我们放了一个弹跳的立方体。这段代码的好处是它展示了一些高度的集成;然而,这是以一种非常干净的方式完成的。例如,这一行代码——const scene = new THREE.Scene()——给你一个可访问的 three.js 场景,所以我们可以用 three.js 做任何我们想做的事情,然而,所有的 React VR 关键词都能正常工作,因为它将使用现有的场景。你不需要从一边导入/导出场景到另一边并维护句柄/指针。这一切都是干净的、声明式的,就像 React VR 应该是的那样。我们在正常的 React VR 语法之外创建了常规场景和对象。

在我们之前的动画中,我们改变了index.vr.js。在这种情况下,对于 three.js 对象,我们直接在client.js的这部分进行更改;就在代码生成器建议的地方:

vr.render = function(timestamp) {

// 在这里执行每帧的自定义行为

使原生代码与 React VR 交互

如果我们继续让这个对象与世界其他部分进行交互,你就能真正看到 React VR 的强大之处。为了做到这一点,我们需要改变index.vr.js。我们还将第一次使用VrButton

注意 VrButton 中的拼写。我在这个问题上纠结了一段时间。我自然地会输入"VR"而不是"Vr",但它确实遵循了 React VR 的大小写规范。

线索是,在控制台中你会看到 VRButton is not defined,这通常意味着你在import语句中忘记了它。在这种特殊情况下,你会看到 React 的一个奇怪之处;你可以输入 import { YoMomma } from 'react-vr'; 而不会出错;试试看。React VR 显然太害怕回答 YoMomma 了。

当我们点击按钮时,沉浸感的一个重要部分是它们发出的点击声音。任何将手机调成静音且没有震动的人都知道我的意思;你按一下手机,什么声音都没有,以为它坏了。所以,让我们去FreeSound.org下载一些点击声音。

我找到了 IanStarGem 制作的 Switch Flip #1,并且它是根据知识共享许可证授权的。所以,让我们把它放在 static_assets 文件夹中:

  1. 首先,我们需要包括我们的NativeModule的声明;通常,你会在import指令之后的顶部这样做,如下所示:
// Native Module defined in vr/client.js const  cubeModule  =  NativeModules.CubeModule;

请注意,你可以将你的对象称为CubeModule,但你可能会在实现与定义之间感到困惑。这样打字会更容易。JavaScript 可能会很宽容。这可能是好事,也可能不是。

  1. 无论如何,在index.vr.js中,我们需要设置我们的新初始状态,否则会出现黑屏和错误:
class GoingNative extends React.Component {
 constructor(props) {
 super(props);
 this.state = { btnColor: 'white', cubeColor: 'yellow' };
 cubeModule.changeCubeColor(this.state.cubeColor);
 }
  1. 在同一个文件中,在render()语句的下面,将<View>的定义更改为以下内容(注意我们仍然在视图中,并且尚未关闭它):
      <View
        style={{
          transform:[{translate: [0, 0, -3]}],
          layoutOrigin: [0.5, 0, 0],
          alignItems: 'center',
        }}>

我们在这里稍微作弊,也就是说,将视图向后移动,这样物体就在我们面前。

由于 React VR 不是 CAD 系统,你无法进行可视化编辑,因此在编写代码时必须考虑物品的定位。

对于一些复杂的情况,布局图纸也可能有所帮助。

  1. <Pano>语句之后,并在</View>结束标记之前,插入以下内容(更改模板生成的 Text 语句):
  <VrButton
    style={{
      backgroundColor: this.state.btnColor,
      borderRadius: 0.05,
      margin: 0.05,
    }}
    onEnter={() => { this.setState({ btnColor: this.state.cubeColor }) }}
    onExit={() => { this.setState({ btnColor: 'white' }) }}
    onClick={() => {
      let hexColor = Math.floor(Math.random() * 0xffffff).toString(16);
      // Ensure we always have 6 digits by padding with leading zeros.
      hexColor = '#' + (('000000' + hexColor).slice(-6));
      this.setState({ cubeColor: hexColor, btnColor: hexColor });
      // Asynchronous call to custom native module; sends the new color.
      cubeModule.changeCubeColor(hexColor);
    }}
    onClickSound={asset('freesound__278205__ianstargem__switch-flip-1.wav')}
  >
    <Text style={{
      fontSize: 0.15,
      paddingTop: 0.025,
      paddingBottom: 0.025,
      paddingLeft: 0.05,
      paddingRight: 0.05,
      textAlign: 'center',
      textAlignVertical: 'center',
    }}>
      button
    </Text>
  </VrButton>

当你刷新浏览器时,立方体仍然会四处弹跳,但你可以点击按钮看到立方体变色。当你将鼠标或控制器的光标悬停在按钮上(显示为<Text>组件),你会看到按钮变成立方体的当前颜色。

一个很好的做法是在静态变量中预先生成立方体的新颜色(这样它不会像 let 一样消失),然后使鼠标悬停的颜色变成那种颜色。

白色背景上的默认颜色也应该修复。

继续尝试吧;这是一个有趣的练习。

当我们播放声音时,在浏览器的控制台中会出现以下错误:

VrSoundEffects: must load sound before playing ../static_assets/freesound__278205__ianstargem__switch-flip-1.wav

你可能还会看到以下错误:

Failed to fetch audio: ../static_assets/freesound__278205__ianstargem__switch-flip-1.wav
The buffer passed to decodeAudioData contains invalid content which cannot be decoded successfully.
  1. 解决这个问题的方法是确保你的浏览器有正确的音频格式。正确的格式有:

  2. 音频文件需要是单声道;这样它们才能被转换成 3D 空间。

  3. 音频文件需要是 48 千赫或更低。这似乎在 Firefox 55 和 59 之间有所改变,但尽可能通用是最安全的。

  4. 如果你的文件格式错误,或者你听不到声音,有两种可能的解决方法:

  5. 你可以使用 Audacity 或其他音频编辑工具来修复这些问题。

  6. 你可以让我来修复它!我已经在书中的文件中下载并转换了文件。但是,如果你不尝试修复,你就学不到。你可以只下载 48 千赫单声道文件,避免转换,但实际上这些相当罕见。使用 Audacity 转换声音很容易和免费,你只需要学一点这个程序就可以了。在 VR 按钮内,我们需要做的就是加载修改后的单声道声音文件:

onClickSound={asset('freesound__278205__ianstargem__switch-flip-48kmono.wav')}

我在早期的部分提到过这一点,但值得重申的是,如果您遇到无法解释的错误,并且大声说“我知道文件在那里并且可以播放!”,请尝试检查声音文件的格式。

总结到目前为止的代码

我们添加了很多代码;让我们总结一下我们的进展。React VR 有时可能会令人困惑,因为它是 JavaScript 和 XML“ish”代码(JSX)的混合,所以这里是完整的index.vr.js

import React from 'react';
import {
  AppRegistry,
  Animated,
  asset,
  Easing,
  NativeModules,
  Pano,
  Sound,
  Text,
  View,
  VrButton
} from 'react-vr';

const cubeModule = NativeModules.CubeModule;

class GoingNative extends React.Component {
  constructor(props) {
    super(props);
    this.state = { btnColor: 'white', cubeColor: 'yellow' };
    cubeModule.changeCubeColor(this.state.cubeColor);
  }
  render() {
    return (
      <View
        style={{
          transform: [{ translate: [0, 0, -3] }],
          layoutOrigin: [0.5, 0, 0],
          alignItems: 'center',
        }}>
        <Pano source={asset('chess-world.jpg')} />
        <VrButton
          style={{
            backgroundColor: this.state.btnColor,
            borderRadius: 0.05,
            margin: 0.05,
          }}
          onEnter={() => { this.setState({ btnColor: this.state.cubeColor }) }}
          onExit={() => { this.setState({ btnColor: 'white' }) }}
          onClick={() => {
            let hexColor = Math.floor(Math.random() * 0xffffff).toString(16);
            // Ensure we always have 6 digits by padding with leading zeros.
            hexColor = '#' + (('000000' + hexColor).slice(-6));
            this.setState({ cubeColor: hexColor, btnColor: hexColor });
            // Asynchronous call to custom native module; sends the new color.
            cubeModule.changeCubeColor(hexColor);
          }}
          onClickSound={asset('freesound__278205__ianstargem__switch-flip-48kmono.wav')}
        >
          <Text style={{
            fontSize: 0.15,
            paddingTop: 0.025,
            paddingBottom: 0.025,
            paddingLeft: 0.05,
            paddingRight: 0.05,
            textAlign: 'center',
            textAlignVertical: 'center',
          }}>
            button
    </Text>
        </VrButton>
      </View>
    );
  }
};

AppRegistry.registerComponent('GoingNative', () => GoingNative);

vr文件夹(文件夹名称为小写)中的client.js文件中将包含以下内容:

import {VRInstance} from 'react-vr-web';
import {Module} from 'react-vr-web';
import * as THREE from 'three';

function init(bundle, parent, options) {
const scene = new THREE.Scene();
const cubeModule = new CubeModule();
const vr = new VRInstance(bundle, 'GoingNative', parent, {
    cursorVisibility: 'visible',
    nativeModules: [ cubeModule ],
    scene: scene,
    ...options,
  });

  const cube = new THREE.Mesh(
    new THREE.BoxGeometry(1, 1, 1),
    new THREE.MeshBasicMaterial()
  );
  cube.position.z = -4;
  scene.add(cube);

  cubeModule.init(cube);

  vr.render = function(timestamp) {
    const seconds = timestamp / 1000;
    cube.position.x = 0 + (1 * (Math.cos(seconds)));
    cube.position.y = 0.2 + (1 * Math.abs(Math.sin(seconds)));
  };
  vr.start();
  return vr;
};

window.ReactVR = {init};

export default class CubeModule extends Module {
  constructor() {
    super('CubeModule');
  }
  init(cube) {
    this.cube = cube;
  }
  changeCubeColor(color) {
    this.cube.material.color = new THREE.Color(color);
  }
}

更多视觉效果

我们做了一些很棒的交互,这是很棒的,尽管直接使用 three.js 的另一个重要原因是在渲染方面做一些 React VR 无法做到的事情。实际上,React VR 可以通过本地方法做一些令人惊叹的事情,所以让我们确切地做到这一点。

首先,让我们将我们的立方体从四处弹跳改为旋转。当我们添加一些视觉效果时,它会看起来更令人印象深刻。

让我们也添加一些球体。我们希望有一些东西可以反射。我选择反射作为一个令人印象深刻的事情,目前在 WebVR 中你实际上不能做到,尽管我们可以通过环境映射做一些非常接近的事情。关于环境映射是什么的讨论比较长,你可以去这里了解:bit.ly/ReflectMap

将以下代码添加到您现有的index.vr.js中,在</VrButton>下方:

     <Sphere
      radius={0.5}
      widthSegments={20}
      heightSegments={12}
      style={{
        color: 'blue',
        transform: [{ translate: [-1, 0, -3] }],
      }}
      lit />
    <Sphere
      radius={1.5}
      widthSegments={20}
      heightSegments={12}
      style={{
        color: 'crimson',
        transform: [{ translate: [1, -2, -3] }],
      }}
      lit />

我们还将在顶层<View>内的index.vr.js中添加环境光和定向光:

  <AmbientLight  intensity={.3} />
  <DirectionalLight
    intensity={.7}
    style={{ transform: [{
        rotateZ: 45
      }]
    }}
  />

继续加载,并确保您看到一个漂亮的蓝色球和一个大红色球。请注意,我编码比平常稍微密集一些,这样这本书就不会消耗更多的树木或光子。我们大部分的更改将在client.js中进行。首先,在init下初始化我们需要的所有变量:

 var materialTorus;
 var materialCube;
 var torusCamera;
 var cubeCamera;
 var renderFrame;
 var torus;
 var texture;
 var cube;

然后,我们将为场景设置自定义背景。有趣的是,在我们有<Pano>语句时,这并不会显示出来,但这是件好事,因为我们现在正在用three.js编码;它不理解 VR,所以背景不太对。这会在图像上显示出来,但最好由读者自行修复。要为three.js设置自定义背景,继续按照以下方式添加代码:

  var textureLoader = new THREE.TextureLoader();
  textureLoader.load('../static_assets/chess-world.jpg', function (texture) {
    texture.mapping = THREE.UVMapping;
    scene.background = texture;
  });

然后,我们将创建一个圆环和之前创建的立方体(记住,这一切仍然在init语句中):

  torusCamera = new THREE.CubeCamera(.1, 100, 256);
  torusCamera.renderTarget.texture.minFilter = THREE.LinearMipMapLinearFilter;
  scene.add(torusCamera);

  cubeCamera = new THREE.CubeCamera(.1, 100, 256);
  cubeCamera.renderTarget.texture.minFilter = THREE.LinearMipMapLinearFilter;
  scene.add(cubeCamera);

我们在这里做的是创建了一些额外的摄像头。我们将把这些摄像头移动到圆环和我们的弹跳立方体所在的位置,然后将这些摄像头渲染到一个屏幕外的缓冲区(看不见)。现在我们已经创建了这些摄像头,我们可以创建我们的立方体和圆环 three.js 对象;请注意,这对我们之前的立方体有一点改变:

  materialTorus = new THREE.MeshBasicMaterial({ envMap: torusCamera.renderTarget.texture });
  materialCube = new THREE.MeshBasicMaterial({ envMap: cubeCamera.renderTarget.texture });

  torus = new THREE.Mesh(new THREE.TorusKnotBufferGeometry(2, .6, 100, 25), materialTorus);
  torus.position.z = -10; torus.position.x = 1;
  scene.add(torus);

  cube = new THREE.Mesh( new THREE.BoxGeometry(1, 1, 1), materialCube);
  cube.position.z = -4;
  scene.add(cube);

  renderFrame = 0;
  cubeModule.init(cube);

请注意,cubeModule.init(cube);语句应该已经存在。现在,我们只需要真正地将假锡箔包裹在我们的物体周围;我们将在vr.render函数中完成这个操作。以下是整个函数:

vr.render = function (timestamp) {
    // Any custom behavior you want to perform on each frame goes here
    const seconds = timestamp / 2000;
    cube.position.x = 0 + (1 * (Math.cos(seconds)));
    cube.position.y = 0.2 + (1 * Math.abs(Math.sin(seconds)));
    cube.position.y = 0.2 + (1 * Math.sin(seconds));

    var time = Date.now();
    torus.rotation.x += 0.01;
    torus.rotation.y += 0.02;

    //we need to turn off the reflected objects, 
    //or the camera will be inside.
    torus.visible = false;
    torusCamera.position.copy(torus.position);
    torusCamera.update(vr.player.renderer, scene)
    materialTorus.envMap = torusCamera.renderTarget.texture;
    torus.visible = true;

    cube.visible = false;
    cubeCamera.position.copy(cube.position);
    cubeCamera.update(vr.player.renderer, scene);
    materialCube.envMap = cubeCamera.renderTarget.texture;
    cube.visible = true;

    renderFrame++;

  };
  // Begin the animation loop
  vr.start();
  return vr;
};

我稍微改变了盒子,去掉了正弦波周围的Math.abs(..)函数,这样它就会在一个完整的圆圈中旋转;这样我们就可以看到反射贴图的优点和缺点。

希望我们已经把所有内容都粘贴进去了。你可以面带微笑地观看显示。漂亮的铬结对象!当你盯着它看时,你会注意到有些地方不太对劲。你可以看到在方框中伪造的反射和真实的反射之间的区别。它看起来有点“不对劲”,但铬结看起来不错。

看看以下图像中红色高亮和绿色的区别:

创建良好的 VR 主要是关于合理的妥协。在反射的情况下,它们看起来可能很棒,就像前面的图像所示的那样,但它们也可能看起来有点不舒服。盒子或平面镜子就是一个不好的例子。曲面物体看起来更自然,正如你所看到的。

游戏和实时编程与仔细的设计一样重要,也是对真实世界的忠实再现。记住,我们不是在创造真实的东西;我们所要做的就是创造一个看起来真实的东西。

在 three.js 中有一个真正的反射器叫做THREE.Reflector,如果你想建造一个平面镜子。在 three.js 的示例中有很好的文档记录。

借助这些技术和 React Native 桥接,您可以在不深入常规 three.js 编程的情况下,在 React VR 中做一些令人惊叹的事情。

下一步

现在您已经看到了材料的基本 three.js 语法,您可以查看各种 three.js 示例,并复制其中的一些代码。不要只看屏幕上的示例。您还会想在 VR 中尝试它们。一些游戏技巧,比如镜头反射或屏幕空间反射,在 VR 中看起来并不好。一如既往,测试,测试和测试。

我还略微改变了按钮的颜色,当我们切换到 VR 模式时,我们没有光标,所以按钮按下并不总是有效。在下一章中,我将向您展示如何解决这个问题,或者您可以自行调查。

我还在源文件中加载了一个类似金属的反射纹理,名为static_assets/metal_reflect.jpg。您不必进行相机渲染来获得看起来闪亮的东西,特别是如果它是一种暗淡的反射,并且可能不希望额外增加帧速率(所有这些相机渲染都需要时间)。如果是这种情况,您可以做一个简单的环境贴图,跳过相机加载和渲染。

扩展 React VR — 本机视图

您还可以通过一种称为本机视图的东西来扩展 React VR 本身。视图这个词可能让您想到相机渲染,尽管在这种情况下,意思有点不同。把它们看作是本机 three.js 的新 React VR 对象更为合适。它们非常有用。您可以使用我们刚刚介绍的 three.js 代码来混合原始的 three.js 编程,但是以这种方式使用声明式编程的能力有限。有没有更适合 React VR 的方法?您可以通过本机视图来实现这一点。

扩展语言

当您实现本机视图时,您可以控制属性和代码与其余运行时代码的交互方式。这些注入通常是视觉的,尽管您也可以注入声音。

您还可以实现新的本机对象。编程方式与我们迄今为止所做的类似;您实现基本属性,将新关键字暴露给运行时,然后将它们编码,就好像它们是 React VR 语言的一部分。还有其他关键字和函数,让您能够根据属性和类型描述您的新 React VR 视图。

要创建本机视图,可以查看文档:bit.ly/RCTNativeView.  

你现在已经到了可以用 React VR 做一些令人惊叹的事情的地步了,我完全相信你可以分解我的例子,扩展它们,并且玩得开心。

总结

在本章中,我们讨论了如何在 React VR 中使用 three.js 的全部功能。在学习这一点的同时,我们演示了如何放置本地代码和 React VR 本地桥接。我们直接通过 JavaScript 构建了three.js网格,并添加了使世界更加生动的声音。我们还使用了 React Native Views 和本地桥接来进行自定义渲染,包括反射贴图 - 我们为 VR 添加了 Chrome(而不是用 Chrome 查看 VR)。我们还展示了如何通过vr.player.renderer访问 React VR 相机来进行更多的 three.js 处理。

有了完整的 three.js,我们真的可以用 React VR 做任何我们想做的事情。然而,我们应该在需要的地方使用 React VR,在需要更多细节的地方使用 three.js,否则 React VR 将成为螺栓上的糖霜。它可能会生锈并容易脱落。

第十章:引入真实世界

正如您在上一章第九章中学到的,自己动手-本地模块和 Three.js,我们可以将本地代码和 JavaScript 代码包含到我们的世界中。除了通过使其在视觉上更有趣来为我们的世界注入生命外,我们还可以将外部世界引入其中。

在本章中,您将学习如何使用 React 和 JavaScript 将网络带入 VR 世界。您将学习如何在 VR 中使用现有的高性能代码。

首先,我们需要一个 VR 世界来开始。这一次,我们要去火星了!

在本章中,您将学习以下主题:

  • 执行 JSON/Web API 调用

  • Fetch语句

  • 跨域资源共享(CORS)

  • 诊断的网络选项卡

  • Cylindrical Pano语句

  • 类似于 flexbox 的文本对齐(React Native 的一部分)

  • 条件渲染

  • 样式表

前往火星(初始世界创建)

您可能会认为太空中没有天气,但实际上是有的,我们在那里有天气站。我们将前往火星获取我们的天气。这将是一个实时程序,将从火星科学实验室或其名为好奇号的探测车获取天气数据。

好奇号是一辆体积为 SUV 大小的火星探测车,于 2011 年 11 月 26 日发射到火星,于 2012 年 8 月 6 日着陆。如果您开着 SUV 去那里,即使您能买到汽油,也需要大约 670 年才能到达那里。火星探测车最初设计为两年的任务,但其任务被延长了,这对我们来说是幸运的。

开着 SUV 去火星获取天气报告将是一件麻烦事。我甚至不知道加油站在哪里。

创建初始世界

首先,就像以前做过的那样,转到存储世界的目录并创建一个,如下所示:

react-vr init MarsInfo

然后,从github.com/jgwinner/ReactVRBook/tree/master/Chapter10/MarsInfo下载资产。

尽管我上传了所有文件来使其工作,而不仅仅是静态资产,但您真的应该尝试自己编写代码。从下载文件并运行它们中,您并不会真正学到任何东西。

犯错误是塑造性格的过程。我上传了文件并将继续维护它们,以防有太多的性格。

现在我们有了一个初始世界,我们将开始设置 Web 服务以获取数据。

Jason 和 JSON

当您听到人们谈论 JSON 时,希望您不会想到这个家伙:

我在网上找到了这张图片,标记为创意共享;这是来自加拿大拉瓦尔的 Pikawil 拍摄的蒙特利尔 Comic-Con 上的 Jason Voorhees 服装(角色扮演)。

认真地说,JSON 是通过 Web 服务引入外部世界的最常见方式;然而,正如我们已经看到包括原生代码和 JavaScript 的方式,您可以以各种方式集成您的系统。

React VR 的另一个巨大优势是它基于 React,因此您可以在 React VR 中常见的事情,也可以在 React VR 中做,只是有一些重要的区别。

为什么 JSON 与 React 无关

起初,您可能会想,"在 React VR 中如何进行 AJAX 请求?"

实际上并不是。React VR 和 React Native 对获取数据的方式没有任何忠诚度。事实上,就 React 而言,它甚至不知道图片中有服务器

React 只是使用来自两个地方的数据(props 和 state)简单地渲染组件。

这是学术答案。真实答案要广泛一些。您可以以任何您喜欢的方式获取数据。在说完这些之后,通常大多数 React 程序员将使用这些 API 和/或框架之一:

  • Fetch:几乎是一个标准,它内置在 React 中,因为它通常已经包含;有关用法说明和示例,请参阅bit.ly/FetchAPI

  • Axios:Axios 围绕着承诺(异步完成 API)展开,尽管它也可以在单线程应用程序中以更简单的方式使用;有关更多详细信息,请参阅bit.ly/AxiosReadme

  • Superagent:如果您不喜欢承诺,但喜欢回调;有关更多信息,请参阅bit.ly/SuperagentAPI

在这些示例中,我们将展示 fetch,因为没有必要安装不同的模块和设置回调。在说完这些之后,您可能希望构建一个稍微更具响应性的应用程序,该应用程序使用某种类型的回调或异步完成,以便在等待外部数据时执行某些操作。Fetch 确实通过承诺进行异步完成,因此我们将进行条件渲染以利用这一点,并保持响应性 VR 应用程序。

你可能已经写了很多这样的代码。React VR,正如前面讨论的那样,是一个用于 VR 对象的渲染系统,因此你可以使用各种外部 JavaScript 系统。

找到 API——从火星一直到地球

现在,我们将从火星获取天气数据。不,我并不是在开玩笑。参考bit.ly/MarsWeatherAPI,如果你感兴趣,这里描述了 API 并提供了一些科学背景。这个 API 被设置为从 XML 数据中获取并以 JSON 或 JSONP 格式返回。以下是结果数据,你也可以参考:marsweather.ingenology.com/v1/latest/

{
  "report": {
    "terrestrial_date": "2019-04-21",
    "sol": 2250,
    "ls": 66.0,
    "min_temp": -80.0,
    "min_temp_fahrenheit": -112.0,
    "max_temp": -27.0,
    "max_temp_fahrenheit": -16.6,
    "pressure": 878.0,
    "pressure_string": "Higher",
    "abs_humidity": null,
    "wind_speed": null,
    "wind_direction": "--",
    "atmo_opacity": "Sunny",
    "season": "Month 4",
    "sunrise": "2019-04-21T11:02:00Z",
    "sunset": "2019-04-21T22:47:00Z"
  }
}

我们可以相当容易地将这转换为我们的 JSON 对象。首先,让我们测试连接性,并对实际返回的 JSON 文本进行合理检查。我们在浏览器中测试了前面的 JSON 数据,但我们需要测试代码以确保它能正常工作。要做到这一点,请按照以下步骤:

  1. index.vr.js中找到 MarsInfo Component {的声明,添加以下内容:
export default class MarsInfo extends Component {
    componentDidMount() {
        fetch(`http://marsweather.ingenology.com/v1/latest/`,
            {
                method: 'GET'
            })
            .then(console.log(result))
    }

    render() {
  1. 粘贴这个并运行它。

  2. 在浏览器中打开控制台(在 Firefox Nightly 中按Ctrl+Shift+K)。虽然我们刚刚展示的代码非常合理,在浏览器中运行良好,但当我们运行时,会出现错误:

问题是什么?是 CORS。这是一种机制,用于使跨源或不来自同一服务器的 Web 内容安全可靠。基本上,这是 Web 服务器表明“我可以嵌入到另一个网页中”的一种方式。例如,你的银行不希望你的银行详细信息被嵌入到其他网站的网页中;你的支票账户可能会很容易地受到威胁,你会认为自己正在登录真正的银行——而实际上并非如此。

请注意,我本可以使用一个不会出现这些错误的 API,但你可能会遇到自己内容的相同问题,所以我们将讨论如何发现 CORS 问题以及如何解决它。

  1. 要找出我们为什么会出现这个错误,我们需要查看协议头;点击工具->Web 开发者->网络,打开网络选项卡:

这个窗口对于解决原生 JSON 请求问题和网站集成非常有价值。

  1. 一旦打开控制台,你会看到不同的 HTTP 操作;点击那个没有完成的操作:

然后我们将查看返回的数据。

  1. 查看以下截图的右侧;在这里,您可以单击响应和头部来检查数据。我们可以看到网站确实返回了数据;但是,我们的浏览器(Firefox)通过生成 CORS 错误来阻止显示:

代码是正确的,但网站没有包括重要的 CORS 头,因此根据 CORS 安全规则,网站将其阻止。您可以在以下网址了解有关 CORS 的更多信息:bit.ly/HTTPCORS

如果出现此错误,可能可以通过向请求添加头部来解决。要添加头部,您需要修改fetch请求;fetch请求还允许使用'cors'模式。然而,出于某种原因,对于这个特定的网站,'cors'选项似乎对我不起作用;对于其他网站,可能效果更好。其语法如下:

fetch(`http://marsweather.ingenology.com/v1/latest/`,
    {
        method: 'GET',
        mode: 'cors',
    })

为了更好地控制我们的请求,创建一个头部对象并将其传递给fetch命令。这也可以用于所谓的预检查,即简单地进行两个请求:一个是为了找出 CORS 是否受支持,第二个请求将包括来自第一个请求的值。

  1. 要构建请求或预检查请求,请设置如下头部:
var myHeaders = new Headers();
myHeaders.append('Access-Control-Request-Method', 'GET');
myHeaders.append('Access-Control-Request-Headers', 'Origin, Content-Type, Accept');

fetch(`http://marsweather.ingenology.com/v1/latest/`,
    {
        headers: myHeaders,
        method: 'GET',
        mode: 'cors',
    })

头部值'Access-Control-Request-Headers'可以设置为服务器将返回的自定义头部选项(如果支持 CORS),以验证客户端代码是否是有效的 CORS 请求。截至 2016 年,规范已经修改以包括通配符,但并非所有服务器都会更新。如果出现 CORS 错误,您可能需要进行实验并使用网络选项卡来查看发生了什么。

在这种情况下,我们需要使用“预检查”的选项,但即使在修改了 React VR 网络代码之后,这在marsweather.ingenology.com上也没有起作用,因此他们的服务器很可能还没有升级到现代网络安全标准。

这种情况可能会发生!在我们的情况下,确实没有通用的解决方法。我找到了一个 Firefox 插件,可以让您绕过 CORS 限制(请记住,问题不是来自服务器,而是浏览器在看到服务器已经发送的有效负载时关闭您的代码),但这需要人们下载插件并进行调试。

我们需要找到一个更好的 API。NASA 拥有一个出色的 Web API 目录,我们将使用他们的火星探测器相机 API。你可以免费获取数十万张照片中的任何一张。一旦我们使用不同的 Web API,我们将得到我们一直在寻找的正确的 CORS 标头,一切都运行得很好。一旦我们向具有现代安全标准的服务器发出请求,我们会注意到它自动包含了 Firefox 需要的access-control-allow-origin(在这里是通配符),如下图所示,取自网络选项卡:

因此,我们将看实际图片,而不是火星上的天气。

来自 NASA 的更好的 API

要查看一些很棒的 Web API,你可以访问:bit.ly/NasaWebAPI并查看你可以使用的 API 列表,或者更好的是,使用你已经编写的一些 Web API。React VR 使得通过 React 和 React Native 的强大功能集成这些 API 变得非常容易。我们将使用火星照片 API。要启用它,你可能需要一个开发者密钥。当你发出请求时,你可以将你的 API 密钥添加到 URL 中,或者使用DEMO_KEY。这将成为 API 调用的一部分,例如,api.nasa.gov/mars-photos/api/v1/rovers/curiosity/photos?sol=1000&api_key=DEMO_KEY。请注意,URL 末尾没有句号。

如果在开发代码时出现错误,你可能使用了DEMO_KEY太多次;获取你自己的开发者 API 非常快速和简单;有关说明可以在我提到的网站上找到:bit.ly/NasaWebAPI

要从 NASA 获取数据,我们只需稍微更改fetch命令,如下所示;事实证明,我们不需要自定义标头:

  1. index.vr.js更改为以下内容,直到render()语句:
export default class MarsInfo extends Component {
    constructor() {
        super();
        this.state = {
            currentPhoto: 2,
            photoCollection: { photos: []}
        };
    };
    componentDidMount() {
        fetch('https://api.nasa.gov/mars-photos/api/v1/rovers/curiosity/photos?sol=1197&api_key=DEMO_KEY',
            { method: 'GET' })
            .then(response => response.json())
            .then(console.log("Got a response"))
            .then(json => this.setState({ photoCollection:json }))

    };

这就是我们从 NASA 获取火星数据并将其放入集合中所需做的一切。太棒了!以下是我们所做的一些注意事项:

  • photoCollection对象被初始化为空数组(集合)。这样我们在获取数据之前和之后可以使用类似的代码。

  • 但是,你仍然应该检查是否有失败。

  • 我们将currentPhoto值初始化为2,有点像是在“作弊”。这样做的原因是,当我写这本书的时候,如果你让currentPhoto默认为第一张图片,你在火星的第一个视图会很无聊。前几张图片都是测试图片,相当普通,所以我让你把currentPhoto改成2,这样我们就能看到一些有趣的东西。如果你有一个返回特定数据的 API,你也可以做同样的事情。

  • 这段代码只是获取数据;它不会渲染它。为此,我们将开发一个单独的对象来保持我们的代码模块化。

  1. 出于调试目的,我们还将在render()线程中添加一行,以查看我们确切拥有的数据。插入以下console.log语句:
  render() {
      console.log("Render() main thread, photo collection:", this.state.photoCollection);
      return (

这对于解决渲染代码和理解当前状态以及其变化非常有用。运行这段代码,我们可以在控制台中看到返回的对象。首先,我们从render()线程中得到一行,显示一个空的photo collection

注意photo collection是空的;这是有道理的,因为我们是这样初始化的。几秒钟后——在这段时间内你可以查看虚拟世界——你会看到另一个render()更新和更改的数据:

在这种特殊情况下(第 1,1197 天),有很多图片。JSON 处理这些数据非常出色,同时我们在 VR 世界中四处张望。

另一个需要注意的事情是render()循环只被调用了两次。如果你习惯于游戏开发范式,这可能看起来很奇怪,因为正如我们讨论过的,为了建立沉浸感,我们需要超过 60 帧每秒。如果我们只渲染了两次,我们怎么能做到呢?

React VR 并不实际生成图像,而是由 three.js 完成。当 React VR“渲染”时,它只是采用 React VR 语法,并应用任何 props 或状态更改,并为那些已经改变的对象调用render()

为了显示我们检索到的数据,我们将构建一个新对象。

  1. 创建一个名为CameraData的新文件,并将其作为一个单独的组件。我们还将改变index.vr.js中的render()方法。

每个人都需要一个样式表

样式不仅仅适用于你的头发;在这种情况下,使用样式表将有助于使我们的代码更简单、更清洁、更易于维护。样式重用非常容易。样式不是一种单独的语言;它们像 React 中的其他所有内容一样都是 JavaScript。React VR 中的所有核心对象都接受一个名为styles的 prop。我们将在我们的文件中定义这个样式并重用它。

创建以下样式定义,以便我们可以在CameraData.js组件中使用它们(请注意,您可以将其放在文件的任何位置):

const styles = StyleSheet.create({
    manifestCard: {
        flex: 1,
        flexDirection: 'column',
        width: 2,
        alignItems: 'center',
        justifyContent: 'center',
        backgroundColor: 'green',
        opacity: 0.8,
        borderRadius: 0.1,
        borderColor: '#000',
        borderWidth: 0.02,
        padding: 0.1,
        layoutOrigin: [-1, 0.3],
        transform: [
            {
                rotateY: -30,
                translate: [1, 0, -2]
            }
        ]
    },

    manifestText: {
        textAlign: 'center',
        fontSize: 0.1
    },
    frontCard: {
        flex: 1,
        flexDirection: 'column',
        width: 2,
        alignItems: 'center',
        justifyContent: 'center',
        backgroundColor: 'green',
        borderRadius: 0.1,
        borderColor: '#000',
        borderWidth: 0.02,
        padding: 0.05,
        transform: [{ translate: [-1, 1, -3] }],
    },
    panoImage: {
        width: 500,
        height: 500,
        layoutOrigin: [-.5, 0],
    },
    baseView: {
        layoutOrigin: [0, 0],
    },
});

如果省略width样式,对象将以完全不同的方式进行变换和移动。我还不确定这是否是一个错误,还是一种不同类型的布局样式,但请注意,如果您的transform语句没有移动文本或视图对象,可能是因为您的文本样式没有width:属性。

构建图像和状态 UI

接下来,我们需要以两种不同的方式渲染相机数据。第一种是当我们还没有CameraData时,换句话说,就是在应用程序启动时,或者如果我们没有互联网连接;第二种是当我们获取数据并需要显示它时。我们还希望保持这些例程相当模块化,以便在启动状态变化时可以轻松地重新绘制需要的对象。

请注意,React VR 自动完成了很多工作。如果一个对象的 props 或状态没有改变,它就不会被告知重新渲染自己。在这种情况下,我们的主线程已经具有了修改更改的 JSON 处理,因此主循环中不需要创建任何内容来重新渲染相机数据。

  1. 添加以下代码:
export default class CameraData extends Component {
    render() {
        if (!this.props) {
            return this.renderLoadingView();
        }
        var photos = this.props.photoCollection.photos;
        if (!photos) {
            return this.renderLoadingView();
        }
        var photo = photos[this.props.currentPhoto];
        if (!photo) {
            return this.renderLoadingView();
        }
        return this.renderPhoto(photo);
    };

请注意,我们还没有完成组件,所以不要输入最终的};。让我们讨论一下我们添加了什么。先前的主render()循环实质上是检查哪些值是有效的,并调用两个例程中的一个来实际进行渲染,要么是renderPhoto(photo),要么是renderLoadingView()。我们可以假设如果我们没有照片,我们正在加载它。前面的代码的好处是在使用之前检查我们的 props 并确保它们是有效的。

许多计算机课程和自助书籍剥离了错误处理以“专注于重要的事情”。

错误处理是你的应用程序中重要的事情。在这种情况下,它特别重要,因为当我们检索数据时,我们还没有加载照片,所以我们没有东西可以显示。如果我们不处理这个问题,我们会得到一个错误。我剥离的是console.log语句;如果你下载本书的源代码,你会发现更多的详细注释和跟踪语句。

现在,让我们继续进行实际的渲染。这看起来欺骗性地简单,主要是因为所有序列化、获取和有选择地渲染的辛苦工作已经完成。这就是编程应该努力做到的—清晰、健壮、易于理解和维护。

一些代码示例变得很长,所以我把闭合括号和标签放在它们要关闭的对象的末尾。我建议你买一个大的台式屏幕,以更宽广的方式编码;当你花一个小时追踪丢失或放错的/>时,你会感激大尺寸的显示设备。这只会提高生产力。

  1. 添加以下代码:
renderLoadingView() {
    console.log('CameraData props during renderLoadingView', this.props);
    return (
        <View style={styles.frontCard} >
            <Text style={styles.manifestText}>Loading</Text>
            <Text style={styles.manifestText}>image data</Text>
            <Text style={styles.manifestText}>from NASA</Text>
            <Text style={styles.manifestText}>...</Text>
        </View>
    );
};
renderPhoto(photo) {
return (
   <View style={styles.baseView}>
      <CylindricalPanel
         layer={{
            width: 1000,
            height: 1000,
            density: 4680,
            radius: 20 }}>
         <Image
            source={{ uri: photo.img_src }}
            style={styles.panoImage}>
         </Image>
      </CylindricalPanel>
      <Model
         source={{
            obj: asset('ArrowDown.obj'),
            mtl: asset('ArrowDown.mtl'), }}
         lit
         style={{
            transform: [{ translate: [-2.5, -1, -5.1] }] }} />
      <Model
         source={{
            obj: asset('ArrowUp.obj'),
            mtl: asset('ArrowUp.mtl'), }}
         lit
         style={{
            transform: [{ translate: [1.3, -1, -5.1] }] }} />
      <View style={styles.manifestCard}>
         <Text style={styles.manifestText}>
            {photo.camera.full_name}</Text>
         <Text style={styles.manifestText}>
            {photo.rover.name} Rover #{photo.rover.id}</Text>
         <Text style={styles.manifestText}>
            Landed on: {photo.rover.landing_date}</Text>
         <Text style={styles.manifestText}>
            Launched on: {photo.rover.launch_date}</Text>
         <Text style={styles.manifestText}>
            Total Photos: {photo.rover.total_photos}</Text>
         <Text style={styles.manifestText}>
            Most recent: {photo.rover.max_date} Latest earth date</Text>
         <Text style={styles.manifestText}>
            Viewing: {photo.rover.max_sol} Mars Sol</Text>
         <Text style={styles.manifestText}>
            Taken: {photo.earth_date} Earth (GMT)</Text>
      </View>
   </View>
);
}
}

如果你迄今为止已经输入了所有的代码,当世界加载时,你会看到一个绿色的对话框,告诉你它正在接收数据。几秒钟后,它将被照片 2 和来自火星的数据的详细元信息所取代。

如果你想同时打开两个虚拟世界,例如,为了检查一些导入而不产生我们正在编程中的往返网络请求,你可以通过转到设置好的第二个世界,而不是npm start,使用react-native start --port 9091命令来实现。

我之前简要提到过这一点,但重要的是要注意 React 是多线程的;当它们的 props 或状态改变时,元素会改变它们的渲染,而无需告诉它们。这是多线程的,而不需要改变代码。这使你能够在世界填充数据时移动摄像机并查看。

这使虚拟世界看起来更加“真实”;它对输入做出响应,就像它是现实一样。它就是—我们创造了虚拟现实。

如何(不)让人生病

你可能已经注意到,我们把用户界面——图标和屏幕——放得有点远;到目前为止,我们把所有东西都放在至少五米外。为什么呢?

这是因为容纳-聚焦冲突。

当你的眼睛“注视”着某样东西,就像我们在第一章“虚拟现实到底是什么?”中讨论的那样,如果那个东西离你的脸很近,你的眼睛会试图对其进行聚焦。然而,你的头戴式显示器是一个固定焦距的设备,无论物体离你有多近或多远,它总是显示清晰的图像。在现实世界中,比如说,距离小于 3 到 4 英尺的物体会需要你的眼睛进行更多的聚焦,而距离 10 英尺的物体则需要较少的聚焦。

因此,你的眼睛会聚焦在一个你本应该需要更多聚焦的图像上,但你所看到的已经是清晰的(因为一切都是清晰的),所以你期望在现实世界中看到的和在头戴式显示器中看到的有所不同。

这不会导致任何实际的视觉问题——一切都是清晰的和聚焦的。

你可能会感到眼睛疲劳和一种模糊的不适感,这种感觉会随着使用头戴式显示器的时间变得更糟。

避免这种情况的方法是尽量将 UI 元素放得比我们在这个例子中展示的更远。比如不要将浮动屏幕放在眼镜的位置。如果你这样做,人们会看着它们,他们的眼睛会期望对着距离大约六英寸的东西进行聚焦,但从聚焦的角度来看,这个物体的距离已经超过了手臂的长度。这会让你的用户感到疲劳。

这就是为什么大多数虚拟现实让你看着远处的大屏幕进行选择。你可能希望将 UI 元素放在手腕上,甚至那样也有点冒险。

我觉得人们使用虚拟现实的次数越多,他们的眼睛和聚焦就会得到重新训练,然而,我不知道有没有任何医学研究显示这种效果。我之所以提到这一点,是因为我的一只眼睛近视,另一只眼睛远视;当我戴上眼镜时,我的聚焦会发生变化。有趣的是,如果我戴上“没有镜片”的眼镜,我的聚焦仍然会发生变化。我觉得人类大脑是无限适应的,我们可以克服调节-调节冲突。

然而,用户的体验可能会有所不同,所以不要让他们因为把东西放得太近(距离小于一米)而感到疲劳。

总结

在本章中,你学到了很多东西。我们通过构建消耗 JSON API 的网络服务调用,使我们的世界真正实现了互动。我们看到了一些获取数据的方法,并使用了更多或更少内置的fetch语句。这些 API 调用现在是异步的,所以我们可以环顾四周,欣赏火星,而我们请求的相机数据正在加载。

我们已经看到了如何通过处理跨站脚本问题来构建安全的世界。我们创建了合理的文本并进行了条件渲染。我们还讨论了错误处理。

做所有这些需要一些时间,我们在开发过程中有几次花了几个小时来排列对象。有几次我被关闭,因为我在一个小时内超过了DEMO_KEY检索次数。这就是为什么我建议你获取自己的 API 密钥,然后你就可以请求更多的图片。

这一章相当长,虽然检索了真实世界的数据,但世界还不是完全互动的。在下一章中,你将学习如何使你的世界与我们的输入互动。这就是为什么我在前面的视图中加入了+和-箭头。查看下一章,找出如何将它们连接到页面通过我们的火星数据。我会展示一个不同的世界,但展示如何使按钮互动。你可以通过做简单的属性更改来使加号和减号按钮变得真实。

第十一章:走在野生的一边

到目前为止,在前面的章节中,我们已经建立了一些真实但小型的世界。

然而,有一些东西一直缺失。一开始,我谈到了 VR 作为一种可以互动的东西——一种现实,即使它看起来并不真实。到目前为止,我们所做的大部分是看和观察事物,但我们无法四处移动。

在本章中,我们将做到这一点。

你将学习以下主题:

  • 使用 NPM 添加组件

  • 凝视按钮

  • 使用凝视按钮触发事件

  • 添加 JavaScript 文件

  • 将 JavaScript 文件转换为动态构建几何图形

  • 在我们创建的世界中移动观点

  • 移动使事物看起来更真实

  • 更多关于 VR 控制器的信息

发疯了——VR 运动方式

我小时候晕车。VR 也会让你晕车——之前在介绍 VR 时已经讨论了这些原因,但这是一个非常重要的话题,所以值得重复。

如果你移动一个观点,独立于用户的行为(用户代理),大脑会知道它没有移动。然而,大脑也会看到世界通过你(VR)的眼睛移动。然后,大脑依赖于一个非常古老、重要的生存特征——你会认为自己中毒了。

当你中毒时,你的身体非常擅长呕吐。用不太临床的术语来说,你会呕吐。你的身体认为有什么东西试图杀死你,所以它只是想尽快摆脱胃里的任何东西,作为一种恐慌反应。

那么,在 VR 中如何移动?如何在不让人发疯的情况下启用 VR 运动方式?

VR 运动方式的类型

讨论 VR 运动方式,至少要稍微讨论一下 VR 控制器。你手中拿着的东西,脚下的东西,支撑你的东西,或者让你在周围滚动的东西显然会产生巨大的影响。

我们正在讨论 WebVR,虽然对人们来说非常容易上手,但这可能意味着你的用户可能没有各种类型的 VR 装备。如果你确实有装备,你可能会发现对于你的应用程序,更简单的运动方式更好,而且编码速度肯定更快。

在讨论设备时,人们讨论自由度(DOF)。这实际上与严格考虑自由度有关,但主要是关于被跟踪的内容。

如果您有手持设备,您可能只有3DOF;这意味着电子设备可以跟踪您是否围绕其中心旋转。6DOF控制器是这样跟踪的,但它也可以检测自己是否在移动,换句话说,是在进行平移。通常,每个都有 3 个度。

6DOF 控制器更加逼真;您可以伸手触摸物体。然而,它们需要某种形式的跟踪,对于目前的行业状态来说,通常意味着外部跟踪器,比如 Vive 灯塔或 Oculus 摄像头。

还有一种称为内部跟踪的跟踪方式,这意味着头戴设备本身可以看到控制器并确定它们的位置。它们确实使用摄像头,只是不是散布在房间周围的外部摄像头。

很难将运动类型归类为无控制器的运动方式;它也可能与控制器(传送)一起很好地工作。

我不会真的包括四处移动头部(或四处移动鼠标),尽管那也是移动;没有这个,VR 系统实际上不是真正的 VR(按我的定义)。然而,确实有一些 VR 头戴设备不包括这个功能,或者做得不好。这是高端手机(三星 Gear VR 和 Google Daydream)和 PC 头戴设备 Vive 和 Rift 的真正突破。

考虑以下类型的 VR 运动:

  • 凝视检测:您看着某物,它会激活一个效果,一个眨眼,或者让您移动

  • 车辆/驾驶舱运动:您的视野显示墙壁或驾驶舱的细节

  • 可以通过凝视检测移动

  • 带有控制器(游戏手柄等)

  • 定时/人工(按下按钮或在一段时间后移动玩家)

  • 只有轻微的生病几率

  • 房间规模

  • 四处走动(直到边界)

  • 生病的几率非常低

  • 需要硬件

  • 传送或眨眼

  • 通常使用凝视或 3DOF 或 6DOF 控制器

  • 传送也可以分成小步骤进行——消除运动(视觉加速);这会让您感觉自己在移动,但不会让您感到恶心

  • 跑步机

  • 一种您站在上面并移动脚,它会检测您的移动方式

  • 还有滑翔伞模拟器和飞行模拟器,您可以躺下或坐下,通过移动身体重量来飞行

  • 所有这些都很大且昂贵,通常只限于 VR 游乐场

  • 跟踪的 6DOF 控制器运动范式

  • Vive/Rift 通常使用传送,而 6DOF 控制器使其变得容易

  • 有许多其他使用 6DOF 控制器的移动方式;一个好的列表可以在bit.ly/VRLoco找到

  • 人工运动/轨道:

  • 一旦你使用 UI 指示要做什么,VR 系统就会沿着一条路径将你移动。

  • 凝视/头部控制的转向属于这一类。

  • 很容易让人感到恶心。

  • 如果你的头转动,它可能会很烦人;只需改变你的移动方式;即使你不会感到恶心,你也会感觉自己被带走了。不过,通过谨慎的实施,它也可以起作用。

围绕移动的方式当然受到你拥有多少硬件的限制。另一个限制是你想要多大的受众群体。如果你设计你的 VR 应用程序为房间规模(自然四处走动),你就排除了每个手机用户。然而,同样地,如果你决定使用凝视瞬移系统,那些拥有房间规模 VR 的人会感到沮丧,因为他们不能四处走动。

WebVR 目前更多地针对移动 VR,房间规模是一个很大的编程挑战。这是可能的,但在 React-VR 和 WebVR 中并没有内置。从硬件可用性的角度来看:

  • 无需设备(Google Cardboard):

  • 自然运动(平移/倾斜)- 仅限少量

  • 凝视检测

  • 通过定时器或凝视检测的人工运动('轨道'运动,就像你在轨道上)

  • 带控制器的 VR 头盔(Gear VR,Daydream 等):

  • 现在我们有更好的方法,但仍然可以做所有以前的方法:

  • 自然运动(平移/倾斜)- 仅限少量

  • 凝视检测

  • 通过定时器或凝视检测的人工运动('轨道'运动,就像你在轨道上)

  • 驾驶舱运动

  • 通过控制器进行瞬间移动

  • 操纵杆/控制器

  • PC VR–Vive/Rift:

  • 现在我们有更好的方法,但仍然可以做所有以前的方法:

  • 自然运动(平移/倾斜)- 仅限少量

  • 凝视检测

  • 通过定时器或凝视检测的人工运动('轨道'运动,就像你在轨道上)

  • 驾驶舱运动

  • 通过控制器进行瞬间移动

  • 操纵杆/控制器(在被跟踪的 6DOF 控制器上)

  • 被跟踪的 6DOF 控制器运动范式

  • 房间规模行走

  • 高端设备:

  • 全景虚拟跑步机或其他跑步机

避免幽灵效应

还有另一个原因,为什么我们希望人们能够在没有某种用户代理的情况下四处移动;没有移动,它真的不是虚拟现实。实际上,我们都在四处移动;猫在潜行时会侧着头。如果你感到好奇,你会歪着头。在 360 度视频中,一个挑战是你只能四处看看;你不能移动。歪着头真的没什么用。

360 度视频会发生什么,尽管它可能非常详细,但你会感觉自己像一个游荡的幽灵。你不能往下看到自己(尽管你可能会看到摄像机支架),你不能四处移动,也不能伸手触摸东西,也不能改变你的视角。如果你歪着头,或者左右移动,就没有视差效果。

我真的很喜欢 360 度视频,但我也觉得它并不真正是虚拟现实,因为最终你会感到游离,本质上是一个被束缚的幽灵。当然,视频可能会移动,但你无法改变它的移动方式;你只是随波逐流。

我对 WebVR 非常印象深刻的一个微妙之处是,如果你歪着头,VR 视图会稍微移动,就好像你在侧头。这是一个微妙的效果;它不是室内尺度的 VR,你不能四处走动,但它是一种 VR。你不会感觉自己像一个游荡的幽灵。

让人们探索他们的环境是很重要的;没有这一点,你真的会感觉自己像一个幽灵。在我们的例子中,我们将使用传送移动的隐喻,让人们探索一个迷宫。

没有与世界互动和移动的能力,你会感觉自己像一个游荡的幽灵。虽然我们几乎用了整本书的篇幅才达到这一点,但与环境和世界互动的能力是虚拟现实中最重要的事情之一。

在这一章中,你将能够使用任何 WebVR 客户端来做到这一点。如果我们知道每个人都有 HTC Vive 或室内尺度的 Oculus Rift,我们可以向你展示在迷宫中四处走动的代码,尽管这会带来一些有趣的用户界面问题——如果有人走过篱笆会怎么样?在我们获得全身触觉套装之前,你可以穿过虚拟墙。有一些使用用户界面来抵消这一点的方法,比如将屏幕短暂地变黑,然后将用户传送回起点,只是允许他们作弊(不好),或者其他有趣的方法来解决这个问题。

现在,我们将简单地允许用户移动到迷宫中的下一个单元格/开放位置,并且仅限于该位置。我们将使用凝视选择,这意味着当您盯着一个 UI 元素时,我们会知道您已经点击它。这将适用于市场上所有的 VR 设备,这真的是开始的最佳地点。更复杂的 UI 元素需要检查用户拥有的 VR 控制器和跟踪类型,并根据需要启用适当的移动。这超出了本书的范围。

在讨论如何在我们的世界中移动之前,我们需要有一些有趣的东西可以四处走动。例如,也许我们在森林中漫步,发现迷宫挡住了我们的去路,或者是清晨,我们想去一个小湖看清晨的雾。

让我们来建造那个迷宫。

建造迷宫

我们可以建造迷宫的几种方式。最直接的方法是启动我们的 3D 建模软件(比如 Blender)并用多边形创建一个迷宫。这样做效果很好,也可以非常详细。

然而,这也会很无聊。为什么?第一次通过迷宫会很激动,但几次尝试之后,你会知道通往目的地的路。当我们构建 VR 体验时,通常希望人们经常访问并每次都有愉快的时光。

建模的迷宫会很无聊。生命太短暂,没有时间做无聊的事情。

因此,我们希望随机生成一个“迷宫”。这样,您可以每次都改变“迷宫”,使其保持新鲜和不同。为了做到这一点,我们需要通过随机数来确保“迷宫”不会围绕我们移动,所以我们实际上希望用伪随机数来实现。要开始做到这一点,我们需要创建一个基本的应用程序。请转到您的 VR 目录并创建一个名为“WalkInAMaze”的应用程序:

react-vr init WalkInAMaze

几乎随机-伪随机数生成器

为了有机会重播价值或能够比较不同人之间的分数,我们真的需要一个伪随机数生成器。基本的 JavaScript Math.random()不是伪随机生成器;它每次都会给你一个完全随机的数字。我们需要一个带有种子值的伪随机数生成器。如果你给随机数生成器相同的种子,它将生成相同的随机数序列。(它们并不是完全随机的,但非常接近。)随机数生成器是一个复杂的话题;例如,它们被用于密码学,如果你的随机数生成器不是完全随机的,有人可能会破解你的代码。

我们不太担心这一点,我们只是想要可重复性。尽管这方面的用户界面可能超出了本书的范围,但以一种点击刷新不会生成完全不同的Maze的方式创建Maze真的是一件好事,会避免用户的沮丧。这也将允许两个用户比较分数;我们可以为Maze持续一个板号,并显示这个。这可能超出了我们书的范围;然而,拥有可预测的Maze在开发过程中将会极大地帮助。如果没有这一点,你可能会在工作中迷失方向。(好吧,可能不会,但这样测试会更容易。)

包含来自其他项目的库代码

到目前为止,我已经向你展示了如何在 React VR(或 React)中创建组件。有趣的是,JavaScript 在include方面有一个历史问题。在 C++、Java 或 C#中,你可以在另一个文件中include一个文件或在项目中引用一个文件。在那之后,那些其他文件中的所有内容,比如函数、类和全局属性(变量),都可以从发出include语句的文件中使用。

在浏览器中,“包含”JavaScript 的概念有点不同。在 Node.js 中,我们使用package.json来指示我们需要哪些包。要将这些包引入我们的代码中,我们将在.js 文件中使用以下语法:

var MersenneTwister = require('mersenne-twister');

然后,我们将创建一个新的随机数生成器并传递一个种子,而不是使用Math.random()

  var rng = new MersenneTwister(this.props.Seed);

从这一点开始,你只需要调用rng.random()而不是Math.random()

目前,我们只需使用 npm install <package>require 语句来正确格式化包。在下一章中,我们将讨论升级并修改 package.json,以确保代码正确地发布和更新。执行 npm 命令可以为您完成其中的大部分工作:

npm install mersenne-twister --save

记住,--save 命令用于更新项目中的清单。在此期间,我们还可以安装另一个以后会用到的包:

npm install react-vr-gaze-button --save

现在我们有一个很好的随机数生成器,让我们用它来复杂化我们的世界。

迷宫渲染()

我们如何构建一个 Maze?我想开发一些动态生成 Maze 的代码;任何人都可以在一个包中对其进行建模,但 VR 世界应该是活生生的。拥有能够动态构建 Maze 的代码(在一定程度上)将允许您重复玩您的世界。

有许多用于打印迷宫的 JavaScript 包。我选择了一个似乎无处不在的、公共领域的 GitHub 上的包,并对其进行了 HTML 修改。这个应用程序由两部分组成:Maze.htmlmakeMaze.JS。它们都不是 React,而是 JavaScript。它运行得相当不错,尽管数字并不真正代表宽度。

首先,我确保只有一个 x 在垂直和水平方向上显示。这样打印效果可能不好(行通常比列),但我们正在构建一个虚拟的 Maze,而不是纸质的 Maze

我们使用 Maze.htmllocalhost:8081/vr/maze.html)和 JavaScript 文件 makeMaze.js 生成的 Maze 现在看起来是这样的:

x1xxxxxxx
x   x   x
xxx x x x
x x   x x
x xxxxx x
x x   x x
x x x x x
x   x   2
xxxxxxxxx

这有点难以阅读,但你可以数一下方块和 x 的数量。别担心,它会看起来更加花哨。现在我们已经让 HTML 版本的 Maze 工作了,我们将开始建造树篱。

这段代码比我预期的要长一些,所以我把它分成了几部分,并将 Maze 对象加载到 GitHub 上,而不是在这里粘贴整个代码,因为它太长了。您可以在以下链接找到源代码:bit.ly/VR_Chap11

添加地板和类型检查

正如我们之前讨论过的,360 全景背景的一个奇怪之处是,你似乎可以“漂浮”在地面上。除了修复原始图像之外,另一个解决方法就是简单地添加一个地板。这就是我们在太空画廊中所做的,看起来相当不错,因为我们假设我们在太空中漂浮。

对于这个版本,让我们import一个地面方块。我们可以使用一个大方块来包含整个Maze;然后如果Maze的大小发生变化,我们就必须调整它的大小。我决定使用一个较小的立方体,并对其进行修改,使其“位于”Maze的每个单元格下方。这将使我们在将来有一些余地,可以旋转方块以制作磨损的路径、水陷阱或其他东西。

为了制作地板,我们将使用一个简单的立方体对象,我稍微修改了它,并进行了 UV 映射。我用 Blender 做的这个。我们还import了一个Hedge模型和一个Gem,它将代表我们可以传送到的地方。在Maze.js内部,我们添加了以下代码:

import Hedge from './Hedge.js';
import Floor from './Hedge.js';
import Gem from './Gem.js';

然后,在Maze.js内部,我们可以用以下代码实例化我们的地板:

<Floor X={-2} Y={-4}/>

注意,当我们进行导入时,我们不使用'vr/components/Hedge.js';我们在 Maze.js 内部。然而,在 index.vr.js 中包含 Maze 时,我们确实需要:

import Maze from './vr/components/Maze.js';

然而,情况稍微复杂一些。在我们的代码中,当属性发生变化时,迷宫会构建数据结构;在移动时,如果迷宫需要重新渲染,它会简单地遍历数据结构并构建一个包含所有地板、传送目标和树篱的集合(mazeHedges)。鉴于此,要创建地板,在Maze.js中的代码实际上是:

        mazeHedges.push(<Floor {...cellLoc} />);

在这里,我遇到了两个大问题,我会告诉你发生了什么,这样你就可以避免这些问题。最初,我一直在试图弄清楚为什么我的地板看起来像树篱。这个问题很容易——我们从Hedge.js文件中导入了Floor。地板看起来像树篱(你在我的前面的代码中注意到了吗?如果是的话,我是故意这样做的,作为一个学习经验。诚实地说)。

这是一个简单的修复。确保你的代码中有import Floor from './floor.js';注意Floor没有经过类型检查。(毕竟,这是 JavaScript。)我觉得这很奇怪,因为hedge.js文件导出了一个Hedge对象,而不是一个Floor对象,但请注意,你可以在import它们时重命名对象。

我遇到的第二个问题更像是一个简单的失误,如果你没有真正思考 React,很容易发生。你可能也会遇到这个问题。JavaScript 是一种可爱的语言,但有时我会想念一种强类型的语言。这是我做的:

<Maze SizeX='4' SizeZ='4' CellSpacing='2.1' Seed='7' />

maze.js文件中,我有这样的代码:

for (var j = 0; j < this.props.SizeX + 2; j++) {

经过一些调试,我发现j的值从0变成了42。为什么会变成42而不是6呢?原因很简单。我们需要充分理解 JavaScript 才能编写复杂的应用程序。错误在于将 SizeX 初始化为'4';这使它成为一个字符串变量。当从0(一个整数)计算j时,React/JavaScript 会取2,将其加到一个字符串'4'上,得到字符串42,然后将其转换为整数并赋给j

当这样做时,非常奇怪的事情发生了。

当我们构建 Space Gallery 时,我们可以轻松地使用'5.1'的值作为输入到框中:

<Pedestal MyX='0.0' MyZ='-5.1'/>

然后,在类中使用下面的转换语句:

 transform: [ { translate: [ this.props.MyX, -1.7, this.props.MyZ] } ]

React/JavaScript 会将字符串值放入This.Props.MyX,然后意识到它需要一个整数,然后悄悄地进行转换。然而,当你得到更复杂的对象,比如我们的Maze生成时,你就逃不过这一点。

记住,你的代码并不是“真正”的 JavaScript。它是经过处理的。在本质上,这种处理是相当简单的,但其影响可能是致命的。

注意你所编写的代码。在 JavaScript 这样的弱类型语言中,再加上 React,你所犯的任何错误都会悄悄地转换成你意想不到的结果。

你是程序员。要正确编程。

所以,回到MazeHedgeFloor基本上是初始Gem代码的副本。让我们来看看我们的起始Gem,尽管请注意它后来变得更加复杂(以及在你的源文件中):

import React, { Component } from 'react';
import {
    asset,
    Box,
    Model,
    Text,
    View
} from 'react-vr';

export default class Gem extends Component {
    constructor() {
        super();
        this.state = {
            Height: -3 };
    }
    render() {
        return (
            <Model
                source={{
                    gltf2: asset('TeleportGem.gltf'),
                }}
                style={{
                    transform: [{ translate: [this.props.X, this.state.Height, this.props.Z] }]
                }}
            />
        );
    }
}

HedgeFloor本质上是相同的东西。(我们本可以让一个 prop 成为加载的文件,但我们希望Gem有不同的行为,所以我们将大幅编辑这个文件。)

要运行这个示例,首先,我们应该像之前一样创建一个名为WalkInAMaze的目录。一旦你这样做了,从本章的 Git 源下载文件(bit.ly/VR_Chap11)。一旦你创建了应用程序,复制了文件并启动了它(进入WalkInAMaze目录并输入npm start),你应该看到类似这样的东西一旦你四处看看——除了有一个 bug。这就是迷宫应该看起来的样子(如果你在Hedge.js中使用文件'MazeHedges2DoubleSided.gltf',在<Model>语句中):

那么,我们是如何在游戏中得到那些看起来整洁的树篱的呢?(好吧,它们的多边形确实很低,但仍然可以。)Web 标准改进的速度之一是它们的新功能。现在,React VR 不仅支持.obj 文件格式,还可以加载 glTF 文件。

使用 glTF 文件格式进行建模

glTF 文件是一种新的文件格式,与 WebGL 非常自然地配合。有许多不同的 CAD 软件的导出器。我喜欢 glTF 文件的原因是,获得正确的导出相当简单。Lightwave OBJ 文件是行业标准,但在 React 的情况下,并非所有选项都被导入。一个主要的问题是透明度。OBJ 文件格式允许这样做,但在撰写本书时,这并不是一个选项。许多其他现代硬件可以处理的图形着色器无法用 OBJ 文件格式描述。

这就是为什么 glTF 文件是 WebVR 的下一个最佳选择。这是一种现代和不断发展的格式,正在努力增强功能,并在 WebGL 可以显示的内容和 glTF 可以导出的内容之间取得相当好的匹配。

然而,这是一章关于与世界互动的内容,所以我会简要提及如何导出 glTF 文件并提供对象,特别是Hedge,作为 glTF 模型。

从建模方面来看,glTF 的好处是,如果您使用它们的材质规范,例如 Blender,那么您就不必担心导出不够准确。今天的基于物理的渲染PBR)倾向于使用金属/粗糙模型,这些比尝试将 PBR 材质转换为 OBJ 文件的镜面光照模型更容易导入。这是我用作凝视点的看起来金属质的Gem

使用 glTF 金属粗糙模型,我们可以分配纹理贴图,例如 Substance Designer 等程序计算并轻松导入。结果看起来金属的地方看起来金属,油漆仍然保持的地方看起来暗淡。

我在这里没有使用环境遮挡,因为这是一个非常凸起的模型;表面凹陷更多的东西会与环境遮挡搭配得很棒。例如,对于建筑模型和家具,也会看起来很棒。

要转换您的模型,可以在bit.ly/glTFExporting找到用户文档。您需要下载并安装 Blender glTF 导出器。或者,您可以直接下载我已经转换过的文件。如果您要进行导出,简而言之,您需要执行以下步骤:

  1. bit.ly/gLTFFiles下载文件。您将需要gltf2_Principled.blend文件,假设您使用的是 Blender 的较新版本。

  2. 在 Blender 中,打开您的文件,然后链接到新的材质。转到文件->链接,然后选择gltf2_Principled.blend文件。一旦您这样做了,进入“NodeTree”,然后选择 glTF 金属粗糙度(用于金属)或其他材质的 glTF 高光光泽。

  3. 选择要导出的对象;确保选择 Cycles 渲染器。

  1. 在窗口中打开节点编辑器(就像您在之前的章节中处理图像时所做的那样)。向下滚动到节点编辑器窗口的底部,并确保“使用节点”框被选中。

  1. 通过节点菜单添加节点,添加->组->glTF 高光光泽或金属粗糙度。

  2. 添加节点后,转到添加->纹理->图像纹理。添加与图像地图数量相同的图像纹理,然后将它们连接起来。您应该得到类似于这个图表的东西。

  1. 要导出模型,我建议您禁用相机导出并合并缓冲区,除非您认为将要导出共享几何图形或材质的多个模型。我使用的导出选项如下:

现在,要包含导出的 glTF 对象,使用<Model>组件,就像使用 OBJ 文件一样,只是没有 MTL 文件。所有材质都在.glTF 文件中描述。要包含导出的 glTF 对象,只需将文件名作为<Model中的 gltf2 属性:

 <Model
 source={{ gltf2: asset('TeleportGem2.gltf'),}}
...

要了解更多关于这些选项和流程的信息,您可以访问 glTF 导出网站:bit.ly/WebGLTF。该网站还包括主要 CAD 软件的教程以及非常重要的 glTF 着色器(例如,我之前展示的 Blender 模型)。

我已经加载了几个.OBJ 文件和.glTF 文件,您可以在bit.ly/VR_Chap11上尝试不同的低多边形和透明度的组合。当在 React VR 版本 2.0.0 中添加了 glTF 支持时,我感到非常兴奋,因为透明度贴图对于许多 VR 模型非常重要,特别是植被;就像我们的树篱一样。然而,事实证明在 WebGL 或 three.js 中存在一个 bug,无法正确渲染透明度。因此,我在 GitHub 网站上的文件中选择了低多边形版本;上面的图片是使用Hedges.js文件中的MazeHedges2DoubleSided.gltf文件(在 vr/components 中)。

如果您遇到 404 错误,请检查 glTF 文件中的路径。这取决于您使用的导出器——如果您使用的是 Blender,Khronos 组的 gltf2 导出器会正确计算路径,但 Kupoman 的导出器有选项,您可能会导出错误的路径。

动画 — VR 按钮

好了!我们想要做一些动画。为了做到这一点,我们将使用 VRButton。当发生以下情况之一时,它会激活:

  • XBox 游戏手柄上的 A 按钮

  • 键盘上的空格键

  • 用鼠标左键单击

  • 屏幕上的触摸

不幸的是,我们的“最低公共分母”是 Google Cardboard,可能有,也可能没有按钮。您不想不得不把手指伸进去尝试触摸屏幕。(说了这些之后,更新的 VR 头盔有一个小杠杆可以戳屏幕,即使是在实际的硬纸板版本中)。我们将使用凝视按钮。当鼠标指针或屏幕中心(由一个小点标记)悬停在您的对象上时,事件将被调用,我们的代码将处理这个问题。

凝视按钮也被打包成了npm生态系统中的一个漂亮的<GazeButton>对象。请参考网页:bit.ly/GazeButton。要使用它,我们需要了解它的功能,以及如何让视图知道一个Gem已经被“触摸”(或者被观察了两秒)。我们在本章的前面已经安装了它;如果你到目前为止还没有安装,我们可以通过使用 Node.js 命令提示符并输入以下命令来安装它:

npm install react-vr-gaze-button

我们可以使用 VR 按钮,但那样我们就必须处理进入对象、离开对象、倒计时等等。GazeButton会为我们处理所有这些。请注意,它对子元素的期望方式与我们到目前为止所习惯的方式有些不同。

现在,您的Gem.js代码(注意大写)应该如下所示:

import GazeButton from 'react-vr-gaze-button'
export default class Gem extends Component {

  constructor() {
    super();
    this.state = {
      Height: -3,
      buttonIsClicked: false
    };
  }
  onGemClicked() {
    this.setState({ buttonIsClicked: true });
    console.log("Clicked on gem " + this.props.X + " x " + this.props.Z);
  }
  render() {
    const { buttonIsClicked } = this.state
    return (
      <GazeButton onClick={() => this.onGemClicked()}
        duration={2000}>
        {time => (

          <Model
            source={{
              gltf2: asset('TeleportGem.gltf'),
            }}
            style={{
              transform: [{ translate: [0, -1, 0] }]
            }}
            style={{
              transform: [{ translate: 
                [this.props.X, this.state.Height, this.props.Z] }]
            }}
          />
        )}
      </GazeButton>
    );
  }
}

现在,当我们在桌面上尝试这样做时,似乎可以工作,但在手机上(我尝试了三星 GearVR),没有光标,也没有可以点击的东西。我们需要实现一个射线投射器(即使没有控制)。

正如我们在本章开头简要讨论的那样,有许多不同类型的 VR 控制系统,默认情况下是“没有”VR 输入设备,包括屏幕中心光标。

适当的控制系统的实施在我们手中。

当您使用桌面浏览器进行初始开发时,您会得到一个鼠标光标(包括在跟踪组件上时的光标),这可能意味着内置了注视光标;实际上并没有。只需意识到这是有合理理由的。

射线投射器

射线投射器向世界发射一条射线并计算它触及了什么。通常您会看到这些作为 VR 控制器发出的发光线。没有控制器时,射线投射器将从屏幕中心发射一条射线;这正是我们需要实现我们的注视按钮的地方。

在这种情况下,就像我们对按钮所做的那样,已经有一个simple-raycaster。如果您还没有安装它,您需要通过以下命令从npm安装它:

npm install --save simple-raycaster

在尝试使用软件包时,您可能希望跳过--save; 如果您这样做,请记得手动更新您的package.json文件,或者通过适当的工具进行更新。

实现simple-raycaster非常容易。在client.js中,在现有的import行(VRInstance)下面,添加以下import语句:

import * as SimpleRaycaster from "simple-raycaster";

在“//在此处添加自定义选项”处,插入以下行:

    raycasters: [
         SimpleRaycaster // Add SimpleRaycaster to the options
    ],
    cursorVisibility: "auto", // Add cursorVisibility

在您的 PC 上,此时情况会有点奇怪——屏幕中心会激活(并丢弃)宝石,即使您没有点击。这正是整个重点。

如果我们有更多的页面,当你的目光进入宝石时,我们会让宝石旋转。但现在,我们将把这个练习留给读者。

您将希望在onClick处理程序中开始动画。

到目前为止,我们已经展示了当注视宝石时如何获得事件。这很好,我们可以使用事件来触发移动,但我们如何移动呢?

有一件有点奇怪的事情是,React VR 没有像许多图形系统那样移动摄像头的方法。要移动当前的视角,你需要在index.vr.js的开头将<View>向相反方向进行平移;这会使世界中的一切朝相反方向移动,看起来就像你在向前移动。要移动视角,我们需要将点击事件从Gem传递给其父级的父级(顶级 View)。

Props,state 和 events

React,以及 React VR,在其核心,以可预测、确定的方式处理 props、事件和状态,这就是使 React 应用程序保持一致、清晰且易于维护的原因。

当对象声明时创建 props,并且在对象的生命周期内不应更改。如果对象需要更改,例如我们的传送门宝石,那么应将这些值分配给state。这强制实现了自顶向下的单向数据流。如果组件在不同区域需要相同的状态,那么该state应该被提升到最高级的父级。

这会引发有趣的问题,如果你想让一个子组件告诉父组件有关事件的信息,或者根据较低级别的事件改变其状态。

处理这个问题有几种方法;在 React 世界中,这可能是一个复杂的主题。React VR 在处理状态、props 和事件方面与 React Native 或 React 没有区别。一个很好的起点是 React 文档中的State and Lifecycle

基本上,在 React 应用程序中,应该有一个用于变化的单一真相来源。如果父级不关心,例如Gem是更高还是更低(被踩或未被踩),那么你不需要让父级跟踪其子级的高度。将state保持在尽可能低的级别是正确的决定。高度可以从“我们是否踩了Gem”中计算出来,因此不应该是传递下来的 prop。(尽管在很多书籍文件中,出于简洁起见,我们已经硬编码了值,但你可能会考虑起始高度作为一个 prop;良好的编程规范说不要硬编码值。)

在我们的迷宫世界中,我们遇到了一个困境。我们通过改变世界树顶部的<View>节点来移动视角。然而,当我们点击每个<Gem>时,我们希望视图发生变化。

我们可以用上下文来处理这个问题;许多库,比如 Redux 或 MobX,在内部使用上下文。还有一些使用上下文和其他功能的事件库。然而,上下文是一个有点高级的概念,对于我们正在做的事情来说有点过度。

在这种特殊情况下,我们将简单地将一个“回调”函数传递到子树中。我们这样做的原因如下:

  • 此时,从层次结构的角度来看,我们的应用程序相当小,只有三个级别。

  • “迷宫”本身可能需要知道用户何时到达终点(例如,显示烟花或更新高分)。“宝石”不知道这一点。如果“宝石”直接向视图发送通知,那么“迷宫”将永远不知道。

  • 我们可以引入额外的库,但这是一个简单的项目,在开源世界中太多外部依赖可能会导致问题。通常这不是一个大问题,如果出了问题,那就是开源的,去修复它。

如果在寻找外部包时出现问题,你需要卸载有问题的包,然后通过运行以下命令重新启动你的 Node.js 服务器:

npm start -- --reset-cache

npm cache clean --force不会执行此缓存重置。如果你忘记了,你得到的错误消息应该指出这一点。

使更新向上流动

尽管更新会传播下来,但我们需要传递信息。我们怎么做?很简单,用一个功能性的“回调”。

index.vr.js中,创建一些例行程序,并将这些例行程序与WalkInAMaze组件进行重要的绑定。在这一点上,我只展示了更改的行:

constructor(props) {
  super(props);
  this.state = {
    // ... existing member state initialization
  }
  this.handleClickGem = this.handleClickGem.bind(this); 
};

onClickFloor(X, Z) {
  this.setState({ newX: X, newZ: Z });
}

handleClickGem(X, Z) {
  this.setState({ newX: X, newZ: Z });
};

在我们的Gem.js中,我们已经有一个onClick方法。我们只需要添加一些新的props

    onGemClicked() {
        this.setState({ buttonIsClicked: true });
        //send it to the parent
        this.props.onClickGem(this.props.X, this.props.Z);
    }

现在,这个this.props.onClickGem是什么?这是一个从父级传递的函数 prop。在我们创建“宝石”时,我们只需插入以下 prop(加粗的插入行,注意源代码不能加粗):

... 
mazeHedges.push(<Gem {...cellLoc}
 onClickGem={this.handleClickGem}
  />);

好的,我们从哪里得到this.handleClickGem?在这个(简单)情况下,“迷宫”不会对事件做任何处理,只是将其传递。在Maze.js中,我们将添加一个处理程序:

constructor(props) {
  super(props);
  // existing code here doesn't change
  // at the bottom:
  this.handleClickGem = this.handleClickGem.bind(this);
}

handleClickGem(X, Z) {
  this.props.onClickGem(X, Z);
}

现在,我们注意到这里还有另一个 prop。这当然是由迷宫的父级传递给我们的;所以,在index.vr.js中,添加(加粗)这一行:

<Maze sizeX={this.state.sizeX} sizeZ={this.state.sizeZ} 
  cellSpacing={this.state.cellSpacing} seed={this.state.seed}
  onClickGem={this.handleClickGem} />

基本上就是这样。当Gem的 VR 注视按钮检测到点击时会发生什么?它调用了一个函数作为 prop。这会导致迷宫的handleClickGem被调用;它反过来调用了index.vr.js中的handleClickGem()。这个例程(双关语)然后设置内部状态。这个状态导致视图重新渲染:

handleClickGem(X, Z) {
        this.setState({ startX: -X, startZ: -Z });
    };

就是这样。请注意,你不仅仅通过this.startX = -X来设置state,你需要像前面的代码中所示调用this.setState()。然后这个例程将处理render()更新的传递。

这些都是大文件,我们刚刚做了很多改变。我在上面标出了重要的行,但我强烈建议你从bit.ly/VR_Chap11下载源文件并看看我们做了什么。在其中,我建立了一个 4x4 的迷宫,应该在大多数 PC 和移动设备上有合理的帧率。你可以尝试一些其他版本的各种对象(看起来像树篱或低多边形树篱)。

接下来怎么办?

这是一个相当基础的游戏,但你可以做很多事情。我们之前讨论过的一些东西,很容易包括在内,比如:

  • 我们的传送有点突然。我们应该有声音,或者甚至通过改变HandleClickGem()例程进行两次更新,以添加一个简短的动画或两步传送。请注意,通常不建议平滑地动画化视图本身;这会让人们感到不适,因为他们的眼睛说他们在移动,但他们的身体说没有。

  • 点击的宝石数量可以成为得分。这使我们有优势可以慢慢前进,一步一步地点击所有的宝石。

  • 你可以计时到达出口所需的时间,较短的时间可以增加你的得分。这使得快速前进并跳过传送宝石有优势。这两个目标是互斥的,通过平衡可以使游戏变得有趣。

  • 你可以在迷宫前面加入按钮来增加/减少大小或生成不同的随机数(并显示它们)。

  • 得分和随机数可以加载到高分 API 中。

  • 事件传递库,比如eventing-bus,使传递props变得更容易。我的目标是向你展示 React VR 的做法。

总结

在本章中,我们学习了构建完整的网络应用程序和游戏的最后一部分;再加上我们之前学到的知识,我们的旅程几乎已经完成;这实际上只是将现实带到网络的第一步。我们讨论了如何在 VR 世界中移动,包括基本的传送机制。我们讨论了凝视按钮和使用射线投射来实现它们。我们讨论了propsstate和事件的重要机制。为了实现这些流程,我们复习了重要的 React 哲学,即将state向上传递并处理下游事件。我们还讨论了使用伪随机数生成器来确保我们的propsstate不会发生混乱性变化。总的来说,我们现在知道如何创建、在其中移动,并使世界对我们做出反应。

在下一章中,我们将讨论接下来该怎么做,如何升级 React VR,以及如何在互联网上发布你的虚拟世界。